Chapter 1Adding an endpoint
- In API and webhooks, tap “Add endpoint”
- Type your server URLIt must be https and public (no localhost or internal IPs). Redirects are not followed.
- Choose events“All events”, or the ones you want. A group wildcard such as
milestone.*works too. - Copy the signing secretStore it as
KROWS_WEBHOOK_SECRET. - Tap “Send test event”A
test.pingarrives. Check the result in the log.
Each endpoint belongs to one mode: test or live, per the selector.
Chapter 2Verifying the signature
Each delivery is a POST with Content-Type: application/json, User-Agent: Krows-Webhooks/1.0 and the Krows-Signature: t=<timestamp>,v1=<signature> header. The signature is the hex HMAC-SHA256 of t + "." + body with your endpoint secret. Use the raw body exactly as received.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Use the raw body: the signature is computed over the exact bytes we send
app.post('/webhooks/krows', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Krows-Signature') ?? '';
const { t, v1 } = Object.fromEntries(header.split(',').map((x) => x.split('=')));
const expected = crypto.createHmac('sha256', process.env.KROWS_WEBHOOK_SECRET)
.update(`${t}.${req.body}`).digest('hex');
const valid = v1 && v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
// Reject anything older than 5 minutes to stop replays
if (!valid || Date.now() / 1000 - Number(t) > 300) return res.sendStatus(400);
const event = JSON.parse(req.body);
switch (event.type) {
case 'investment.confirmed':
// event.data is the investment: amount, tokens, investor, reference, metadata…
break;
case 'round.succeeded':
break;
}
res.sendStatus(200); // answer 2xx within 10 seconds
});import hmac, hashlib, os, time, json
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["KROWS_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/krows")
def krows():
header = request.headers.get("Krows-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
body = request.get_data() # raw bytes
expected = hmac.new(SECRET, f"{parts.get('t')}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(parts.get("v1", ""), expected) or time.time() - int(parts.get("t", 0)) > 300:
abort(400)
event = json.loads(body)
if event["type"] == "investment.confirmed":
pass # event["data"] is the investment
return "", 200<?php
$secret = getenv('KROWS_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_KROWS_SIGNATURE'] ?? ''), $p);
$expected = hash_hmac('sha256', ($p['t'] ?? '') . '.' . $body, $secret);
if (!hash_equals($expected, $p['v1'] ?? '') || time() - (int)($p['t'] ?? 0) > 300) {
http_response_code(400); exit;
}
$event = json_decode($body, true);
if ($event['type'] === 'investment.confirmed') {
// $event['data'] is the investment
}
http_response_code(200);{
"id": "evt_Qm3x…",
"object": "event",
"type": "investment.confirmed",
"mode": "live",
"created": "2026-10-17T10:03:13.000Z",
"data": {
"object": "investment",
"id": "inv_…",
"status": "confirmed",
"amount": 250,
"currency": "USDC",
"tokens": 250,
"investor": { "alias": "ana_investor", "wallet": "7xKX…gAsU" },
"reference": "order-1234",
"metadata": { "campaign": "autumn" },
"transaction": "5hTq…"
}
}Chapter 3Responses and retries
- Reply 2xx within 10 seconds. If you have long work, queue it and reply right away.
- On failure we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours: up to 6 retries.
- The same event can arrive more than once: store the event
idand process it once. - Order is not guaranteed: use
createdand query the API if needed. - If you missed something,
GET /v1/eventsreturns the history.
Chapter 4Every event
| Event | When |
|---|---|
| investment.confirmed | Someone invested |
| investment.withdrawn | An investor withdrew |
| checkout.session.completed | An API-created session completes |
| round.succeeded | The round reaches 100% |
| round.failed | The round fails or is cancelled |
| tokens.delivered | An investor receives tokens |
| refund.completed | USDC refunded to an investor |
| milestone.requested | You request a milestone and a vote opens |
| milestone.approved | Backers approve a milestone and funds are released |
| milestone.rejected | Backers reject a milestone |
| report.delivered | You deliver a report |
| referendum.opened | A vote to close the startup opens |
| referendum.closed | The closing vote ends |
| free_withdrawal.requested | You request a free withdrawal and the veto period starts |
| free_withdrawal.paid | Nobody vetoes it and the contract pays you |
| free_withdrawal.vetoed | Backers veto it |
| test.ping | Test event from the dashboard |
Was this helpful?
Thanks. We will use it to improve.