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.

Create a resource route with no default component and export only an action. Inside it, call await request.text() to get the raw body, verify the sume-v1 HMAC, and only then JSON.parse the text and store the event by job_id. A resource route has no UI, so a webhook URL such as /api/sume cannot render by accident, and request.text() gives you the exact bytes that Sume signed.
Sume's Node and TypeScript SDK ships verifyWebhook({ body, headers, secret }), which does the check for you, and the webhook docs describe the scheme if you prefer node:crypto. The sample uses node:crypto so it has no dependency.
The route module
Put it in app/routes/api.sume.ts and register it as a route. The loader is omitted on purpose: a GET then gets a 405.
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
if (!secret) throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is empty");
function valid(raw: string, request: Request): boolean {
const ts = Number(request.headers.get("x-sume-webhook-timestamp"));
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const want = Buffer.from("sume-v1=" + createHmac("sha256", secret).update(`${ts}.${raw}`).digest("hex"));
return (request.headers.get("x-sume-webhook-signature") ?? "")
.split(",")
.some((e) => {
const got = Buffer.from(e.trim());
return got.length === want.length && timingSafeEqual(got, want);
});
}
export async function action({ request }: { request: Request }) {
if (request.method !== "POST") return new Response(null, { status: 405 });
const raw = await request.text();
if (!valid(raw, request)) return new Response(null, { status: 401 });
const event = JSON.parse(raw) as { event?: string; job_id?: string };
// Insert event.job_id under a unique key here, then answer.
return new Response(null, { status: 204 });
}Answers and what Sume does
The route has four exits. Only the last one tells Sume the event was stored.
| Answer | When | Sume behavior |
|---|---|---|
| 405 | Not a POST | Not a delivery, no effect |
| 401 | Missing, stale or wrong signature | Treated as a failed attempt; retried up to 10 times |
| 500 | Your store is down | Retried; a real event is not lost |
| 204 | Verified and stored | Delivery done |
Caveats
- Do not read
request.json()beforerequest.text(). A body can be read once, and the parsed form is not the signed bytes. - A
webhook.testevent from the dashboard has nojob_id. Check for that, answer204, and store nothing. - Resource routes run on the server only. Keep the signing secret out of any client module.
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.
- 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.
- Recraft V4 on Sume returns WebP only: convert to PNG with Pillow
Recraft V4 on Sume lists webp as its only output format at $0.05 per image. Download the file and convert to PNG or JPEG with Pillow in six lines.
Written by Sume