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
- React Router resource route for Sume webhooks: request.text() first
In React Router or Remix, a resource route with only an action can verify a Sume webhook: read request.text(), check the HMAC, then JSON.parse and store job_id.
- 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.
- Compute Sume job headroom from generation_limits, not a hardcoded 4
Read generation_limits from each Sume submit response and size new work with max(0, concurrency_limit - active - queued). Python example, with the docs numbers.
- 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.
Written by Sume