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.

5 min readSume
All posts

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.

Resource route answers for a Sume job webhook (Sume docs, read 2026-10-04)
AnswerWhenSume behavior
405Not a POSTNot a delivery, no effect
401Missing, stale or wrong signatureTreated as a failed attempt; retried up to 10 times
500Your store is downRetried; a real event is not lost
204Verified and storedDelivery done

Caveats

  • Do not read request.json() before request.text(). A body can be read once, and the parsed form is not the signed bytes.
  • A webhook.test event from the dashboard has no job_id. Check for that, answer 204, 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

All Developers posts

Written by Sume