Next.js Pages Router webhook for a Sume video job: bodyParser false
A Pages Router API route that reads the raw body, verifies sume-v1, acks fast and dedupes on job_id for a 30 s render. Refuses an empty secret.

In the Next.js Pages Router, export config = { api: { bodyParser: false } } from the API route so you can read the raw request bytes, then verify Sume's sume-v1 HMAC over timestamp.raw_body before you parse any JSON. A 30-second render can take several minutes, so a webhook is the cleanest way to hear about it.
The Pages Router parses JSON bodies by default, which destroys the exact bytes that Sume signed. Turning the parser off for this one route is the whole trick.
Why the parser has to go
Sume signs the raw JSON body with HMAC SHA-256 over <timestamp>.<raw_body> and sends x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>. A parsed-and-reserialized object does not verify, because key order and whitespace are part of the signed data (Webhooks).
The route
Save this as pages/api/sume-webhook.js. It reads the stream, checks the replay window of 300 seconds, accepts any sume-v1= entry in the header so a secret rotation does not break it, and refuses to run with an empty secret.
import crypto from "node:crypto";
export const config = { api: { bodyParser: false } };
const seen = new Set(); // replace with a database in production
async function raw(req) {
const chunks = [];
for await (const c of req) chunks.push(c);
return Buffer.concat(chunks).toString("utf8");
}
export default async function handler(req, res) {
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) return res.status(500).end("no secret");
const body = await raw(req);
const ts = Number(req.headers["x-sume-webhook-timestamp"]);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300)
return res.status(401).end("stale");
const mac = crypto.createHmac("sha256", secret)
.update(`${ts}.${body}`).digest("hex");
const want = Buffer.from("sume-v1=" + mac);
const ok = String(req.headers["x-sume-webhook-signature"] || "")
.split(",").map((s) => Buffer.from(s.trim()))
.some((b) => b.length === want.length && crypto.timingSafeEqual(b, want));
if (!ok) return res.status(401).end("bad signature");
const event = JSON.parse(body);
if (!seen.has(event.job_id)) { seen.add(event.job_id); /* enqueue work */ }
res.status(204).end();
}What the route should and should not do
Return a 2xx quickly. Sume gives each attempt 10 seconds and retries up to 10 times, 30 seconds apart, so a slow handler that downloads a 30-second MP4 inline will burn attempts. Store the event, answer 204, and copy the video from a background worker.
Use job_id as your idempotency key. A redeliver or a retry sends the same job again. Branch on event: job.completed, job.failed, and job.canceled are the only job events.
Delivery facts for a long render
| Item | Value |
|---|---|
| Events | job.completed, job.failed, job.canceled |
| Attempts | Up to 10, fixed 30 s spacing |
| Per-attempt timeout | 10 s |
| Replay window | 300 s suggested |
| Dedupe key | job_id |
Keep a poll as backup
A webhook is a delivery optimization, not your only recovery path. Keep GET /v1/jobs/:id/status available for deliveries that never arrive, and do not resubmit a paid job because a webhook was late.
Test the route first with the dashboard's Send test, which posts a signed webhook.test body. Your handler should accept it without looking for a job_id.
Submitting the job
To make the job call your route, send mode: "webhook" with a public HTTPS webhook_url on the submit. On /v1/videos the field is callback_url. Sume rejects localhost, private-network and non-HTTPS URLs, so test locally with a tunnel or on a preview deployment.
Send an Idempotency-Key on the submit as well. If your own client times out and retries, the retry returns the original job instead of billing a second render.
Where the secret comes from
Read the signing secret on the Webhooks tab of the dashboard, or from GET /v1/webhooks/signing-secret with a key that has account:read. Store it as SUME_COM_WEBHOOK_SIGNING_SECRET. During a rotation the signature header carries two entries for 24 hours, which the route above already handles by testing each one.
Sources
Related posts
More in Developers
- No GET /v1/audio-detach/:id: poll the job instead
Audio detach and timeline audio have no GET resource route. Read /v1/jobs/:id/status and /result. Video captions and avatar video do have resource GETs.
- Node 22 batch runner for a prompt file: four lanes on Sume
Read one prompt per line, render each on Sume POST /v1/videos with four concurrent lanes and a stable idempotency key per line, and save clip-N.mp4. Node 22.
- Node quickstart: your first Wan 3.0 video on Sume and what 30 s costs
Node 18 fetch script: POST /v1/videos with wan-3.0, poll, print the URL. The list-price arithmetic for 30 s at 480p, 720p and 1080p, times the 1.25 margin.
- Node stream.pipeline to save a Sume MP4 and catch truncation
Stream a finished Sume video to disk with stream.pipeline, then compare bytes written with content-length so a cut-off MP4 never reaches your users.
Written by Sume