# Verify a webhook

> Check a webhook's signature against the raw body in TypeScript, Python or Java, 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.5.0/guides/webhooks/receive-run-finished.md) shows.
- **For TypeScript:** Node 22.12 or later, with Kindgi 0.2's `@kindgi/sdk`.
- **For Python:** Python 3.11 or later, with Kindgi 0.2's `kindgi`, and FastAPI, uvicorn and httpx.
- **For Java or Scala:** JDK 17 or later, with Kindgi 0.2's `com.kindgi:kindgi-client`. Scala apps call the helper the same way.

## 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.5.0/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**

  Use `Webhooks.verify` from `com.kindgi.client.webhooks`, then `Webhooks.parseEvent` for the typed event. `verify` takes your endpoint's one signing secret. This receiver uses the JDK's built-in `HttpServer`; with a framework (a servlet, Spring), pass the request's headers as a lookup by lower-case name and its unparsed body the same way.

  `src/main/java/com/acme/app/Receiver.java`

  ```java
  package com.acme.app;

  import com.kindgi.client.KindgiJsonException;
  import com.kindgi.client.models.RunFinishedEvent;
  import com.kindgi.client.models.WebhookEvent;
  import com.kindgi.client.webhooks.VerifiedWebhook;
  import com.kindgi.client.webhooks.WebhookVerificationException;
  import com.kindgi.client.webhooks.Webhooks;
  import com.sun.net.httpserver.HttpExchange;
  import com.sun.net.httpserver.HttpServer;
  import java.io.IOException;
  import java.net.InetSocketAddress;
  import java.util.Set;
  import java.util.concurrent.ConcurrentHashMap;

  public final class Receiver {
    private static final String secret = System.getenv("ACME_WEBHOOK_SECRET");
    private static final Set<String> handled = ConcurrentHashMap.newKeySet(); // use your database in production

    public static void main(String[] args) throws IOException {
      HttpServer server = HttpServer.create(new InetSocketAddress(8789), 0);
      server.createContext("/hooks/kindgi", Receiver::handle);
      server.start();
      System.out.println("listening on http://localhost:8789/hooks/kindgi");
    }

    private static void handle(HttpExchange exchange) throws IOException {
      if (!"POST".equals(exchange.getRequestMethod())) {
        respond(exchange, 404);
        return;
      }
      byte[] body = exchange.getRequestBody().readAllBytes(); // the raw bytes, unparsed

      VerifiedWebhook delivery;
      try {
        // The lookup takes a lower-case header name, like a servlet request's getHeader.
        delivery = Webhooks.verify(secret, name -> exchange.getRequestHeaders().getFirst(name), body);
      } catch (WebhookVerificationException err) {
        System.out.println("rejected: " + err.reason());
        respond(exchange, 401);
        return;
      }
      if (!handled.add(delivery.id())) {
        respond(exchange, 204); // a retry of an event you already handled
        return;
      }

      WebhookEvent event;
      try {
        event = Webhooks.parseEvent(body);
      } catch (KindgiJsonException err) {
        // Signed, but a known type whose fields this version can't read: a retry wouldn't read better.
        System.out.println("skipped: " + err.getMessage());
        respond(exchange, 204);
        return;
      }
      if (event instanceof RunFinishedEvent finished) {
        var run = finished.data().run();
        System.out.println("run finished: " + run.id() + " " + run.flowId() + " " + run.status().asString());
      } else if (event instanceof WebhookEvent.Unrecognized) {
        // A newer runtime's event type this version doesn't know: answer 2xx and ignore it.
        System.out.println("skipped: " + event.type());
      } else {
        System.out.println("event: " + event.type());
      }
      respond(exchange, 204);
    }

    private static void respond(HttpExchange exchange, int status) throws IOException {
      exchange.sendResponseHeaders(status, -1);
      exchange.close();
    }

    private Receiver() {}
  }
  ```

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

- **Scala**

  Scala apps use the Java helper. Call `Webhooks.verify` and `Webhooks.parseEvent` from `com.kindgi.client.webhooks` as the Java tab shows — pass the request's headers as a `java.util.Map[String, String]` (names match case-insensitively) and the unparsed body as `Array[Byte]`.

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

  `src/main/java/com/acme/app/TestReceiver.java`

  ```java
  package com.acme.app;

  import com.kindgi.client.webhooks.Webhooks;
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.nio.charset.StandardCharsets;
  import java.time.Instant;
  import java.util.List;
  import java.util.Map;

  public final class TestReceiver {
    public static void main(String[] args) throws IOException, InterruptedException {
      String body =
          "{\"id\":\"evt-test-1\",\"type\":\"webhook.test\","
              + "\"createdAt\":\"2026-10-03T12:00:00.000Z\",\"data\":{\"endpointId\":\"local\"}}";
      byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
      long timestamp = Instant.now().getEpochSecond();

      HttpClient http = HttpClient.newHttpClient();
      for (String secret : List.of(System.getenv("ACME_WEBHOOK_SECRET"), Webhooks.generateSecret())) {
        Map<String, String> signed = Webhooks.signedHeaders(secret, "evt-test-1", timestamp, bytes);
        HttpRequest.Builder request =
            HttpRequest.newBuilder(URI.create("http://localhost:8789/hooks/kindgi"))
                .header("content-type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofByteArray(bytes));
        signed.forEach(request::header);
        HttpResponse<Void> res = http.send(request.build(), HttpResponse.BodyHandlers.discarding());
        System.out.println(res.statusCode());
      }
    }

    private TestReceiver() {}
  }
  ```

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

  ```text
  204
  401
  ```

- **Scala**

  Scala apps use the Java helper: `Webhooks.generateSecret` for a secret and `Webhooks.signedHeaders` for the three signed headers, from `com.kindgi.client.webhooks`, as the Java tab shows. Send the same bytes you signed.

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.5.0/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.5.0/guides/webhooks/test-and-replay.md)
- [Webhook events and deliveries](https://docs.kindgi.com/v0.5.0/guides/webhooks/webhook-events.md)
- [Show runs in your app](https://docs.kindgi.com/v0.5.0/guides/runs/show-runs-in-your-app.md)
