Verify a webhook
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.
Before you start
Section titled “Before you start”- 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.
A receiver
Section titled “A receiver”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.
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.
Use kindgi.webhooks.verify. This receiver, receiver.py, uses FastAPI. Any
framework works the same way: pass the headers and the body's bytes.
Add FastAPI, uvicorn and httpx to your app's project:
uv add fastapi uvicorn httpxIt prints the packages it installed. uvicorn serves the receiver, and httpx sends the requests in Test the receiver without Kindgi.
import os
from fastapi import FastAPI, Request, Responsefrom pydantic import ValidationError
from kindgi import webhooks
app = FastAPI()secret = os.environ["ACME_WEBHOOK_SECRET"]handled: set[str] = set() # use your database in production
@app.post("/hooks/kindgi")async def kindgi_webhook(request: Request) -> Response: body = await request.body() # the raw bytes, unparsed try: delivery = webhooks.verify(secret, request.headers, body) except webhooks.WebhookVerificationError as err: print("rejected:", err.reason) return Response(status_code=401) if delivery.id in handled: return Response(status_code=204) # a retry of an event you already handled handled.add(delivery.id)
try: event = webhooks.parse_event(body) except ValidationError as err: # 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. print("skipped:", err.errors()[0]["type"]) return Response(status_code=204) if event.type == "run.finished": run = event.data.run print("run finished:", run.id, run.flow_id, run.status) else: print("event:", event.type) return Response(status_code=204)Run it with ACME_WEBHOOK_SECRET set: uv run uvicorn receiver:app --port 8788.
receiver:app is the file's app, and --port the port it listens on.
Not in the Java SDK. Kindgi's deliveries are Standard Webhooks, so a Standard Webhooks library verifies them with the same secret: see Other languages.
Not in the Scala SDK. Kindgi's deliveries are Standard Webhooks, so a Standard Webhooks library for the JVM verifies them with the same secret: see Other languages.
With the endpoint registered, the TypeScript receiver prints this for a run
of acme.review-order:
listening on http://localhost:8787/hooks/kindgirun finished: 9fefc7cf-3249-4ba2-a50e-2795f16c7c67 acme.review-order completedWhat verification checks
Section titled “What verification checks”- The signature.
webhook-signaturemust hold an HMAC-SHA256 of<webhook-id>.<webhook-timestamp>.<body>made with your secret. The comparison is constant-time. - The time.
webhook-timestampmust 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.
Handle each event once
Section titled “Handle each event once”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.
Test the receiver without Kindgi
Section titled “Test the receiver without Kindgi”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:
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:
204401import jsonimport osimport time
import httpx
from kindgi import webhooks
body = json.dumps( {"id": "evt-test-1", "type": "webhook.test", "createdAt": "2026-10-03T12:00:00.000Z", "data": {"endpointId": "local"}})
for secret in [os.environ["ACME_WEBHOOK_SECRET"], webhooks.generate_secret()]: headers = webhooks.signed_headers(secret, id="evt-test-1", timestamp=int(time.time()), body=body) res = httpx.post( "http://localhost:8788/hooks/kindgi", headers={"content-type": "application/json", **headers}, content=body, ) print(res.status_code)Run it with ACME_WEBHOOK_SECRET set, while the receiver runs:
uv run python test_receiver.py. It prints each request's status:
204401Not in the Java SDK. Kindgi's deliveries are Standard Webhooks, so a Standard Webhooks library verifies them with the same secret: see Other languages.
Not in the Scala SDK. Kindgi's deliveries are Standard Webhooks, so a Standard Webhooks library for the JVM verifies them with the same secret: see Other languages.
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.
Other languages
Section titled “Other languages”The format is Standard Webhooks: its libraries verify Kindgi's requests with the same secret.