Next.js route handlers to replace a Sora call: submit + webhook
Two App Router route handlers: one posts a video job to Sume with an idempotency key and callback_url, one verifies the signed job.completed webhook.

In a Next.js App Router app, replace the Sora call with two route handlers: one that POSTs the job to https://api.sume.com/v1/videos with an Idempotency-Key and a callback_url, and one that verifies Sume's signed webhook and records the result by job_id. Both fit in about 25 lines each and need no packages.
OpenAI's deprecations page lists the Videos API and sora-2 ids as removed on 2026-09-24, with no replacement named. A route handler pair is the smallest thing that restores the feature.
Why a webhook and not a poll in a serverless route
A route handler on a serverless host should not sit in a 10-minute poll loop. Sume documents webhook delivery for exactly this: you send callback_url on the submit, and Sume POSTs a terminal event (job.completed, job.failed or job.canceled) to that URL. The URL must be public HTTPS; localhost and private networks are rejected.
The webhook is an optimization, not the only path. After ten refused attempts the job still reaches its real terminal state, so keep a poll on GET /v1/videos/{id} as a backstop for rows that stay pending too long.
| Item | Value |
|---|---|
| Events | job.completed, job.failed, job.canceled |
| Signature header | x-sume-webhook-signature: sume-v1=<hex> |
| Signed string | <timestamp>.<raw body>, HMAC SHA 256 |
| Timestamp header | x-sume-webhook-timestamp |
| Replay tolerance | reject outside about 5 minutes |
| Attempts | up to 10, fixed spacing, 10 s timeout each |
Route 1: submit
The clipId from your own database becomes the idempotency key, so a double-click or a retried request returns the original job instead of billing twice. The handler passes Sume's status through, which keeps 402 and 429 visible to your front end.
// app/api/clips/route.ts
export async function POST(req: Request) {
const { prompt, clipId } = await req.json();
const res = await fetch("https://api.sume.com/v1/videos", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUME_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `clip-${clipId}`,
},
body: JSON.stringify({
model: "gemini-omni-flash-1.1",
prompt,
duration: 8,
resolution: "720p",
aspect_ratio: "16:9",
callback_url: `${process.env.PUBLIC_URL}/api/sume/webhook`,
}),
});
const job = await res.json();
return Response.json(job, { status: res.status });
}
Route 2: verify and record
Read the body with req.text(), not req.json(): the signature covers the raw bytes, and a re-serialized object can differ. The handler refuses to run at all when the secret is empty, because an HMAC with an empty key is trivially forgeable. It also accepts any comma-separated sume-v1 entry, which matters during a signing-secret rotation, when the header carries one entry per live secret.
// app/api/sume/webhook/route.ts
import crypto from "node:crypto";
export async function POST(req: Request) {
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
if (!secret) return new Response("signing secret not set", { status: 500 });
const raw = await req.text();
const ts = req.headers.get("x-sume-webhook-timestamp") ?? "";
const header = req.headers.get("x-sume-webhook-signature") ?? "";
if (!Number.isFinite(Number(ts)) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return new Response("stale", { status: 400 });
}
const digest = crypto.createHmac("sha256", secret).update(`${ts}.${raw}`).digest("hex");
const expected = Buffer.from(`sume-v1=${digest}`);
const ok = header.split(",").some((entry) => {
const got = Buffer.from(entry.trim());
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(raw);
console.log(event.event, event.job_id); // store durably, keyed by job_id
return new Response("ok");
}
Before you deploy
Find your signing secret on the Webhooks tab of the Sume dashboard and store it as SUME_COM_WEBHOOK_SIGNING_SECRET. Return a 2xx only after the event is stored; Sume retries non-2xx responses. Treat job_id as the idempotency key on your side, since a redelivery sends the same event again.
Keep the handler fast. The delivery timeout is 10 seconds per attempt, so fetch and re-upload the finished file in a queue worker, not inside the route. The linked post on that exact timeout explains the failure mode.
Sources
Related posts
More in Developers
- 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.
- 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.
Written by Sume