TanStack Start webhook route for Sume jobs: verify the raw body
Receive a signed Sume job webhook in a TanStack Start server route: read request.text(), verify with the SDK, answer 204, and dedupe on job_id.

In TanStack Start, receive a Sume job webhook with a server route: define a POST handler on a file route, read the body with request.text(), and pass that string to verifyWebhook from @sume-com/sdk before you parse anything. Return a fast 204 once the signature checks out, and save the result keyed by job_id.
The one trap is the body. Sume signs the raw bytes, so a handler that calls request.json() first and re-serializes later will never verify. The server-routes page (TanStack Start: Server Routes, read 2026-10-11) shows request.json() in its example and notes that request.text() and request.formData() are also available, so use text() here.
The route
This is the whole receiver. It follows the handler shape in the TanStack page and the verifier from the SDK webhook page, which documents body, headers and secret as inputs and says verifyWebhook is async and returns false instead of throwing.
The explicit check for an empty secret is ours, not the SDK's: a route that deploys without the environment variable should fail loudly with a 500, not quietly reject every delivery as a bad signature.
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook } from "@sume-com/sdk";
export const Route = createFileRoute("/api/sume-webhook")({
server: {
handlers: {
POST: async ({ request }) => {
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) return new Response("no secret", { status: 500 });
const body = await request.text(); // raw, before JSON.parse
const ok = await verifyWebhook({ body, headers: request.headers, secret });
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(body);
if (event.event === "job.completed") {
await saveOnce(event.job_id, event.payload.artifacts);
}
return new Response(null, { status: 204 });
},
},
},
});What Sume sends and what you answer
Job webhooks carry event, request_id, job_id, status and a payload. Sume sends terminal events only, so there is no progress stream to handle. See Webhooks for the payload and Communication modes for how webhook mode sits next to polling.
| Event | Meaning | Your reply |
|---|---|---|
| job.completed | Result is public; artifacts are in payload | 204 after saving by job_id |
| job.failed | Terminal failure with a public error object | 204 after recording the error |
| job.canceled | Job reached canceled | 204 after marking it canceled |
| Any other event name | A type your code does not know yet | 204, never a 500 |
Submit with a webhook URL
The job only calls your route if you asked for it. Send mode: "webhook" with a webhook_url on the generation request. The URL must be public HTTPS; the API rejects localhost, private-network and non-HTTP URLs, so a TanStack dev server on your laptop needs a tunnel with a public HTTPS address, or you poll instead.
Keep a poll in place next to the webhook. The jobs docs describe GET /v1/jobs/:id/status with exponential backoff, and say not to resubmit a paid request just because a local process timed out.
Dedupe and rotation
Store the secret as SUME_COM_WEBHOOK_SIGNING_SECRET, the name the delivery worker uses. After a rotation, Sume signs with both secrets for 24 hours and puts two sume-v1= entries in the signature header. The 0.2.0 SDK verifier already handles that, which is a reason to use it over a hand-written comparison.
Make saveOnce idempotent on job_id. A redelivery then writes nothing new. Verify before you touch the database, and do the slow work (copying media, calling other services) after you have answered.
- Read the raw body once with
request.text()and keep the string for both verification and parsing. - Treat an unknown
eventas a204. - Keep
saveOncekeyed onjob_id. - Use the same secret for job and run webhooks; one route can serve both if you switch on
event.
Where this stops
This post covers the receiving route only. It does not claim anything about how TanStack Start deploys or about hosting limits, which depend on where you run it and are not covered by the pages above. For the signing details and the retry behaviour, read the Sume pages directly; for server-route options beyond POST, read the TanStack page.
Sources
Related posts
More in Developers
- Usage hold_counts: open, browser_session, billing_pending
A scoped Sume usage summary can show final false with money held. hold_counts says whether a turn runs, a Browser session is open, or work is being priced.
- What one script_run call cost: the usage script_runs array
Sume's usage summary lists each script_run call with its rows, debited, held and refunded micros, so a fan-out of TTS or image jobs has one price.
- WireMock scenarios: fake a Sume job going queued to completed
Use one WireMock scenario per job so GET /v1/jobs/{id}/status answers queued, processing, then completed, and test the poll loop without spending.
- Word timestamps for an Eleven v4 voice: the router says 400, use STT
The Sume TTS Router rejects timestamps on eleven-* ids. Get word timings by running the finished narration through Sume STT for one cent a minute.
Written by Sume