Webhook Idempotency Receiver
Handle retried provider events with delivery dedupe, logical idempotency keys, and repo policy checks.
Implement WebhookReceiver, an in-memory webhook receiver for provider events that may be retried. Correct idempotency distinguishes replaying the same delivery from receiving a different delivery for the same logical work.
Requirements
- Constructor receives
allowed_repos, a set of"org/repo"strings. handle(provider, delivery_id, event)returns a response dictionary.- Check order per delivery: if
(provider, delivery_id)was already handled, return the cached response immediately (even if the body changed). Otherwise validate fields, then repo policy, then logical run creation. - A duplicate
(provider, delivery_id)returns the original response (includingbad_requestandblocked). - Events must contain non-empty
org,repo, andaction(empty strings count as missing). - If
org,repo, oractionis missing or empty, return{"status": "bad_request", "code": 400}and cache that response under the delivery key (like any other response). - If
"org/repo"isn't allowed, return{"status": "blocked", "code": 403}and cache it under the delivery key. - For allowed events, create exactly one run for each logical key.
- The logical identity is the structured tuple
(provider, "org/repo", action, number_text). Normalizenumber_textwithstr(event.get("number") or ""). Missing, null, numeric zero, and the empty string share the empty number; integer7and string"7"share the same number text. Colons inside fields must not merge different identities. - Return
{"status": "created", "code": 202, "run_id": ...}for new runs and{"status": "duplicate", "code": 200, "run_id": ...}for duplicate logical work. - Return a fresh response dictionary on every call so callers can't alter cached responses. The allowlist is a constructor-time copy.
Example
Track the two deduplication keys separately:
| Provider delivery | Logical event | Response | Side effect |
|---|---|---|---|
("github", "delivery-1") | ("github", "cursor/app", "issue_comment", "7") | created | Create run-1. |
("github", "delivery-1") again | Same delivery, even with a changed body | Cached created | No new run. |
("github", "delivery-2") | Same logical event | duplicate | Reuse run-1. |
The example below covers new work, logical duplication, and a cached validation failure:
1receiver = WebhookReceiver({"cursor/app"})
2event = {"org": "cursor", "repo": "app", "action": "issue_comment", "number": 7}
3
4first = receiver.handle("github", "delivery-1", event)
5second = receiver.handle("github", "delivery-2", event)
6
7assert first["status"] == "created"
8assert second["status"] == "duplicate"
9assert first["run_id"] == second["run_id"]
10
11assert receiver.handle("github", "d-bad", {"repo": "app", "action": "push"}) == {
12 "status": "bad_request",
13 "code": 400,
14}Constraints
- Keep it in memory.
- Don't parse provider-specific payloads.
- Preserve deterministic run IDs:
run-1,run-2, and so on. - Scope delivery IDs by provider so different providers can't replay one another's cached response.
- Calls are sequential. Provider and delivery IDs are strings; present required fields are strings or null; org/repo strings are slash-free components. Numbers are integers, strings, or null. Don't strip field values.
A delivery retry and a repeated logical event take different branches. Reusing delivery-1 returns its original created response. Using delivery-2 with the same identity returns duplicate. Neither creates another run, but they aren't interchangeable responses.
Don't concatenate logical fields with :. Provider a:cursor/app plus action push and provider a plus action cursor/app:push would produce the same concatenated string for this repository. A tuple keeps them distinct.
This is a handler for already normalized inputs. In an HTTP service, authenticate every request before consulting delivery caches. In-memory check-then-create isn't atomic across workers or durable across restarts. A production receiver needs transactional uniqueness and durable enqueueing; a delivery ID by itself isn't proof of authenticity.