Convex HTTP action as a Sume webhook receiver: raw body, no retry
A Convex httpAction reads the raw body with request.text() and is not retried by Convex, so Sume's 10 delivery attempts and a job_id dedupe do the work.

Yes: a Convex httpAction can receive a Sume webhook. Read the body with request.text() before parsing it, verify the sume-v1 signature, record the event with an internal mutation keyed on job_id, and return 2xx. Convex does not retry an HTTP action that errors, so Sume's own retries are your retry layer.
The Convex facts are from its HTTP actions and limits pages, read on 2026-10-02.
What does Convex say about HTTP actions?
The limits page lists concurrent HTTP actions by deployment class (for example 64 on S16 and 512 on S256).
| Fact | Value |
|---|---|
| Define with | httpAction exposed through httpRouter |
| Body access | request.text(), json(), blob(), arrayBuffer() |
| Argument validation | None; parsing is left to you |
| Response size | 20 MB on the HTTP actions page; 20 MiB on the limits page (request size has no specific limit there) |
| Automatic retry on error | None; the caller must retry |
| Runtime | Same environment as queries and mutations, no Node.js APIs |
Why does the raw body matter?
Sume signs <timestamp>.<raw_body>, so a body that was parsed and re-serialized no longer matches; key order and whitespace are part of what was signed. request.text() gives you the exact bytes. The verifying webhooks docs say verifyWebhook uses WebCrypto rather than node:crypto, which is why it is meant to import in runtimes without Node. Because Convex HTTP actions have no Node.js APIs, that is the property you want; test the import in your own deployment before you rely on it.
What does the route look like?
The handler refuses to run without a secret, verifies, skips Sume's dummy webhook.test event (it has no job_id), and then hands the event to an internal mutation. Your mutation should upsert by job_id, so a retry or a Redeliver does not create a second row. internal.sume.recordEvent is a placeholder for a mutation you write.
import { httpRouter, httpAction } from "convex/server";
import { internal } from "./_generated/api";
import { verifyWebhook } from "@sume-com/sdk";
const http = httpRouter();
http.route({
path: "/sume/webhook",
method: "POST",
handler: httpAction(async (ctx, request) => {
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
if (!secret) return new Response("no secret", { status: 500 });
const body = await request.text();
const ok = await verifyWebhook({ body, headers: request.headers, secret });
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(body);
if (event.event === "webhook.test") return new Response(null, { status: 204 });
await ctx.runMutation(internal.sume.recordEvent, { jobId: event.job_id, event: event.event });
return new Response(null, { status: 204 });
}),
});
export default http;What should the mutation store?
Store job_id, the event name, the status and the artifact URLs from payload, and mark the row as received. Sume's guidance is to use job_id as the idempotency key on your side, so make the mutation an upsert: a second delivery of the same terminal event updates nothing and returns normally.
Do the slow work after the response. A mutation that schedules a follow-up function keeps the HTTP action fast, which matters because Sume gives each attempt 10 seconds. Do not download media inside the action; keep the artifact URL and fetch later if you need the bytes.
How do I find the URL to give Sume?
Register the route path on your deployment's HTTP actions host, then pass the full public HTTPS address as webhook_url when you submit. Convex shows the site URL for HTTP actions in your deployment settings; confirm it there rather than guessing from the deployment name. The address must be public HTTPS, and Sume rejects anything else with 400 invalid_request. Then use Send test on /dashboard/webhooks to check the route answers before you spend on a real job; it posts a signed dummy webhook.test body, which the handler above answers with 204.
How do the retries line up?
Sume retries a failed delivery up to 10 times with a 30-second default spacing and a 10-second timeout per attempt, and a 2xx stops it; the webhook docs list this. Convex adds no retry of its own, so if the mutation throws, return a 5xx and let Sume try again rather than swallowing the error.
Keep a poll as the backup. After 10 refused attempts the job is still terminal on Sume's side, and POST /v1/jobs/{job_id}/webhook/redeliver sends the real event again with a fresh signature. Unknown event names should get a 204, as the same docs recommend, so a new event type is not a retry storm.
Sources
Related posts
More in Integrations
- Copilot CLI 1.0.92: MCP tools after OAuth re-auth, with Sume
Copilot CLI 1.0.92-0 keeps MCP tools working after OAuth re-auth when definitions are unchanged. Sume tokens last one hour with no refresh, so you will hit it.
- Cursor MCP allowlist: approve Sume's URL and its read tools
Cursor enterprise admins approve remote MCP servers by URL entry and list tools per server. How to allow Sume's mcp.sume.com/mcp and which tools to list.
- Cursor supports MCP roots and elicitation; Sume uses tools only
Cursor lists tools, prompts, resources, roots, elicitation and Apps as supported. Sume's hosted MCP answers only tools, so here is what you will see.
- Fastify 5 raw body for a Sume webhook: parseAs string
Fastify parses JSON before your handler, which breaks HMAC checks. Use parseAs string, then verify sume-v1 and dedupe. Tested on Fastify 5.12.5.
Written by Sume