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.

5 min readSume
All posts

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

Webhook fields worth keeping (Sume docs, read 2026-10-04)
FieldValueWhy keep it
job_idjob_...Primary key and the idempotency key
eventjob.completed, job.failed or job.canceledBranch on this, not on a guess
statusOK or ERRORFailed and canceled events use ERROR and carry an error object
payload.artifactsid, url, type, content_typeDownload from the Sume media URL, never from a provider URL

Caveats

  • An empty update means 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_url for 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

All Developers posts

Written by Sume