X-OpenRouter-Idempotency-Key vs Sume webhook dedupe on job_id

OpenRouter's webhook dedupe key is job_id plus status. Sume says dedupe on job_id. A SQLite sample builds a job_id plus event key that skips replays.

5 min readSume
All posts

OpenRouter's video guide gives its webhook retries a header, X-OpenRouter-Idempotency-Key, in the format <job_id>-<status>. Sume's webhook guide tells you to dedupe on job_id, and it sends no such header. If you are moving a receiver across, build the same key yourself as job_id plus the event name.

What each vendor gives you

The OpenRouter video guide says the X-OpenRouter-Idempotency-Key header uses the format <job_id>-<status> so a retry of the same delivery can be recognised.

The Sume webhook guide lists the headers a delivery carries as x-sume-webhook-timestamp, x-sume-webhook-signature and x-sume-webhook-secret-fingerprint. For duplicates it says to use job_id.

Where the dedupe key comes from (read 2026-10-07)
OpenRouter video callbackSume job webhook
Dedupe keyX-OpenRouter-Idempotency-Key headerjob_id in the body
Format<job_id>-<status>job_id; add event for a composite
RetriesNot detailed on the page readUp to 10 attempts, 30 s apart by default
SignatureNot detailed on the page readHMAC-SHA256, sume-v1= header

Why a composite key is still useful

Sume sends one terminal event per job: job.completed, job.failed or job.canceled. A single job_id is therefore a fine key for a first delivery. A redelivery you trigger with POST /v1/jobs/{job_id}/webhook/redeliver carries the same job and a fresh signature, so it should be skipped too.

Joining job_id and the event name, job_id-job.completed, copies the OpenRouter shape. It also keeps your table honest if a later event type ever shares a job.

A key table in SQLite

The sample uses the standard library only. insert or ignore into a table with a primary key either adds the row, or does nothing, and rowcount says which. That one statement is atomic, so two deliveries that arrive together cannot both pass.

The test event is skipped. A webhook.test body has no job_id, so first_time returns False for it and your handler can answer 200 without acting.

import json, sqlite3

db = sqlite3.connect("events.db")
db.execute("create table if not exists seen (k text primary key)")

def first_time(event):
    job_id = event.get("job_id")
    if not job_id:  # webhook.test has no job_id
        return False
    key = f'{job_id}-{event["event"]}'
    cur = db.execute("insert or ignore into seen values (?)", (key,))
    db.commit()
    return cur.rowcount == 1

if __name__ == "__main__":
    ev = {"event": "job.completed", "job_id": "job_demo", "status": "OK"}
    print(first_time(ev), first_time(ev), first_time({"event": "webhook.test"}))

Order of work in the handler

Verify the signature first, then call first_time, then store the result, and answer with a 2xx. Sume treats any 2xx as an acknowledgement and waits 10 seconds for each attempt, so keep slow work out of the request.

If the insert succeeds and your later step fails, delete the row or write both in one transaction. Otherwise a retry would be dropped as a duplicate while the work never happened.

Keep the table small

The key table only needs the key and, if you like, a received-at time. Delete rows after you are sure no redelivery will come. Sume stops after ten attempts, which at the default 30 second spacing is a few minutes, so a retention of a day is generous.

A production receiver would use its main database in place of SQLite and put the insert in the same transaction as the job result. The point of the sample is the single atomic insert, not the storage engine.

If you also receive from OpenRouter, the header they send can go straight into the same table. Keys from the two vendors differ in shape, so they cannot clash.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume