Durable Object as a job_id ledger for Sume webhook retries

Use one Cloudflare Durable Object per Sume job_id to dedupe job.completed retries and redeliveries: verify in the Worker, record once in the object, return 2xx.

4 min readSume
All posts

Yes, a Durable Object works well as the idempotency ledger for Sume webhooks: address one object by the event's job_id, record the event inside it once, and return a 2xx. Sume documents job_id as the idempotency key, and Cloudflare documents a Durable Object as single-threaded with a globally unique name, so two deliveries of the same job cannot interleave.

Why does a Sume receiver need a ledger at all?

Sume retries network errors and non-2xx responses up to 10 attempts in total, with a fixed delay (30 seconds by default) and a 10-second timeout per attempt. A slow receiver can therefore see the same job.completed twice even when the first attempt did the work.

A manual Redeliver, either on a delivery row or through POST /v1/jobs/{job_id}/webhook/redeliver, sends the real terminal event again with a fresh timestamp and signature. The timestamp check cannot catch that, because the signature is valid. Only your own record of the job_id can.

What does the Durable Object give you?

Cloudflare describes each object as having its own private, transactional, strongly consistent storage, and as single-threaded and cooperatively multi-tasked. Two retries that arrive at the same moment are handled one after the other by the same object, so a read-then-insert inside it has no race. A plain KV namespace cannot promise that, and a Worker alone has no shared state at all.

  • One object per job_id: derive its id from the name, so every delivery of that job lands on the same object.
  • SQLite-backed storage: ctx.storage.sql.exec takes a query with ? bindings and returns a cursor.
  • Do the read and the insert in one synchronous call so no await sits between them.

What does the code look like?

The Worker verifies the signature on the raw body, then asks the object whether it has seen the job. The secret comes from your Worker secret store, and the verifier refuses an empty one.

export class Ledger {
  constructor(private ctx: DurableObjectState) {
    ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS seen (job_id TEXT PRIMARY KEY, event TEXT)");
  }
  async fetch(req: Request): Promise<Response> {
    const { job_id, event } = (await req.json()) as { job_id: string; event: string };
    const first = this.ctx.storage.transactionSync(() => {
      const hit = this.ctx.storage.sql.exec("SELECT 1 FROM seen WHERE job_id = ?", job_id).toArray();
      if (hit.length) return false;
      this.ctx.storage.sql.exec("INSERT INTO seen (job_id, event) VALUES (?, ?)", job_id, event);
      return true;
    });
    return Response.json({ first });
  }
}

async function verify(secret: string, ts: string, body: string, header: string) {
  if (!secret || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  for (const part of header.split(",")) {
    const hex = part.trim().replace(/^sume-v1=/, "");
    if (!/^[0-9a-f]{64}$/.test(hex)) continue;
    const sig = Uint8Array.from(hex.match(/../g)!, (h) => parseInt(h, 16));
    if (await crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(`${ts}.${body}`))) return true;
  }
  return false;
}

How should the Worker use the answer?

Call verify with the x-sume-webhook-timestamp value, the raw text of the body, and the x-sume-webhook-signature header. During a secret rotation that header carries one sume-v1 entry per live secret, newest first, which is why the loop accepts any matching entry. Reject with 401 on a failure, then post the job_id and event to env.LEDGER.get(env.LEDGER.idFromName(job_id)).

Return 2xx only after the object answers. If first is true, do the real work (copy the artifact, update your database) or queue it. If first is false, skip the work and still return 2xx, so Sume stops retrying. Keep polling GET /v1/jobs/{job_id}/status as a backup, because after ten refused attempts the job is still terminal but the delivery has failed.

Sume webhook delivery facts and the ledger step that answers each (docs read 2026-10-11)
Sume behaviorLedger step
Up to 10 attempts, fixed 30s delaySecond delivery finds the job_id and returns 2xx
10s timeout per attemptKeep the object call to one short SQL step; queue the slow work
Redeliver sends a fresh signatureDedupe on job_id, not on timestamp or signature
Send test has no job_idVerify, then return 2xx without touching the ledger

What are the limits of this pattern?

The ledger records that an event arrived, not that your downstream work finished. If the work fails after the object said first, store a status column and set it when the work ends, or a retry will be skipped for good. Cloudflare also notes that fully removing an object requires deleteAll and deleteAlarm, so plan a cleanup path if you do not want every job_id kept forever.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume