Prisma upsert on a unique job_id for Sume webhook retries
Dedupe Sume job webhooks with a Prisma model keyed on job_id and an upsert that updates nothing, after verifyWebhook. TypeScript route handler for Next.js.

Make job_id the primary key of a Prisma model and write each webhook with upsert whose update is empty. The first delivery inserts the row, and every retry matches it and changes nothing. Sume's webhook docs say to use job_id as the idempotency key on your side, because delivery is retried up to 10 times and a Redeliver can send the same terminal event again.
Verify the signature first and write second. A forged body should never reach the database.
Model and handler
The model has one column that matters, jobId, which is the id. payload keeps the event so you can reprocess it. The handler is a Next.js route that uses verifyWebhook from @sume-com/sdk and returns 204 after the write.
// schema.prisma
// model SumeJob {
// jobId String @id
// event String
// status String
// payload Json
// receivedAt DateTime @default(now())
// }
import { PrismaClient, Prisma } from "@prisma/client";
import { verifyWebhook } from "@sume-com/sdk";
const prisma = new PrismaClient();
export async function POST(request: Request) {
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) return new Response("signing secret not configured", { status: 500 });
const body = await request.text();
if (!(await verifyWebhook({ body, headers: request.headers, secret }))) {
return new Response("bad signature", { status: 401 });
}
const e = JSON.parse(body);
if (!e.job_id) return new Response(null, { status: 204 }); // webhook.test
const row = { event: e.event, status: e.status, payload: e as Prisma.InputJsonValue };
try {
await prisma.sumeJob.upsert({ where: { jobId: e.job_id }, create: { jobId: e.job_id, ...row }, update: {} });
} catch (err) {
if (!(err instanceof Prisma.PrismaClientKnownRequestError) || err.code !== "P2002") throw err;
}
return new Response(null, { status: 204 });
}The race the catch handles
Two deliveries of the same job can arrive at the same moment, for example a retry that overlaps a slow first attempt. Depending on the query, Prisma can run the upsert as a read followed by a write, and the loser then hits the unique constraint. The P2002 catch above turns that into a plain duplicate.
What goes in the row
| Field | Value | Why keep it |
|---|---|---|
| job_id | job_... | Primary key and the idempotency key |
| event | job.completed, job.failed or job.canceled | Branch on this, not on a guess |
| status | OK or ERROR | Failed and canceled events use ERROR and carry an error object |
| payload.artifacts | id, url, type, content_type | Download from the Sume media URL, never from a provider URL |
Caveats
- An empty
updatemeans a Redeliver will not overwrite the stored payload. That is right for a terminal event, which does not change. - Do the slow work, such as downloading artifacts, in a job that reads the row. Sume gives each attempt 10 seconds.
- Keep polling
status_urlfor jobs that have no row after your expected render time. A webhook is an optimization, not the only recovery path.
Test both paths before you go live
Test the route without waiting for a real render. Sume's dashboard Webhooks tab has a Send test control (and POST /v1/webhooks/test-deliveries with account:write) that posts a signed webhook.test payload to a URL you type. It has no job_id, so it exercises your signature check and your early return without touching the table.
To exercise the dedupe itself, take one real delivery body, sign it yourself with a throwaway secret and the current timestamp, and post it twice. The table should hold one row, and both calls should return 204. A real job's Redeliver, available per call from the dashboard or at POST /v1/jobs/{job_id}/webhook/redeliver, is the production version of the same test.
Sources
Related posts
More in Developers
- Programmatic tool calling 270 s timeout vs Sume's 55 s script_run cap
Claude times out a pending programmatic tool call after about 4 minutes. Sume's script_run stops at 55 seconds. How to size slow video jobs around both.
- Prometheus counters for Sume API errors, split by code and status
Wrap every Sume call in a counter and histogram labeled by route template, status and error.code, never by job id or request id, then alert on retryable rates.
- Export Sume job counts by status to Prometheus with a Python gauge
A small exporter pages GET /v1/jobs with next_cursor and sets a prometheus_client Gauge labelled by status, so a dashboard shows queued and failed jobs.
- Push or poll for a finished render: listen, webhook or jobs_wait
MCP 2026-07-28 adds subscriptions/listen. For a render that takes minutes, compare a listen stream, a signed webhook and jobs_wait, with a Python verifier.
Written by Sume