# Verify a webhook

> Check a webhook's signature against the raw body in TypeScript or Python, reject anything else, and handle each event once.

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

- **A webhook endpoint registered,** with its signing secret stored as `ACME_WEBHOOK_SECRET`, as [Get a webhook when a run finishes](https://docs.kindgi.com/v0.1.10/guides/webhooks/receive-run-finished.md) 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

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:

- **TypeScript**

  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`

  ```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.

- **Python**

  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:

  ```sh
  uv add fastapi uvicorn httpx
  ```

  It prints the packages it installed. uvicorn serves the receiver, and httpx sends the requests in [Test the receiver without Kindgi](https://docs.kindgi.com/v0.1.10/guides/webhooks/verify-a-webhook.md#test-the-receiver-without-kindgi).

  `receiver.py`

  ```python
  import os

  from fastapi import FastAPI, Request, Response
  from 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.

- **Java**

  Not in the Java SDK. Kindgi's deliveries are [Standard Webhooks](https://www.standardwebhooks.com/), so a Standard Webhooks library verifies them with the same secret: see [Other languages](https://docs.kindgi.com/v0.1.10/guides/webhooks/verify-a-webhook.md#other-languages).

- **Scala**

  Not in the Scala SDK. Kindgi's deliveries are [Standard Webhooks](https://www.standardwebhooks.com/), so a Standard Webhooks library for the JVM verifies them with the same secret: see [Other languages](https://docs.kindgi.com/v0.1.10/guides/webhooks/verify-a-webhook.md#other-languages).

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

```text
listening on http://localhost:8787/hooks/kindgi
run finished: 9fefc7cf-3249-4ba2-a50e-2795f16c7c67 acme.review-order completed
```

## What verification checks

- **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.

## 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

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:

- **TypeScript**

  `test-receiver.ts`

  ```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:

  ```text
  204
  401
  ```

- **Python**

  `test_receiver.py`

  ```python
  import json
  import os
  import 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:

  ```text
  204
  401
  ```

- **Java**

  Not in the Java SDK. Kindgi's deliveries are [Standard Webhooks](https://www.standardwebhooks.com/), so a Standard Webhooks library verifies them with the same secret: see [Other languages](https://docs.kindgi.com/v0.1.10/guides/webhooks/verify-a-webhook.md#other-languages).

- **Scala**

  Not in the Scala SDK. Kindgi's deliveries are [Standard Webhooks](https://www.standardwebhooks.com/), so a Standard Webhooks library for the JVM verifies them with the same secret: see [Other languages](https://docs.kindgi.com/v0.1.10/guides/webhooks/verify-a-webhook.md#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](https://docs.kindgi.com/v0.1.10/guides/webhooks/test-and-replay.md) shows.

## Other languages

The format is [Standard Webhooks](https://www.standardwebhooks.com/): its libraries verify Kindgi's requests with the same secret.

## Next

- [Test and replay deliveries](https://docs.kindgi.com/v0.1.10/guides/webhooks/test-and-replay.md)
- [Webhook events and deliveries](https://docs.kindgi.com/v0.1.10/guides/webhooks/webhook-events.md)
- [Show runs in your app](https://docs.kindgi.com/v0.1.10/guides/runs/show-runs-in-your-app.md)
