---
title: Verifying webhooks
description: verifyWebhook checks the sume-v1 signature on a Sume delivery — raw bodies, header shapes, the replay window, and why it is async.
---

Sume signs every webhook delivery with HMAC-SHA256 over `<timestamp>.<raw_body>` and
sends it as `sume-v1=<hex>`. `verifyWebhook` is that check, so you do not write it.

```ts
import { verifyWebhook } from "@sume-com/sdk";

export async function POST(request: Request) {
  const body = await request.text(); // raw, before any JSON.parse

  const ok = await verifyWebhook({
    body,
    headers: request.headers,
    // Same name the Sume delivery worker uses. Provisioned by Sume — not self-serve yet.
    secret: process.env.SUME_COM_WEBHOOK_SIGNING_SECRET!,
  });
  if (!ok) return new Response("bad signature", { status: 401 });

  const event = JSON.parse(body);
  await recordTerminalRun(event.request_id, event); // dedupe on request_id
  return new Response(null, { status: 204 }); // fast 2xx, then work
}
```

Read your signing secret on the **Webhooks** tab of the dashboard
(`/dashboard/webhooks` — Reveal, then copy), or from `GET /v1/webhooks/signing-secret`
with any API key carrying `account:read`. It is derived for your workspace, so a valid
signature proves the delivery was signed for you rather than for anyone holding a shared
platform secret. Store it the way you store the API key. Use the env name
`SUME_COM_WEBHOOK_SIGNING_SECRET` so local samples match what Sume's worker signs with.
It is not the API key, and the client is not involved: `verifyWebhook` takes no `client`
and makes no request.

The dashboard also shows a **fingerprint** of the secret, and every delivery carries the
same value as `x-sume-webhook-secret-fingerprint`. When a signature will not verify,
compare fingerprints — it is the one part of this that is safe to paste into a ticket.

## Rotating the secret

If your secret may have leaked, rotate it: **Webhooks → Rotate secret**, or
`POST /v1/webhooks/signing-secret/rotate` with a key carrying `account:write`.

Rotation is not a cutover. For **24 hours** afterwards Sume signs every delivery with
both secrets and sends them comma-separated in `x-sume-webhook-signature`, newest first:

```http
x-sume-webhook-signature: sume-v1=<new>,sume-v1=<old>
```

A receiver holding either secret verifies, so you can redeploy on your own schedule
rather than in the same instant you press the button. After the window the old secret
stops verifying. The dashboard shows the deadline while the window is open, and
`rotation.previous_valid_until` carries it on both API responses.

<Callout type="warn">
  `verifyWebhook` handles the multi-signature header from **`@sume-com/sdk` 0.5.0**.
  An older verifier — or a hand-rolled one that compares the header for equality —
  fails on every delivery during the window. Upgrade the receiver *before* you rotate.
  Outside a window exactly one signature is sent, so nothing changes for anyone who
  never rotates.
</Callout>

`x-sume-webhook-secret-fingerprint` names the **new** secret from the moment you rotate,
including during the window. It tells you which secret to move to, not which ones are
still accepted. Rotating twice inside one window retires the secret two rotations back
immediately, which is what makes a leak actually stop.

Delivery is live on **`api.dev.sume.com` and `api.sume.com`**. Polling or
[`subscribeFormatRun`](/sdk/runs) remains a valid backup. See
[Run webhooks](/agents/run-webhooks#availability).

## Input

| Field | Notes |
|---|---|
| `body` | The **raw** body: `string`, `ArrayBuffer`, or a typed array. |
| `headers` | A `Headers`, a `Map`, or a plain object (Node's `req.headers`). Case-insensitive. |
| `secret` | Your Sume webhook signing secret. |
| `toleranceSeconds` | Replay window. Default `300`. `0` skips the timestamp check. |

The two headers it reads:

```text
x-sume-webhook-timestamp: 1785000000
x-sume-webhook-signature: sume-v1=<hex_signature>
```

## Four rules that decide whether this works

- **Pass the raw body.** A parsed-and-reserialized object does not verify — key order and
  whitespace are part of what was signed. Frameworks that parse JSON for you have already
  destroyed the bytes. In Express, mount `express.raw({ type: "application/json" })` on
  the webhook route only. In Next.js App Router, `await request.text()` before anything
  else.
- **It is `async`.** The implementation uses WebCrypto rather than `node:crypto`, which is
  what keeps the package importable from Workers, Deno, and bundlers that refuse `node:`
  specifiers. `await` it.
- **It returns `false` rather than throwing** on a malformed delivery. A missing header, a
  garbage timestamp, and a wrong signature are all just failed verification — one thing to
  branch on, no `try`/`catch`.
- **Comparison is constant-time**, and the replay window is enforced before the HMAC is
  computed at all.

## One verifier, two surfaces

Run webhooks (`*.run.terminal`, carrying `run_id`) and generation-job webhooks (`job.*`,
carrying `job_id`) share the `sume-v1` scheme exactly. The payloads differ; the signature
does not. So one verifier covers both — **route on `event`**, and never assume a body has
`run_id`:

```ts
const event = JSON.parse(body);

switch (event.event) {
  case "format.run.terminal":
    return handleFormatRun(event);
  case "job.completed":
  case "job.failed":
    return handleJob(event);
  default:
    return new Response(null, { status: 204 }); // unknown event, not a 500
}
```

Treating an unrecognized event as `204` is what stops a newly added event type from
becoming a 500 and a retry storm.

## Constants, if you need them

`verifyWebhook` covers the common case. The pieces are exported for when you do not
control the receiver — a gateway that verifies before your code runs, say:

| Export | Value |
|---|---|
| `SUME_WEBHOOK_SIGNATURE_VERSION` | `"sume-v1"` |
| `SUME_WEBHOOK_SIGNATURE_HEADER` | `"x-sume-webhook-signature"` |
| `SUME_WEBHOOK_TIMESTAMP_HEADER` | `"x-sume-webhook-timestamp"` |
| `DEFAULT_SUME_WEBHOOK_TOLERANCE_SECONDS` | `300` |

## Next

- [Run webhooks](/agents/run-webhooks) — delivery, events, payloads, retries, and the raw
  scheme for non-JavaScript receivers
- [Webhooks](/workflows/webhooks) — generation-job webhooks, the other surface
- [Waiting for runs](/sdk/runs) — the poll path (required on production until delivery
  is enabled; optional fallback on `api.dev`)
