Skip to content

Verify a webhook

View as Markdown

This guide makes your app act only on webhook requests Kindgi signed, and on each event once.

Anyone can send your URL a request. Before you act on one, check that Kindgi signed it with your secret, against the raw body, before you parse it.

  • A webhook endpoint registered, with its signing secret stored as ACME_WEBHOOK_SECRET, as Get a webhook when a run finishes shows.
  • For TypeScript: Node 22.12 or later, with Kindgi 0.1's @kindgi/sdk.
  • For Python: Python 3.11 or later, with Kindgi 0.1's kindgi, and FastAPI, uvicorn and httpx.

The receiver checks each request's signature before it reads the event.

Ask your coding agent: "I want my app to receive Kindgi's webhooks, and act only on the ones Kindgi signed." For reference, the receiver looks like this:

Use verifyWebhook from @kindgi/sdk/webhooks, then parseEvent for the typed event. verifyWebhook runs on a server only, since it uses node:crypto. This receiver uses Node's http module. With a framework, pass the request's headers and its unparsed body the same way.

receiver.ts
import { createServer } from 'node:http';
import { parseEvent, verifyWebhook } from '@kindgi/sdk/webhooks';
const secret = process.env.ACME_WEBHOOK_SECRET!;
const handled = new Set<string>(); // use your database in production
createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/hooks/kindgi') {
res.writeHead(404).end();
return;
}
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const body = Buffer.concat(chunks).toString('utf8'); // the raw body, unparsed
const result = verifyWebhook({ secret, headers: req.headers, body });
if (result.kind !== 'ok') {
console.log('rejected:', result.reason);
res.writeHead(401).end();
return;
}
if (handled.has(result.id)) {
res.writeHead(204).end(); // a retry of an event you already handled
return;
}
handled.add(result.id);
const read = parseEvent(body);
if (read.kind !== 'ok') {
// Signed, but this version can't read it (most likely a newer runtime's event type):
// take it and leave it; a retry wouldn't read any better.
console.log('skipped:', read.reason, read.message);
res.writeHead(204).end();
return;
}
const event = read.event;
if (event.type === 'run.finished') {
const { id, flowId, status } = event.data.run;
console.log('run finished:', id, flowId, status);
} else {
console.log('event:', event.type);
}
res.writeHead(204).end();
}).listen(8787, () => console.log('listening on http://localhost:8787/hooks/kindgi'));

Run it with ACME_WEBHOOK_SECRET set, the way your app runs TypeScript.

With the endpoint registered, the TypeScript receiver prints this for a run of acme.review-order:

listening on http://localhost:8787/hooks/kindgi
run finished: 9fefc7cf-3249-4ba2-a50e-2795f16c7c67 acme.review-order completed
  • The signature. webhook-signature must hold an HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body> made with your secret. The comparison is constant-time.
  • The time. webhook-timestamp must be within 5 minutes of your clock, so an old request can't be replayed later.
  • The headers. A request without the three webhook-* headers fails.

A failure says which check failed, in result.reason in TypeScript or err.reason in Python: missing-headers, invalid-timestamp, timestamp-out-of-tolerance, invalid-secret or no-matching-signature. Answer 401 and do nothing else.

Verify the body exactly as it arrived. Parsing it and serializing it again changes the bytes, such as its spacing and key order, and the signature no longer matches.

Deliveries are at least once: a retry after a timeout can bring an event you already handled.

The verified result has the event's id, result.id or delivery.id, the same on every retry. Record the ids you've handled, in your database rather than in memory as in the examples. Answer 2xx to a repeat without acting again.

Each event then changes your app once, however often it arrives.

A request you sign yourself tests the receiver without a run. Ask your coding agent: "I want to test my webhook receiver without starting a run." For reference, this script signs one with the same secret, and one with a wrong one:

test-receiver.ts
import { generateWebhookSecret, webhookHeaders } from '@kindgi/sdk/webhooks';
const body = JSON.stringify({
id: 'evt-test-1',
type: 'webhook.test',
createdAt: new Date().toISOString(),
data: { endpointId: 'local' },
});
for (const secret of [process.env.ACME_WEBHOOK_SECRET!, generateWebhookSecret()]) {
const headers = webhookHeaders({
secret,
id: 'evt-test-1',
timestamp: Math.floor(Date.now() / 1000),
body,
});
if (headers.kind === 'err') throw new Error(headers.error.message);
const res = await fetch('http://localhost:8787/hooks/kindgi', {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers.value },
body,
});
console.log(res.status);
}

Run it with ACME_WEBHOOK_SECRET set, while the receiver runs, the way your app runs TypeScript. It prints each request's status:

204
401

The receiver accepts the first and logs rejected: no-matching-signature for the second. To test with Kindgi itself, send a webhook.test event, as Test and replay deliveries shows.

The format is Standard Webhooks: its libraries verify Kindgi's requests with the same secret.