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.

5 min readSume
All posts

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).

Convex HTTP action facts, read 2026-10-02
FactValue
Define withhttpAction exposed through httpRouter
Body accessrequest.text(), json(), blob(), arrayBuffer()
Argument validationNone; parsing is left to you
Response size20 MB on the HTTP actions page; 20 MiB on the limits page (request size has no specific limit there)
Automatic retry on errorNone; the caller must retry
RuntimeSame 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

All Integrations posts

Written by Sume