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.

5 min readSume
All posts

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?

Events one Sume receiver can see, from Sume docs read 2026-10-04
EventSurfaceDedupe key
job.completedGeneration jobjob_id
job.failedGeneration jobjob_id
job.canceledGeneration jobjob_id
format.run.terminalFormat runrequest_id (equals run_id)
action.run.terminal and agent.run.terminalAction and Agent Completion runsrequest_id (equals run_id)
webhook.testDashboard Send testNone; 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_url after you cancel.
  • A run receipt over 1 MiB arrives with payload: null and an error.code of payload_too_large; fetch result_url. A handler that assumes an object will throw on your largest runs.
  • Branch on outcome (ok, degraded, error), not on status alone, 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

All Developers posts

Written by Sume