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.

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.
| OpenRouter video callback | Sume job webhook | |
|---|---|---|
| Dedupe key | X-OpenRouter-Idempotency-Key header | job_id in the body |
| Format | <job_id>-<status> | job_id; add event for a composite |
| Retries | Not detailed on the page read | Up to 10 attempts, 30 s apart by default |
| Signature | Not detailed on the page read | HMAC-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
- Pandas DataFrame to a Sume bulk queue in 100-row chunks
Turn a product DataFrame into Sume Format bulk queues: one item per row, 100 rows per queue, a stable key per chunk, and SKU order saved beside each queue id.
- Pin the model id in an ad test: sume/auto follows the catalog
sume/auto is a pure function of the request plus the catalog version, so two ad arms made weeks apart can land on different models. Pin an explicit id in tests.
- Poll hundreds of AI jobs without a thundering herd: jitter and budgets
Poll many Sume jobs without synchronized bursts: jitter, next_poll_after_seconds, per-plan read budgets, and the math on how much polling a plan can absorb.
- Polling 200 video jobs every 30 s is 400 calls a minute: do this
A poll loop copied to Sume's 30 s example turns 200 open jobs into 400 status calls a minute. Use next_poll_after_seconds, backoff, or a webhook plus sweep.
Written by Sume