verifyWebhook in a fetch handler: four rules, 204 for unknown events

Use @sume-com/sdk verifyWebhook on the raw body, await it, treat false as 401 and answer unknown events with 204. A runnable handler for Workers, Deno and Node.

5 min readSume
All posts

Pass verifyWebhook the raw body string, the request headers and your signing secret, await it, and return 401 when it comes back false. It does not throw on a bad delivery, it checks the replay window before the HMAC, and it accepts the two-signature header Sume sends during a secret rotation. The handler below runs on any runtime with fetch and WebCrypto: Node 18+, Bun, Deno or Cloudflare Workers.

The handler

Read the body with request.text() before you do anything else with the request. Parse JSON only after the signature passes. The secret comes from SUME_COM_WEBHOOK_SIGNING_SECRET, the name the Sume delivery worker uses, and the function refuses to run without it.

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

const seen = new Set(); // swap for a unique-key table

export async function POST(request) {
  const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
  if (!secret) return new Response("signing secret not configured", { status: 500 });

  const body = await request.text(); // raw bytes, before JSON.parse
  const ok = await verifyWebhook({ body, headers: request.headers, secret });
  if (!ok) return new Response("bad signature", { status: 401 });

  let event;
  try {
    event = JSON.parse(body);
  } catch {
    return new Response(null, { status: 204 }); // signed but not JSON: log it, do not loop
  }
  const key = event.job_id ?? event.request_id;
  if (key && !seen.has(key)) {
    seen.add(key); // durable write first, work after the response
  }
  return new Response(null, { status: 204 });
}

The four rules

The SDK page names four rules that decide whether verification works. Each one answers a bug that shows up in a first receiver.

What verifyWebhook does and what you must do (read 2026-10-07)
RuleWhyCommon mistake
Pass the raw bodyThe key order and whitespace are part of the signed bytesLetting a framework parse JSON first and re-serializing it
await itIt uses WebCrypto, so it is asyncUsing the unresolved promise, which is always truthy
Branch on the return valueIt returns false instead of throwingWrapping it in try/catch and treating a throw as the only failure
Constant-time compareThe replay window is checked first, then the HMACComparing the header with ===, which fails during a rotation

Unknown events get a 204

Sume sends job events (job.completed, job.failed, job.canceled) and run events (format.run.terminal, action.run.terminal, agent.run.terminal) with the same signature scheme, so one endpoint can serve both, and the SDK page recommends exactly that. The payloads differ: a job body has job_id, a run body has run_id, and both carry request_id. Route on the event field and never assume a field that only one family has.

A new event type that your code does not know should return 204. A 500 is a failed attempt, and Sume retries failed attempts up to ten times, so an unknown event that throws turns into ten calls for nothing.

Rotation readiness

After a rotation, Sume signs each delivery with both the new and the previous secret for 24 hours and sends the two signatures in one header, newest first and separated by a comma. A hand-written check that compares the whole header to one value fails every delivery in that window. verifyWebhook in @sume-com/sdk 0.2.0 handles it, so upgrade the receiver before you rotate and not after.

The x-sume-webhook-secret-fingerprint header carries a 12-character fingerprint of the secret. When a signature does not verify, compare it to the fingerprint on the Webhooks page, and paste only that into a ticket, never the secret.

Store first, then work

The handler above keeps its seen-set in memory so that it runs by itself. In production, make that a table with a unique key on the job or run id and write the row before you return. Return 2xx in well under the 10-second attempt budget, then do the real work, such as downloading media, from a queue. The delivery is at-least-once, and the unique key is what makes a repeat harmless.

Test the handler without Sume. Sign a fixture body with a throwaway secret, post it to your route, and check that you get 204. Then change one byte of the body, or move the timestamp outside the 300-second replay window, and check that you get 401. Those two checks cover most of what goes wrong in a first receiver, and neither needs a paid job.

Finally, decide what the handler does with a delivery it verified but cannot process, for example a body that is not JSON. Log it and return 204. A signed but unusable event is a bug to read in your logs, and a retry will not fix it.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume