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.

5 min readSume
All posts

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

Sume job webhook behavior, from Sume docs read 2026-10-05
ItemValue
Eventsjob.completed, job.failed, job.canceled
AttemptsUp to 10, fixed 30 s spacing
Per-attempt timeout10 s
Replay window300 s suggested
Dedupe keyjob_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

All Developers posts

Written by Sume