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.

5 min readSume
All posts

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.

Webhook delivery facts for /v1/videos (docs.sume.com/workflows/webhooks, read 2026-10-05)
ItemValue
Eventsjob.completed, job.failed, job.canceled
Signature headerx-sume-webhook-signature: sume-v1=<hex>
Signed string<timestamp>.<raw body>, HMAC SHA 256
Timestamp headerx-sume-webhook-timestamp
Replay tolerancereject outside about 5 minutes
Attemptsup 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

All Developers posts

Written by Sume