React Router 7 resource route as a Sume webhook receiver
A React Router 7 route module with only an action is a resource route. Read request.text(), call verifyWebhook from @sume-com/sdk, answer 204, branch on event.

In React Router 7 framework mode, a route module that exports an action and no default component is a resource route: it gets a URL, answers HTTP, and renders nothing. That is the right shape for a Sume webhook. Read await request.text() first, hand the string to verifyWebhook from @sume-com/sdk, and return a 204 before doing any real work.
verifyWebhook takes the raw body, the headers (a Headers, a Map or a plain object), the secret and an optional toleranceSeconds that defaults to 300. It is async because it uses WebCrypto, it returns false rather than throwing on a malformed delivery, and it already accepts the multi-signature header sent during a secret rotation, per Verifying webhooks.
What does the route module look like?
Register it in app/routes.ts with route("hooks/sume", "routes/hooks.sume.ts"), then write the module.
import { verifyWebhook } from "@sume-com/sdk";
import type { Route } from "./+types/hooks.sume";
export async function action({ request }: Route.ActionArgs) {
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) return new Response("not configured", { status: 500 });
const body = await request.text(); // raw, before any 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);
switch (event.event) {
case "job.completed":
case "job.failed":
case "job.canceled":
await recordOnce(event.job_id, event);
break;
case "format.run.terminal":
await recordOnce(event.run_id, event);
break;
default:
break; // unknown event: still a 2xx
}
return new Response(null, { status: 204 });
}
async function recordOnce(id: string, event: unknown) {
// insert with a unique key on id; ignore the duplicate
}Why a resource route and not a loader or component?
A route with a default export renders UI for GET navigations; Sume only ever sends POST. Keeping the module to one action means there is no page to leak and no loader to forget to protect. It also keeps the receiver out of any layout that reads a session cookie, which a webhook will never have.
Which events can arrive here?
| Event | Surface | Dedupe key |
|---|---|---|
| job.completed | Generation job | job_id |
| job.failed | Generation job | job_id |
| job.canceled | Generation job | job_id |
| format.run.terminal | Format run | request_id (equals run_id) |
| action.run.terminal and agent.run.terminal | Action and Agent Completion runs | request_id (equals run_id) |
| webhook.test | Dashboard Send test | None; not a job or a run |
What are the traps?
- A canceled Format run does not deliver a webhook, and neither does a skipped one. Poll
status_urlafter you cancel. - A run receipt over 1 MiB arrives with
payload: nulland anerror.codeofpayload_too_large; fetchresult_url. A handler that assumes an object will throw on your largest runs. - Branch on
outcome(ok,degraded,error), not onstatusalone, when the question is whether you got usable output.
What next?
Test with Send test in the dashboard, then with one real small job. Keep polling available as a backup, as Webhooks advises.
Sources
Related posts
More in Developers
- Debug a migrated video pipeline with Sume job events
When a replaced Sora pipeline stalls, read GET /v1/jobs/{id}/events: created, queued, started, completed or failed, and webhook delivery in one timeline.
- Recraft V4.1 Flash: median 1.3 s, p95 1.8 s. Set timeouts from p95
Recraft quotes a median of about 1.3 seconds and a p95 of 1.8 seconds for V4.1 Flash. How to turn latency claims into timeouts and polling for image APIs.
- Redeliver a missed speech-to-text webhook without rerunning the job
Your receiver was down when a Sume STT job finished. Redeliver the terminal webhook with one call instead of paying to transcribe again.
- Reels safe-zone boxes in Python: a pure function for any frame size
Meta's Reels percentages are relative, so one pure Python function gives the safe box for any frame size. It runs offline and feeds a caption anchor.
Written by Sume