Agent run webhook: created_at orders deliveries, request_id dedupes
An agent.run.terminal delivery has two ids that look alike. request_id dedupes retries; created_at orders deliveries. Includes a Python receiver.

Dedupe on the envelope's request_id and order by created_at. Sume's run webhook docs say request_id equals the run id and is stable across retries, so it is the dedupe key. created_at is the time Sume built that delivery body, so it is the field for putting deliveries in order, which request_id cannot do because it never changes between retries (Run webhooks).
Two ids that look alike
The docs call out a trap. The envelope's request_id is the run id. The receipt inside, at payload.request_id, is a correlation id: in a webhook it is also the run id, but when you read the same receipt with a GET it is an HTTP req_ id. Dedupe on the envelope value, or on run_id, and ignore the nested one.
An Agent Completion fires exactly one terminal event, agent.run.terminal, when the run completes or fails. A canceled run sends nothing, so do not wait for a POST after you cancel.
| Field | Use it to | Stable across retries? |
|---|---|---|
| request_id | Dedupe | Yes |
| run_id | Look up the run; equals request_id | Yes |
| created_at | Order deliveries | No, it is the body build time |
| status / outcome | Branch: ok, degraded, error | Yes |
| payload.request_id | Ignore; correlation only | Depends on transport |
Verify first, then dedupe
Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends x-sume-webhook-signature: sume-v1=<hex> and x-sume-webhook-timestamp. Verify against the raw body before you parse it, and reject a timestamp outside a replay window; five minutes is the documented default. The receiver below refuses an empty secret and runs offline with a self-signed test.
import hashlib, hmac, json, time
def verify(raw: bytes, ts: str, sig: str, secret: str) -> bool:
if not secret:
raise ValueError("empty webhook secret")
if abs(time.time() - int(ts)) > 300:
return False
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw,
hashlib.sha256).hexdigest()
return hmac.compare_digest("sume-v1=" + mac, sig)
SEEN = set()
def first_time(event: dict) -> bool:
rid = event["request_id"]
if rid in SEEN:
return False
SEEN.add(rid)
return True
if __name__ == "__main__":
raw = json.dumps({"request_id": "agrun_1"}).encode()
ts = str(int(time.time()))
sig = "sume-v1=" + hmac.new(b"s", ts.encode() + b"." + raw,
hashlib.sha256).hexdigest()
print(verify(raw, ts, sig, "s"), first_time(json.loads(raw)))Process after you answer
Record the event durably, then return a 2xx quickly. Each attempt times out at 10 seconds, success is any 2xx, and a redirect counts as a failed attempt. Sume makes up to 10 attempts with backoff, then marks the delivery exhausted.
Do the real work after the response. A slow handler uses the whole attempt budget and invites a retry, which your dedupe then has to absorb.
Handling out-of-order deliveries
Retries mean a delivery can arrive late, after your system already moved on. Keep the created_at of the last event you applied for each run, and ignore any delivery whose created_at is older. Because a run has one terminal event, this rarely matters for a single run, but it matters if you also write run state from a poll or from a list call.
Store the event before you act on it. If your worker crashes between the write and the side effect, the retry arrives with the same request_id, and you can resume from the stored row instead of repeating a paid follow-up step. Keep the dedupe set in a database with a unique constraint rather than in memory, since a restart empties memory and the retry schedule can stretch over hours.
Checklist
Review your receiver against these points.
- Verify the signature on the raw bytes, before parsing.
- Branch on
outcome, not onlystatus. - Dedupe on the envelope
request_id. - Keep a poll as a backup using
status_url.
Sources
Related posts
More in Developers
- Run webhook payload is null: payload_too_large means fetch result_url
Sume cannot send a receipt over 1 MiB inline. The webhook arrives with payload null and error.code payload_too_large. The run did not fail. Fetch result_url.
- Missed an agent run webhook? Poll status_url, redeliver is Format-only
Sume's docs describe a webhook redeliver route for Format runs. For an Agent Completion you did not get a POST for, read the run from its status or result URL.
- Build an AI image progress UI: gate on supports_streaming false
Sume's image catalog reports supports_streaming false on every row. Build the progress UI from job states and gate a live preview on that field.
- AI image to CMYK for print: Pillow convert and what it misses
Image.convert('CMYK') makes a print-mode TIFF from a Sume image in two lines, but it is not color-managed. The code, the gamut risk, and when to ask for an ICC.
Written by Sume