Nuxt server route for Sume webhooks: readRawBody, then verify
A Nuxt 3 or 4 server route at server/api/sume.post.ts that reads the raw body with readRawBody, checks sume-v1 with node:crypto, and answers 204 fast.

In Nuxt, put the receiver in server/api/sume.post.ts, read the body with Nitro's readRawBody(event, "utf8") before anything parses it, verify the sume-v1 signature, and answer 204 quickly. The signature covers the exact bytes Sume sent, so a handler that reads readBody(event) first has already turned them into an object and can no longer verify.
Sume signs the raw JSON body with HMAC-SHA256 over <timestamp>.<raw_body> and sends x-sume-webhook-timestamp plus x-sume-webhook-signature: sume-v1=<hex>; during a secret rotation the header carries one sume-v1= entry per live secret, comma-separated, newest first. The secret is on the Webhooks tab of the dashboard or from GET /v1/webhooks/signing-secret; Sume's own samples call it SUME_COM_WEBHOOK_SIGNING_SECRET.
What does the route look like?
Nitro auto-imports the h3 helpers, so the file needs only node:crypto. Put the secret in runtimeConfig so Nuxt reads it from NUXT_SUME_WEBHOOK_SECRET at runtime.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(raw: string, ts: string, header: string, secret: string) {
if (!secret) return false; // refuse an empty secret
const t = Number(ts);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = Buffer.from(
"sume-v1=" + createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex"),
);
return header.split(",").some((e) => {
const got = Buffer.from(e.trim());
return got.length === want.length && timingSafeEqual(got, want);
});
}
export default defineEventHandler(async (event) => {
const raw = (await readRawBody(event, "utf8")) ?? "";
const ok = verify(
raw,
getHeader(event, "x-sume-webhook-timestamp") ?? "",
getHeader(event, "x-sume-webhook-signature") ?? "",
useRuntimeConfig().sumeWebhookSecret as string,
);
if (!ok) throw createError({ statusCode: 401, statusMessage: "bad signature" });
const payload = JSON.parse(raw);
// store payload.request_id first, then do the work elsewhere
setResponseStatus(event, 204);
return null;
});What decides whether it works?
| Rule | Why | In the route |
|---|---|---|
| Raw body first | Key order and whitespace are part of what was signed | readRawBody before JSON.parse |
| Refuse an empty secret | An empty HMAC key verifies nothing | if (!secret) return false |
| Accept any sume-v1 entry | Rotation sends two for 24 hours | header.split(",").some(...) |
| Constant-time compare | Avoid leaking a prefix match | timingSafeEqual on equal-length buffers |
| Fast 2xx | 10 s per attempt, 10 attempts total | 204 after storing the event |
What should happen after the 204?
Dedupe on job_id for generation-job events and on request_id (equal to run_id) for run events, because the same value arrives on every retry. Write the event to a table or a queue inside the handler, return, and do the heavy work in a Nitro task or a worker. A slow handler burns the 10-second attempt budget and Sume retries.
Route on event, and send a 204 for names you do not know yet, so a new event type never becomes a 500 and a retry storm.
What do you check last?
nuxi devbehind a tunnel works for a first test; confirm the body bytes survive the tunnel unchanged.- The dashboard's Send test posts a signed
webhook.testdummy; it has nojob_id, so do not feed it to your job logic. - Keep polling
status_urlas a backup for deliveries that never arrive.
Sources
Related posts
More in Developers
- Oban worker that polls a Sume job: snooze instead of sleeping
Submit a Sume job once, then let an Oban worker check status, return {:snooze, seconds} while it runs and finish on terminal. Elixir with Req, no sleep loop.
- One Gemini billing cap pauses every linked project: guard the batch
Google pauses all projects on a billing account at the tier cap. Add a balance check and idempotent submits so one Omni batch cannot stall the rest.
- One TypeScript job shape for Sume /v1/videos and /v1/jobs responses
Sume's /v1/videos returns a bare object and /v1/jobs wraps in data. A short normalizer maps both to one state, including the cancelled versus canceled spelling.
- OpenAI Agents API hosted sandbox: which Sume hosts to allow
OpenAI's Agents API is in public beta with hosted or connected sandboxes. Which Sume hosts to allow, how to pass the MCP URL, and why the key stays in a secret.
Written by Sume