[{"data":1,"prerenderedAt":2716},["ShallowReactive",2],{"page-\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fbuilding-an-idempotent-webhook-receiver\u002F":3,"faq-schema-\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fbuilding-an-idempotent-webhook-receiver\u002F":2695},{"id":4,"title":5,"body":6,"description":2685,"extension":2686,"meta":2687,"navigation":242,"path":2691,"seo":2692,"stem":2693,"__hash__":2694},"content\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fbuilding-an-idempotent-webhook-receiver\u002Findex.md","Building an Idempotent Webhook Receiver",{"type":7,"value":8,"toc":2671},"minimark",[9,13,41,53,58,65,68,186,190,205,208,545,552,556,571,907,918,1073,1077,1108,1217,1221,1239,1338,1345,1763,1767,1838,1845,1849,1852,1860,1864,1867,1916,1922,1926,1932,2456,2459,2570,2574,2585,2589,2595,2601,2607,2613,2619,2623,2628,2647,2652,2667],[10,11,5],"h1",{"id":12},"building-an-idempotent-webhook-receiver",[14,15,16,17,21,22,25,26,30,31,34,35,40],"p",{},"Every webhook provider you will integrate — Stripe, GitHub, Shopify, Paddle, Slack — promises ",[18,19,20],"em",{},"at least once"," delivery. None of them promise ",[18,23,24],{},"exactly once",", and none of them promise order. That means your receiver will eventually be handed the same ",[27,28,29],"code",{},"payment_intent.succeeded"," twice, and it will be handed a ",[27,32,33],{},"subscription.updated"," that describes an older state than the one you already stored. If the handler is not idempotent, you refund twice, email twice, or downgrade a paying customer back to free. This page shows the four mechanics that make a receiver safe: an event-id ledger, an atomic claim, versioned state transitions, and a replay tool. It is part of ",[36,37,39],"a",{"href":38},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002F","Processing Webhooks with Python",", which covers the surrounding receive-verify-enqueue shape.",[14,42,43,44,52],{},"The recommendation up front: ",[45,46,47,48,51],"strong",{},"put the deduplication ledger in the same Postgres database as your business data and claim rows with ",[27,49,50],{},"INSERT ... ON CONFLICT DO NOTHING","."," Redis is faster and wrong for this, because a dedup marker that survives a crash but a state write that does not is worse than no dedup at all.",[54,55,57],"h2",{"id":56},"why-duplicates-and-reordering-are-the-normal-case","Why duplicates and reordering are the normal case",[14,59,60,61,64],{},"Duplicates are not an exotic failure. A provider retries whenever it does not see a ",[27,62,63],{},"2xx"," inside its timeout — typically 10 to 30 seconds. Your handler can commit the database write, take 12 seconds to call a slow downstream API, and then get killed by the platform's request timeout before the response flushes. From the provider's point of view nothing happened, so it redelivers. You now have one committed write and one pending redelivery of the same event.",[14,66,67],{},"Reordering is just as ordinary. Providers fan delivery out across workers, so two events emitted 40 milliseconds apart travel independent paths. Add a retry to the first one and the second arrives first. Stripe explicitly documents that it does not guarantee ordering, and if you are running more than one receiver instance behind a load balancer you have added a second source of interleaving on your own side.",[69,70,78,79,78,83,78,87,78,94,78,102,78,109,78,117,78,124,78,128,78,133,78,137,78,142,78,144,78,146,78,151,78,154,78,157,78,161,78,163,78,165,78,168,78,170,78,172,78,175,78,177,78,179,78,182],"svg",{"viewBox":71,"role":72,"ariaLabelledBy":73,"xmlns":76,"style":77},"0 0 720 250","img",[74,75],"idem-arrive-t","idem-arrive-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[80,81,82],"title",{"id":74},"Emitted order versus received order for three webhook events",[84,85,86],"desc",{"id":75},"Three events are emitted in sequence at the provider, but arrive at the receiver in a different order, and the first event is delivered a second time after a retry.",[88,89],"rect",{"x":90,"y":90,"width":91,"height":92,"fill":93},"0","720","250","var(--c-surface)",[95,96,101],"text",{"x":97,"y":98,"fill":99,"style":100},"360","26","var(--c-text)","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Same three events, two different orders",[95,103,108],{"x":104,"y":105,"fill":106,"style":107},"16","66","var(--c-text-muted)","text-anchor:start;font-size:11;font-family:var(--font-sans);","Emitted",[110,111],"line",{"x1":112,"y1":113,"x2":114,"y2":113,"stroke":115,"style":116},"90","80","700","var(--c-border)","stroke-width:2;",[110,118],{"x1":119,"y1":120,"x2":121,"y2":122,"stroke":115,"style":123},"170","88","350","160","stroke-width:1.5;stroke-dasharray:4 4;",[110,125],{"x1":126,"y1":120,"x2":127,"y2":122,"stroke":115,"style":123},"330","500",[110,129],{"x1":130,"y1":120,"x2":131,"y2":122,"stroke":132,"style":123},"490","210","var(--c-coral)",[110,134],{"x1":119,"y1":120,"x2":135,"y2":122,"stroke":136,"style":123},"630","var(--c-yellow)",[138,139],"circle",{"cx":119,"cy":113,"r":140,"fill":141},"7","var(--c-blue)",[138,143],{"cx":126,"cy":113,"r":140,"fill":141},[138,145],{"cx":130,"cy":113,"r":140,"fill":141},[95,147,150],{"x":119,"y":148,"fill":99,"style":149},"62","text-anchor:middle;font-size:11;font-family:var(--font-sans);","created v1",[95,152,153],{"x":126,"y":148,"fill":99,"style":149},"updated v2",[95,155,156],{"x":130,"y":148,"fill":99,"style":149},"updated v3",[95,158,160],{"x":104,"y":159,"fill":106,"style":107},"156","Received",[110,162],{"x1":112,"y1":119,"x2":114,"y2":119,"stroke":115,"style":116},[138,164],{"cx":131,"cy":119,"r":140,"fill":132},[138,166],{"cx":121,"cy":119,"r":140,"fill":167},"var(--c-teal)",[138,169],{"cx":127,"cy":119,"r":140,"fill":167},[138,171],{"cx":135,"cy":119,"r":140,"fill":136},[95,173,156],{"x":131,"y":174,"fill":99,"style":149},"194",[95,176,150],{"x":121,"y":174,"fill":99,"style":149},[95,178,153],{"x":127,"y":174,"fill":99,"style":149},[95,180,181],{"x":135,"y":174,"fill":99,"style":149},"created v1 again",[95,183,185],{"x":97,"y":184,"fill":106,"style":149},"228","The newest state lands first, and the oldest event lands twice",[54,187,189],{"id":188},"the-event-ledger-one-row-per-event-id-forever","The event ledger: one row per event id, forever",[14,191,192,193,196,197,200,201,204],{},"Every serious provider puts a stable, unique id on the event itself — ",[27,194,195],{},"evt_..."," on Stripe, the ",[27,198,199],{},"X-GitHub-Delivery"," header on GitHub, ",[27,202,203],{},"X-Shopify-Event-Id"," on Shopify. That id is your idempotency key. Do not invent your own by hashing the body: a provider that re-sends a payload with a refreshed timestamp will hash differently and slip past you.",[14,206,207],{},"Store one row per event id in a dedicated table, with the raw payload and a status column. That table is doing three jobs at once — deduplication, an audit log you can hand to a customer arguing about a charge, and the input to the replay tool later on. Keep the payload; the storage is trivial next to the support time it saves. At 50,000 events a month with a 2 KB average payload you are storing 100 MB a month, which costs cents on any managed Postgres.",[209,210,215],"pre",{"className":211,"code":212,"language":213,"meta":214,"style":214},"language-python shiki shiki-themes github-light github-dark","import asyncio\nimport os\n\nimport asyncpg\n\nDATABASE_URL = os.getenv(\"DATABASE_URL\", \"\")\nif not DATABASE_URL:\n    raise RuntimeError(\"DATABASE_URL is required\")\n\nDDL = \"\"\"\nCREATE TABLE IF NOT EXISTS webhook_event (\n    event_id     text PRIMARY KEY,\n    source       text        NOT NULL,\n    event_type   text        NOT NULL,\n    payload      jsonb       NOT NULL,\n    status       text        NOT NULL DEFAULT 'claimed',\n    attempts     integer     NOT NULL DEFAULT 0,\n    last_error   text,\n    received_at  timestamptz NOT NULL DEFAULT now(),\n    settled_at   timestamptz\n);\n\nCREATE INDEX IF NOT EXISTS webhook_event_pending_idx\n    ON webhook_event (status, received_at)\n    WHERE status IN ('claimed', 'failed');\n\"\"\"\n\n\nasync def migrate() -> None:\n    conn = await asyncpg.connect(DATABASE_URL)\n    try:\n        await conn.execute(DDL)\n    finally:\n        await conn.close()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(migrate())\n","python","",[27,216,217,229,237,244,252,257,283,298,315,320,331,337,343,349,355,361,367,373,379,385,391,397,402,408,414,420,426,431,436,457,476,484,497,505,513,518,523,539],{"__ignoreMap":214},[218,219,221,225],"span",{"class":110,"line":220},1,[218,222,224],{"class":223},"szBVR","import",[218,226,228],{"class":227},"sVt8B"," asyncio\n",[218,230,232,234],{"class":110,"line":231},2,[218,233,224],{"class":223},[218,235,236],{"class":227}," os\n",[218,238,240],{"class":110,"line":239},3,[218,241,243],{"emptyLinePlaceholder":242},true,"\n",[218,245,247,249],{"class":110,"line":246},4,[218,248,224],{"class":223},[218,250,251],{"class":227}," asyncpg\n",[218,253,255],{"class":110,"line":254},5,[218,256,243],{"emptyLinePlaceholder":242},[218,258,260,264,267,270,274,277,280],{"class":110,"line":259},6,[218,261,263],{"class":262},"sj4cs","DATABASE_URL",[218,265,266],{"class":223}," =",[218,268,269],{"class":227}," os.getenv(",[218,271,273],{"class":272},"sZZnC","\"DATABASE_URL\"",[218,275,276],{"class":227},", ",[218,278,279],{"class":272},"\"\"",[218,281,282],{"class":227},")\n",[218,284,286,289,292,295],{"class":110,"line":285},7,[218,287,288],{"class":223},"if",[218,290,291],{"class":223}," not",[218,293,294],{"class":262}," DATABASE_URL",[218,296,297],{"class":227},":\n",[218,299,301,304,307,310,313],{"class":110,"line":300},8,[218,302,303],{"class":223},"    raise",[218,305,306],{"class":262}," RuntimeError",[218,308,309],{"class":227},"(",[218,311,312],{"class":272},"\"DATABASE_URL is required\"",[218,314,282],{"class":227},[218,316,318],{"class":110,"line":317},9,[218,319,243],{"emptyLinePlaceholder":242},[218,321,323,326,328],{"class":110,"line":322},10,[218,324,325],{"class":262},"DDL",[218,327,266],{"class":223},[218,329,330],{"class":272}," \"\"\"\n",[218,332,334],{"class":110,"line":333},11,[218,335,336],{"class":272},"CREATE TABLE IF NOT EXISTS webhook_event (\n",[218,338,340],{"class":110,"line":339},12,[218,341,342],{"class":272},"    event_id     text PRIMARY KEY,\n",[218,344,346],{"class":110,"line":345},13,[218,347,348],{"class":272},"    source       text        NOT NULL,\n",[218,350,352],{"class":110,"line":351},14,[218,353,354],{"class":272},"    event_type   text        NOT NULL,\n",[218,356,358],{"class":110,"line":357},15,[218,359,360],{"class":272},"    payload      jsonb       NOT NULL,\n",[218,362,364],{"class":110,"line":363},16,[218,365,366],{"class":272},"    status       text        NOT NULL DEFAULT 'claimed',\n",[218,368,370],{"class":110,"line":369},17,[218,371,372],{"class":272},"    attempts     integer     NOT NULL DEFAULT 0,\n",[218,374,376],{"class":110,"line":375},18,[218,377,378],{"class":272},"    last_error   text,\n",[218,380,382],{"class":110,"line":381},19,[218,383,384],{"class":272},"    received_at  timestamptz NOT NULL DEFAULT now(),\n",[218,386,388],{"class":110,"line":387},20,[218,389,390],{"class":272},"    settled_at   timestamptz\n",[218,392,394],{"class":110,"line":393},21,[218,395,396],{"class":272},");\n",[218,398,400],{"class":110,"line":399},22,[218,401,243],{"emptyLinePlaceholder":242},[218,403,405],{"class":110,"line":404},23,[218,406,407],{"class":272},"CREATE INDEX IF NOT EXISTS webhook_event_pending_idx\n",[218,409,411],{"class":110,"line":410},24,[218,412,413],{"class":272},"    ON webhook_event (status, received_at)\n",[218,415,417],{"class":110,"line":416},25,[218,418,419],{"class":272},"    WHERE status IN ('claimed', 'failed');\n",[218,421,423],{"class":110,"line":422},26,[218,424,425],{"class":272},"\"\"\"\n",[218,427,429],{"class":110,"line":428},27,[218,430,243],{"emptyLinePlaceholder":242},[218,432,434],{"class":110,"line":433},28,[218,435,243],{"emptyLinePlaceholder":242},[218,437,439,442,445,449,452,455],{"class":110,"line":438},29,[218,440,441],{"class":223},"async",[218,443,444],{"class":223}," def",[218,446,448],{"class":447},"sScJk"," migrate",[218,450,451],{"class":227},"() -> ",[218,453,454],{"class":262},"None",[218,456,297],{"class":227},[218,458,460,463,466,469,472,474],{"class":110,"line":459},30,[218,461,462],{"class":227},"    conn ",[218,464,465],{"class":223},"=",[218,467,468],{"class":223}," await",[218,470,471],{"class":227}," asyncpg.connect(",[218,473,263],{"class":262},[218,475,282],{"class":227},[218,477,479,482],{"class":110,"line":478},31,[218,480,481],{"class":223},"    try",[218,483,297],{"class":227},[218,485,487,490,493,495],{"class":110,"line":486},32,[218,488,489],{"class":223},"        await",[218,491,492],{"class":227}," conn.execute(",[218,494,325],{"class":262},[218,496,282],{"class":227},[218,498,500,503],{"class":110,"line":499},33,[218,501,502],{"class":223},"    finally",[218,504,297],{"class":227},[218,506,508,510],{"class":110,"line":507},34,[218,509,489],{"class":223},[218,511,512],{"class":227}," conn.close()\n",[218,514,516],{"class":110,"line":515},35,[218,517,243],{"emptyLinePlaceholder":242},[218,519,521],{"class":110,"line":520},36,[218,522,243],{"emptyLinePlaceholder":242},[218,524,526,528,531,534,537],{"class":110,"line":525},37,[218,527,288],{"class":223},[218,529,530],{"class":262}," __name__",[218,532,533],{"class":223}," ==",[218,535,536],{"class":272}," \"__main__\"",[218,538,297],{"class":227},[218,540,542],{"class":110,"line":541},38,[218,543,544],{"class":227},"    asyncio.run(migrate())\n",[14,546,547,548,551],{},"The partial index matters more than it looks. Once the table holds two million settled rows, a worker scanning for pending work against a full index pays for every historical row on every poll. The ",[27,549,550],{},"WHERE"," clause keeps the index at the size of your actual backlog.",[54,553,555],{"id":554},"the-atomic-claim-let-the-database-decide-who-wins","The atomic claim: let the database decide who wins",[14,557,558,559,562,563,566,567,570],{},"The naive version is a ",[27,560,561],{},"SELECT"," to check whether the id exists, then an ",[27,564,565],{},"INSERT"," if it does not. That is a race, and it fires exactly when you least want it to: two retries of the same event arriving milliseconds apart on two workers both see nothing, both insert, both process. Fix it by making the insert itself the test. ",[27,568,569],{},"INSERT ... ON CONFLICT (event_id) DO NOTHING RETURNING event_id"," returns a row for the first caller and nothing for everyone after. The primary key constraint is the lock, and it is enforced by Postgres, not by your code.",[209,572,574],{"className":211,"code":573,"language":213,"meta":214,"style":214},"import os\n\nimport asyncpg\nimport orjson\nfrom fastapi import FastAPI, Request, Response\n\napp = FastAPI()\nSOURCE = os.getenv(\"WEBHOOK_SOURCE\", \"stripe\")\nCLAIM_SQL = \"\"\"\nINSERT INTO webhook_event (event_id, source, event_type, payload)\nVALUES ($1, $2, $3, $4::jsonb)\nON CONFLICT (event_id) DO NOTHING\nRETURNING event_id\n\"\"\"\n\n\n@app.post(\"\u002Fwebhooks\u002Finbound\")\nasync def inbound(request: Request) -> Response:\n    raw = await request.body()\n    # verify_signature() raises before anything touches the database\n    event = verify_signature(raw, dict(request.headers))\n\n    pool: asyncpg.Pool = request.app.state.pool\n    async with pool.acquire() as conn:\n        claimed = await conn.fetchval(\n            CLAIM_SQL,\n            event[\"id\"],\n            SOURCE,\n            event[\"type\"],\n            orjson.dumps(event).decode(),\n        )\n\n    if claimed is None:\n        return Response(status_code=200)  # duplicate, already owned\n\n    await enqueue_job(event[\"id\"])\n    return Response(status_code=200)\n",[27,575,576,582,586,592,599,612,616,626,645,654,659,664,669,674,678,682,686,698,710,722,728,744,748,758,775,787,795,806,813,822,827,832,836,852,875,879,892],{"__ignoreMap":214},[218,577,578,580],{"class":110,"line":220},[218,579,224],{"class":223},[218,581,236],{"class":227},[218,583,584],{"class":110,"line":231},[218,585,243],{"emptyLinePlaceholder":242},[218,587,588,590],{"class":110,"line":239},[218,589,224],{"class":223},[218,591,251],{"class":227},[218,593,594,596],{"class":110,"line":246},[218,595,224],{"class":223},[218,597,598],{"class":227}," orjson\n",[218,600,601,604,607,609],{"class":110,"line":254},[218,602,603],{"class":223},"from",[218,605,606],{"class":227}," fastapi ",[218,608,224],{"class":223},[218,610,611],{"class":227}," FastAPI, Request, Response\n",[218,613,614],{"class":110,"line":259},[218,615,243],{"emptyLinePlaceholder":242},[218,617,618,621,623],{"class":110,"line":285},[218,619,620],{"class":227},"app ",[218,622,465],{"class":223},[218,624,625],{"class":227}," FastAPI()\n",[218,627,628,631,633,635,638,640,643],{"class":110,"line":300},[218,629,630],{"class":262},"SOURCE",[218,632,266],{"class":223},[218,634,269],{"class":227},[218,636,637],{"class":272},"\"WEBHOOK_SOURCE\"",[218,639,276],{"class":227},[218,641,642],{"class":272},"\"stripe\"",[218,644,282],{"class":227},[218,646,647,650,652],{"class":110,"line":317},[218,648,649],{"class":262},"CLAIM_SQL",[218,651,266],{"class":223},[218,653,330],{"class":272},[218,655,656],{"class":110,"line":322},[218,657,658],{"class":272},"INSERT INTO webhook_event (event_id, source, event_type, payload)\n",[218,660,661],{"class":110,"line":333},[218,662,663],{"class":272},"VALUES ($1, $2, $3, $4::jsonb)\n",[218,665,666],{"class":110,"line":339},[218,667,668],{"class":272},"ON CONFLICT (event_id) DO NOTHING\n",[218,670,671],{"class":110,"line":345},[218,672,673],{"class":272},"RETURNING event_id\n",[218,675,676],{"class":110,"line":351},[218,677,425],{"class":272},[218,679,680],{"class":110,"line":357},[218,681,243],{"emptyLinePlaceholder":242},[218,683,684],{"class":110,"line":363},[218,685,243],{"emptyLinePlaceholder":242},[218,687,688,691,693,696],{"class":110,"line":369},[218,689,690],{"class":447},"@app.post",[218,692,309],{"class":227},[218,694,695],{"class":272},"\"\u002Fwebhooks\u002Finbound\"",[218,697,282],{"class":227},[218,699,700,702,704,707],{"class":110,"line":375},[218,701,441],{"class":223},[218,703,444],{"class":223},[218,705,706],{"class":447}," inbound",[218,708,709],{"class":227},"(request: Request) -> Response:\n",[218,711,712,715,717,719],{"class":110,"line":381},[218,713,714],{"class":227},"    raw ",[218,716,465],{"class":223},[218,718,468],{"class":223},[218,720,721],{"class":227}," request.body()\n",[218,723,724],{"class":110,"line":387},[218,725,727],{"class":726},"sJ8bj","    # verify_signature() raises before anything touches the database\n",[218,729,730,733,735,738,741],{"class":110,"line":393},[218,731,732],{"class":227},"    event ",[218,734,465],{"class":223},[218,736,737],{"class":227}," verify_signature(raw, ",[218,739,740],{"class":262},"dict",[218,742,743],{"class":227},"(request.headers))\n",[218,745,746],{"class":110,"line":399},[218,747,243],{"emptyLinePlaceholder":242},[218,749,750,753,755],{"class":110,"line":404},[218,751,752],{"class":227},"    pool: asyncpg.Pool ",[218,754,465],{"class":223},[218,756,757],{"class":227}," request.app.state.pool\n",[218,759,760,763,766,769,772],{"class":110,"line":410},[218,761,762],{"class":223},"    async",[218,764,765],{"class":223}," with",[218,767,768],{"class":227}," pool.acquire() ",[218,770,771],{"class":223},"as",[218,773,774],{"class":227}," conn:\n",[218,776,777,780,782,784],{"class":110,"line":416},[218,778,779],{"class":227},"        claimed ",[218,781,465],{"class":223},[218,783,468],{"class":223},[218,785,786],{"class":227}," conn.fetchval(\n",[218,788,789,792],{"class":110,"line":422},[218,790,791],{"class":262},"            CLAIM_SQL",[218,793,794],{"class":227},",\n",[218,796,797,800,803],{"class":110,"line":428},[218,798,799],{"class":227},"            event[",[218,801,802],{"class":272},"\"id\"",[218,804,805],{"class":227},"],\n",[218,807,808,811],{"class":110,"line":433},[218,809,810],{"class":262},"            SOURCE",[218,812,794],{"class":227},[218,814,815,817,820],{"class":110,"line":438},[218,816,799],{"class":227},[218,818,819],{"class":272},"\"type\"",[218,821,805],{"class":227},[218,823,824],{"class":110,"line":459},[218,825,826],{"class":227},"            orjson.dumps(event).decode(),\n",[218,828,829],{"class":110,"line":478},[218,830,831],{"class":227},"        )\n",[218,833,834],{"class":110,"line":486},[218,835,243],{"emptyLinePlaceholder":242},[218,837,838,841,844,847,850],{"class":110,"line":499},[218,839,840],{"class":223},"    if",[218,842,843],{"class":227}," claimed ",[218,845,846],{"class":223},"is",[218,848,849],{"class":262}," None",[218,851,297],{"class":227},[218,853,854,857,860,864,866,869,872],{"class":110,"line":507},[218,855,856],{"class":223},"        return",[218,858,859],{"class":227}," Response(",[218,861,863],{"class":862},"s4XuR","status_code",[218,865,465],{"class":223},[218,867,868],{"class":262},"200",[218,870,871],{"class":227},")  ",[218,873,874],{"class":726},"# duplicate, already owned\n",[218,876,877],{"class":110,"line":515},[218,878,243],{"emptyLinePlaceholder":242},[218,880,881,884,887,889],{"class":110,"line":520},[218,882,883],{"class":223},"    await",[218,885,886],{"class":227}," enqueue_job(event[",[218,888,802],{"class":272},[218,890,891],{"class":227},"])\n",[218,893,894,897,899,901,903,905],{"class":110,"line":525},[218,895,896],{"class":223},"    return",[218,898,859],{"class":227},[218,900,863],{"class":862},[218,902,465],{"class":223},[218,904,868],{"class":262},[218,906,282],{"class":227},[14,908,909,910,914,915,917],{},"Two details carry the whole design. First, verification happens before the insert — never write an unverified payload into a table you later trust, and see ",[36,911,913],{"href":912},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures\u002F","Verifying Stripe Webhook Signatures"," for the exact check. Second, a duplicate still returns ",[27,916,868],{},". Returning an error on a duplicate teaches the provider to retry harder, which is the opposite of what you want, and it turns a healthy dedup into a red delivery log the customer sees.",[69,919,78,924,78,927,78,930,78,933,78,950,78,957,78,963,78,967,78,972,78,974,78,978,78,981,78,984,78,987,78,991,78,994,78,998,78,1001,78,1005,78,1008,78,1012,78,1016,78,1019,78,1025,78,1030,78,1035,78,1039,78,1042,78,1044,78,1047,78,1050,78,1052,78,1056,78,1059,78,1062,78,1064,78,1069],{"viewBox":920,"role":72,"ariaLabelledBy":921,"xmlns":76,"style":77},"0 0 720 290",[922,923],"idem-flow-t","idem-flow-d",[80,925,926],{"id":922},"Request path through signature verification and the atomic claim",[84,928,929],{"id":923},"A POST is verified, its event id extracted, and an insert with on conflict do nothing decides whether the request enqueues work or acknowledges as a duplicate; both paths return 200.",[88,931],{"x":90,"y":90,"width":91,"height":932,"fill":93},"290",[934,935,936,937,78],"defs",{},"\n    ",[938,939,945,946,936],"marker",{"id":940,"viewBox":941,"refX":942,"refY":943,"markerWidth":140,"markerHeight":140,"orient":944},"idem-flow-arrow","0 0 10 10","9","5","auto-start-reverse","\n      ",[947,948],"path",{"d":949,"fill":106},"M0 0 L10 5 L0 10 z",[88,951],{"x":952,"y":953,"width":954,"height":955,"rx":956,"fill":93,"stroke":141,"style":116},"20","40","150","54","10",[95,958,962],{"x":959,"y":960,"fill":99,"style":961},"95","64","text-anchor:middle;font-size:12;font-family:var(--font-sans);","POST \u002Fwebhooks",[95,964,966],{"x":959,"y":965,"fill":106,"style":149},"81","raw body",[110,968],{"x1":119,"y1":969,"x2":970,"y2":969,"stroke":106,"style":971},"67","196","stroke-width:2;marker-end:url(#idem-flow-arrow);",[88,973],{"x":868,"y":953,"width":954,"height":955,"rx":956,"fill":93,"stroke":167,"style":116},[95,975,977],{"x":976,"y":960,"fill":99,"style":961},"275","Verify signature",[95,979,980],{"x":976,"y":965,"fill":106,"style":149},"reject on fail",[110,982],{"x1":121,"y1":969,"x2":983,"y2":969,"stroke":106,"style":971},"376",[88,985],{"x":986,"y":953,"width":954,"height":955,"rx":956,"fill":93,"stroke":167,"style":116},"380",[95,988,990],{"x":989,"y":960,"fill":99,"style":961},"455","Read event_id",[95,992,993],{"x":989,"y":965,"fill":106,"style":149},"provider id only",[110,995],{"x1":996,"y1":969,"x2":997,"y2":969,"stroke":106,"style":971},"530","546",[88,999],{"x":1000,"y":953,"width":954,"height":955,"rx":956,"fill":93,"stroke":132,"style":116},"550",[95,1002,1004],{"x":1003,"y":960,"fill":99,"style":961},"625","Claim row",[95,1006,1007],{"x":1003,"y":965,"fill":106,"style":149},"ON CONFLICT skip",[110,1009],{"x1":1003,"y1":1010,"x2":1003,"y2":1011,"stroke":106,"style":116},"94","116",[947,1013],{"d":1014,"fill":1015,"stroke":106,"style":971},"M625 116 H455 V146","none",[110,1017],{"x1":1003,"y1":1011,"x2":1003,"y2":1018,"stroke":106,"style":971},"146",[95,1020,1024],{"x":1021,"y":1022,"fill":106,"style":1023},"540","110","text-anchor:middle;font-size:10;font-family:var(--font-sans);","1 row",[95,1026,1029],{"x":1027,"y":1028,"fill":106,"style":1023},"666","136","0 rows",[88,1031],{"x":986,"y":954,"width":954,"height":1032,"rx":956,"fill":1033,"stroke":115,"style":1034},"60","var(--c-surface-alt)","stroke-width:1.5;",[95,1036,1038],{"x":989,"y":1037,"fill":99,"style":961},"176","New event",[95,1040,1041],{"x":989,"y":174,"fill":106,"style":149},"enqueue worker job",[88,1043],{"x":1000,"y":954,"width":954,"height":1032,"rx":956,"fill":1033,"stroke":115,"style":1034},[95,1045,1046],{"x":1003,"y":1037,"fill":99,"style":961},"Duplicate",[95,1048,1049],{"x":1003,"y":174,"fill":106,"style":149},"no work at all",[88,1051],{"x":952,"y":954,"width":126,"height":1032,"rx":956,"fill":93,"stroke":115,"style":1034},[95,1053,1055],{"x":1054,"y":1037,"fill":99,"style":149},"185","The claim is the idempotency boundary:",[95,1057,1058],{"x":1054,"y":174,"fill":99,"style":149},"exactly one caller ever gets a returned row.",[110,1060],{"x1":989,"y1":131,"x2":989,"y2":1061,"stroke":106,"style":971},"236",[110,1063],{"x1":1003,"y1":131,"x2":1003,"y2":1061,"stroke":106,"style":971},[88,1065],{"x":986,"y":1066,"width":1067,"height":1068,"rx":956,"fill":93,"stroke":141,"style":116},"240","320","38",[95,1070,1072],{"x":1021,"y":1071,"fill":99,"style":961},"264","Return 200 — the worker does the real work",[54,1074,1076],{"id":1075},"the-rows-lifecycle-and-who-is-allowed-to-move-it","The row's lifecycle, and who is allowed to move it",[14,1078,1079,1080,1083,1084,1087,1088,1091,1092,1094,1095,1098,1099,1102,1103,1107],{},"A claimed row is not a finished row. The worker moves it through ",[27,1081,1082],{},"processing"," to ",[27,1085,1086],{},"settled",", or to ",[27,1089,1090],{},"failed"," with an incremented attempt counter. Only the transition into ",[27,1093,1082],{}," needs to be exclusive, and ",[27,1096,1097],{},"UPDATE ... WHERE status = 'claimed'"," with a ",[27,1100,1101],{},"RETURNING"," clause gives you that for free — the update takes a row lock and the second worker sees zero rows affected. If you already run ",[36,1104,1106],{"href":1105},"\u002Fscaling-and-operating-production-python-apis\u002Frunning-background-jobs-with-celery\u002F","background jobs with Celery",", the queue gives you concurrency and the ledger gives you correctness; they are not substitutes.",[69,1109,78,1113,78,1116,78,1119,78,1121,78,1128,78,1131,78,1136,78,1141,78,1146,78,1151,78,1154,78,1157,78,1161,78,1165,78,1169,78,1171,78,1174,78,1180,78,1183,78,1188,78,1190,78,1193,78,1196,78,1199,78,1201,78,1204,78,1207,78,1210,78,1213],{"viewBox":71,"role":72,"ariaLabelledBy":1110,"xmlns":76,"style":77},[1111,1112],"idem-state-t","idem-state-d",[80,1114,1115],{"id":1111},"State machine for a single webhook event row",[84,1117,1118],{"id":1112},"A claimed row moves to processing, then to settled on commit or failed on error; failed rows retry back into processing and move to a dead letter state after five tries, while duplicate posts are acknowledged without changing state.",[88,1120],{"x":90,"y":90,"width":91,"height":92,"fill":93},[934,1122,936,1123,78],{},[938,1124,945,1126,936],{"id":1125,"viewBox":941,"refX":942,"refY":943,"markerWidth":140,"markerHeight":140,"orient":944},"idem-state-arrow",[947,1127],{"d":949,"fill":106},[95,1129,1130],{"x":97,"y":98,"fill":99,"style":100},"Lifecycle of one webhook_event row",[88,1132],{"x":952,"y":1133,"width":1134,"height":1135,"rx":98,"fill":93,"stroke":141,"style":116},"42","130","52",[95,1137,1140],{"x":1138,"y":1139,"fill":99,"style":961},"85","74","claimed",[110,1142],{"x1":954,"y1":1143,"x2":1144,"y2":1143,"stroke":106,"style":1145},"68","266","stroke-width:2;marker-end:url(#idem-state-arrow);",[95,1147,1150],{"x":1148,"y":1149,"fill":106,"style":1023},"208","58","worker picks",[88,1152],{"x":1153,"y":1133,"width":954,"height":1135,"rx":98,"fill":93,"stroke":167,"style":116},"270",[95,1155,1082],{"x":1156,"y":1139,"fill":99,"style":961},"345",[110,1158],{"x1":1159,"y1":1143,"x2":1160,"y2":1143,"stroke":106,"style":1145},"420","476",[95,1162,1164],{"x":1163,"y":1149,"fill":106,"style":1023},"448","commit",[88,1166],{"x":1167,"y":1133,"width":1168,"height":1135,"rx":98,"fill":93,"stroke":167,"style":116},"480","140",[95,1170,1086],{"x":1000,"y":1139,"fill":99,"style":961},[110,1172],{"x1":1173,"y1":1010,"x2":1173,"y2":159,"stroke":106,"style":1145},"365",[95,1175,1179],{"x":1176,"y":1177,"fill":106,"style":1178},"378","128","text-anchor:start;font-size:10;font-family:var(--font-sans);","raise",[110,1181],{"x1":1182,"y1":159,"x2":1182,"y2":1010,"stroke":106,"style":1145},"315",[95,1184,1187],{"x":1185,"y":1177,"fill":106,"style":1186},"303","text-anchor:end;font-size:10;font-family:var(--font-sans);","retry",[88,1189],{"x":1153,"y":122,"width":954,"height":1135,"rx":98,"fill":93,"stroke":132,"style":116},[95,1191,1090],{"x":1156,"y":1192,"fill":99,"style":961},"192",[110,1194],{"x1":1159,"y1":1195,"x2":1160,"y2":1195,"stroke":106,"style":1145},"186",[95,1197,1198],{"x":1163,"y":1037,"fill":106,"style":1023},"5 tries",[88,1200],{"x":1167,"y":122,"width":1168,"height":1135,"rx":98,"fill":93,"stroke":132,"style":116},[95,1202,1203],{"x":1000,"y":1192,"fill":99,"style":961},"dead letter",[88,1205],{"x":952,"y":122,"width":1206,"height":1135,"rx":956,"fill":1033,"stroke":115,"style":1034},"180",[95,1208,1209],{"x":1022,"y":1192,"fill":99,"style":149},"duplicate POST → 200",[110,1211],{"x1":1138,"y1":122,"x2":1138,"y2":1212,"stroke":136,"style":1145},"98",[95,1214,1216],{"x":97,"y":1215,"fill":106,"style":149},"238","Only the move into processing needs exclusivity",[54,1218,1220],{"id":1219},"versioned-state-transitions-beat-last-write-wins","Versioned state transitions beat \"last write wins\"",[14,1222,1223,1224,1226,1227,1230,1231,1234,1235,1238],{},"Deduplication solves repeats. It does nothing for order. If ",[27,1225,33],{}," v3 lands before v2, both are unique events, both pass the claim, and the later-processed older event overwrites your customer's plan with stale data. The fix is a monotonic version on the ",[18,1228,1229],{},"entity",", not on the event: Stripe objects carry ",[27,1232,1233],{},"created"," and Shopify carries ",[27,1236,1237],{},"updated_at",", and both providers include the full object in the payload. Store the version you applied, and make every write conditional on moving forward.",[69,1240,78,1244,78,1247,78,1250,78,1252,78,1256,78,1262,78,1266,78,1269,78,1273,78,1275,78,1279,78,1281,78,1285,78,1290,78,1293,78,1296,78,1299,78,1302,78,1305,78,1308,78,1311,78,1314,78,1317,78,1320,78,1323,78,1327,78,1329,78,1332,78,1335],{"viewBox":71,"role":72,"ariaLabelledBy":1241,"xmlns":76,"style":77},[1242,1243],"idem-ver-t","idem-ver-d",[80,1245,1246],{"id":1242},"Version guard outcomes for four incoming events",[84,1248,1249],{"id":1243},"A table showing that events with a version higher than the stored version are applied, while an older version and an exact replay are both skipped.",[88,1251],{"x":90,"y":90,"width":91,"height":92,"fill":93},[95,1253,1255],{"x":97,"y":1254,"fill":99,"style":100},"28","Guard: UPDATE ... WHERE version \u003C incoming",[88,1257],{"x":1258,"y":1259,"width":1260,"height":1261,"fill":1033,"stroke":115,"style":1034},"30","46","660","34",[95,1263,1265],{"x":1264,"y":1143,"fill":99,"style":961},"145","Incoming",[95,1267,1268],{"x":1173,"y":1143,"fill":99,"style":961},"Stored",[95,1270,1272],{"x":1271,"y":1143,"fill":99,"style":961},"580","Outcome",[88,1274],{"x":1258,"y":113,"width":1260,"height":1261,"fill":93,"stroke":115,"style":1034},[95,1276,1278],{"x":1264,"y":1277,"fill":99,"style":961},"102","v1",[95,1280,1015],{"x":1173,"y":1277,"fill":106,"style":961},[138,1282],{"cx":1283,"cy":1284,"r":943,"fill":167},"505","97",[95,1286,1289],{"x":1287,"y":1277,"fill":99,"style":1288},"522","text-anchor:start;font-size:12;font-family:var(--font-sans);","apply, store 1",[88,1291],{"x":1258,"y":1292,"width":1260,"height":1261,"fill":93,"stroke":115,"style":1034},"114",[95,1294,1295],{"x":1264,"y":1028,"fill":99,"style":961},"v3",[95,1297,1298],{"x":1173,"y":1028,"fill":106,"style":961},"1",[138,1300],{"cx":1283,"cy":1301,"r":943,"fill":167},"131",[95,1303,1304],{"x":1287,"y":1028,"fill":99,"style":1288},"apply, store 3",[88,1306],{"x":1258,"y":1307,"width":1260,"height":1261,"fill":93,"stroke":115,"style":1034},"148",[95,1309,1310],{"x":1264,"y":119,"fill":99,"style":961},"v2 (late)",[95,1312,1313],{"x":1173,"y":119,"fill":106,"style":961},"3",[138,1315],{"cx":1283,"cy":1316,"r":943,"fill":132},"165",[95,1318,1319],{"x":1287,"y":119,"fill":99,"style":1288},"skip, keep 3",[88,1321],{"x":1258,"y":1322,"width":1260,"height":1261,"fill":93,"stroke":115,"style":1034},"182",[95,1324,1326],{"x":1264,"y":1325,"fill":99,"style":961},"204","v3 replay",[95,1328,1313],{"x":1173,"y":1325,"fill":106,"style":961},[138,1330],{"cx":1283,"cy":1331,"r":943,"fill":132},"199",[95,1333,1334],{"x":1287,"y":1325,"fill":99,"style":1288},"skip, no-op",[95,1336,1337],{"x":97,"y":1215,"fill":106,"style":149},"Zero rows updated is a success, not an error",[14,1339,1340,1341,1344],{},"Here is the worker side. Note the ",[27,1342,1343],{},"match"," on event type and the single transaction that applies business state, bumps the version, and settles the ledger row together — if anything raises, all three roll back and the retry finds the row exactly as it was.",[209,1346,1348],{"className":211,"code":1347,"language":213,"meta":214,"style":214},"import os\n\nimport asyncpg\nimport orjson\n\nDATABASE_URL = os.getenv(\"DATABASE_URL\", \"\")\nLEASE_SQL = \"\"\"\nUPDATE webhook_event SET status = 'processing', attempts = attempts + 1\nWHERE event_id = $1 AND status IN ('claimed', 'failed')\nRETURNING payload\n\"\"\"\nAPPLY_SQL = \"\"\"\nUPDATE subscription SET plan = $2, state = $3, version = $4\nWHERE stripe_id = $1 AND version \u003C $4\n\"\"\"\n\n\nasync def process(pool: asyncpg.Pool, event_id: str) -> str:\n    async with pool.acquire() as conn, conn.transaction():\n        raw = await conn.fetchval(LEASE_SQL, event_id)\n        if raw is None:\n            return \"already-leased\"\n\n        event = orjson.loads(raw)\n        obj = event[\"data\"][\"object\"]\n        version = int(obj[\"created\"])\n\n        match event[\"type\"]:\n            case \"customer.subscription.updated\" | \"customer.subscription.created\":\n                await conn.execute(\n                    APPLY_SQL, obj[\"id\"], obj[\"items\"][\"data\"][0][\"price\"][\"id\"],\n                    obj[\"status\"], version,\n                )\n            case \"customer.subscription.deleted\":\n                await conn.execute(APPLY_SQL, obj[\"id\"], None, \"canceled\", version)\n            case _:\n                pass  # unhandled types still settle, so they stop retrying\n\n        await conn.execute(\n            \"UPDATE webhook_event SET status = 'settled', settled_at = now()\"\n            \" WHERE event_id = $1\",\n            event_id,\n        )\n    return \"settled\"\n",[27,1349,1350,1356,1360,1366,1372,1376,1392,1401,1406,1411,1416,1420,1429,1434,1439,1443,1447,1451,1473,1486,1503,1517,1525,1529,1539,1561,1579,1583,1595,1611,1619,1654,1665,1670,1679,1704,1711,1719,1723,1730,1736,1744,1750,1755],{"__ignoreMap":214},[218,1351,1352,1354],{"class":110,"line":220},[218,1353,224],{"class":223},[218,1355,236],{"class":227},[218,1357,1358],{"class":110,"line":231},[218,1359,243],{"emptyLinePlaceholder":242},[218,1361,1362,1364],{"class":110,"line":239},[218,1363,224],{"class":223},[218,1365,251],{"class":227},[218,1367,1368,1370],{"class":110,"line":246},[218,1369,224],{"class":223},[218,1371,598],{"class":227},[218,1373,1374],{"class":110,"line":254},[218,1375,243],{"emptyLinePlaceholder":242},[218,1377,1378,1380,1382,1384,1386,1388,1390],{"class":110,"line":259},[218,1379,263],{"class":262},[218,1381,266],{"class":223},[218,1383,269],{"class":227},[218,1385,273],{"class":272},[218,1387,276],{"class":227},[218,1389,279],{"class":272},[218,1391,282],{"class":227},[218,1393,1394,1397,1399],{"class":110,"line":285},[218,1395,1396],{"class":262},"LEASE_SQL",[218,1398,266],{"class":223},[218,1400,330],{"class":272},[218,1402,1403],{"class":110,"line":300},[218,1404,1405],{"class":272},"UPDATE webhook_event SET status = 'processing', attempts = attempts + 1\n",[218,1407,1408],{"class":110,"line":317},[218,1409,1410],{"class":272},"WHERE event_id = $1 AND status IN ('claimed', 'failed')\n",[218,1412,1413],{"class":110,"line":322},[218,1414,1415],{"class":272},"RETURNING payload\n",[218,1417,1418],{"class":110,"line":333},[218,1419,425],{"class":272},[218,1421,1422,1425,1427],{"class":110,"line":339},[218,1423,1424],{"class":262},"APPLY_SQL",[218,1426,266],{"class":223},[218,1428,330],{"class":272},[218,1430,1431],{"class":110,"line":345},[218,1432,1433],{"class":272},"UPDATE subscription SET plan = $2, state = $3, version = $4\n",[218,1435,1436],{"class":110,"line":351},[218,1437,1438],{"class":272},"WHERE stripe_id = $1 AND version \u003C $4\n",[218,1440,1441],{"class":110,"line":357},[218,1442,425],{"class":272},[218,1444,1445],{"class":110,"line":363},[218,1446,243],{"emptyLinePlaceholder":242},[218,1448,1449],{"class":110,"line":369},[218,1450,243],{"emptyLinePlaceholder":242},[218,1452,1453,1455,1457,1460,1463,1466,1469,1471],{"class":110,"line":375},[218,1454,441],{"class":223},[218,1456,444],{"class":223},[218,1458,1459],{"class":447}," process",[218,1461,1462],{"class":227},"(pool: asyncpg.Pool, event_id: ",[218,1464,1465],{"class":262},"str",[218,1467,1468],{"class":227},") -> ",[218,1470,1465],{"class":262},[218,1472,297],{"class":227},[218,1474,1475,1477,1479,1481,1483],{"class":110,"line":381},[218,1476,762],{"class":223},[218,1478,765],{"class":223},[218,1480,768],{"class":227},[218,1482,771],{"class":223},[218,1484,1485],{"class":227}," conn, conn.transaction():\n",[218,1487,1488,1491,1493,1495,1498,1500],{"class":110,"line":387},[218,1489,1490],{"class":227},"        raw ",[218,1492,465],{"class":223},[218,1494,468],{"class":223},[218,1496,1497],{"class":227}," conn.fetchval(",[218,1499,1396],{"class":262},[218,1501,1502],{"class":227},", event_id)\n",[218,1504,1505,1508,1511,1513,1515],{"class":110,"line":393},[218,1506,1507],{"class":223},"        if",[218,1509,1510],{"class":227}," raw ",[218,1512,846],{"class":223},[218,1514,849],{"class":262},[218,1516,297],{"class":227},[218,1518,1519,1522],{"class":110,"line":399},[218,1520,1521],{"class":223},"            return",[218,1523,1524],{"class":272}," \"already-leased\"\n",[218,1526,1527],{"class":110,"line":404},[218,1528,243],{"emptyLinePlaceholder":242},[218,1530,1531,1534,1536],{"class":110,"line":410},[218,1532,1533],{"class":227},"        event ",[218,1535,465],{"class":223},[218,1537,1538],{"class":227}," orjson.loads(raw)\n",[218,1540,1541,1544,1546,1549,1552,1555,1558],{"class":110,"line":416},[218,1542,1543],{"class":227},"        obj ",[218,1545,465],{"class":223},[218,1547,1548],{"class":227}," event[",[218,1550,1551],{"class":272},"\"data\"",[218,1553,1554],{"class":227},"][",[218,1556,1557],{"class":272},"\"object\"",[218,1559,1560],{"class":227},"]\n",[218,1562,1563,1566,1568,1571,1574,1577],{"class":110,"line":422},[218,1564,1565],{"class":227},"        version ",[218,1567,465],{"class":223},[218,1569,1570],{"class":262}," int",[218,1572,1573],{"class":227},"(obj[",[218,1575,1576],{"class":272},"\"created\"",[218,1578,891],{"class":227},[218,1580,1581],{"class":110,"line":428},[218,1582,243],{"emptyLinePlaceholder":242},[218,1584,1585,1588,1590,1592],{"class":110,"line":433},[218,1586,1587],{"class":223},"        match",[218,1589,1548],{"class":227},[218,1591,819],{"class":272},[218,1593,1594],{"class":227},"]:\n",[218,1596,1597,1600,1603,1606,1609],{"class":110,"line":438},[218,1598,1599],{"class":223},"            case",[218,1601,1602],{"class":272}," \"customer.subscription.updated\"",[218,1604,1605],{"class":223}," |",[218,1607,1608],{"class":272}," \"customer.subscription.created\"",[218,1610,297],{"class":227},[218,1612,1613,1616],{"class":110,"line":459},[218,1614,1615],{"class":223},"                await",[218,1617,1618],{"class":227}," conn.execute(\n",[218,1620,1621,1624,1627,1629,1632,1635,1637,1639,1641,1643,1645,1648,1650,1652],{"class":110,"line":478},[218,1622,1623],{"class":262},"                    APPLY_SQL",[218,1625,1626],{"class":227},", obj[",[218,1628,802],{"class":272},[218,1630,1631],{"class":227},"], obj[",[218,1633,1634],{"class":272},"\"items\"",[218,1636,1554],{"class":227},[218,1638,1551],{"class":272},[218,1640,1554],{"class":227},[218,1642,90],{"class":262},[218,1644,1554],{"class":227},[218,1646,1647],{"class":272},"\"price\"",[218,1649,1554],{"class":227},[218,1651,802],{"class":272},[218,1653,805],{"class":227},[218,1655,1656,1659,1662],{"class":110,"line":486},[218,1657,1658],{"class":227},"                    obj[",[218,1660,1661],{"class":272},"\"status\"",[218,1663,1664],{"class":227},"], version,\n",[218,1666,1667],{"class":110,"line":499},[218,1668,1669],{"class":227},"                )\n",[218,1671,1672,1674,1677],{"class":110,"line":507},[218,1673,1599],{"class":223},[218,1675,1676],{"class":272}," \"customer.subscription.deleted\"",[218,1678,297],{"class":227},[218,1680,1681,1683,1685,1687,1689,1691,1694,1696,1698,1701],{"class":110,"line":515},[218,1682,1615],{"class":223},[218,1684,492],{"class":227},[218,1686,1424],{"class":262},[218,1688,1626],{"class":227},[218,1690,802],{"class":272},[218,1692,1693],{"class":227},"], ",[218,1695,454],{"class":262},[218,1697,276],{"class":227},[218,1699,1700],{"class":272},"\"canceled\"",[218,1702,1703],{"class":227},", version)\n",[218,1705,1706,1708],{"class":110,"line":520},[218,1707,1599],{"class":223},[218,1709,1710],{"class":227}," _:\n",[218,1712,1713,1716],{"class":110,"line":525},[218,1714,1715],{"class":223},"                pass",[218,1717,1718],{"class":726},"  # unhandled types still settle, so they stop retrying\n",[218,1720,1721],{"class":110,"line":541},[218,1722,243],{"emptyLinePlaceholder":242},[218,1724,1726,1728],{"class":110,"line":1725},39,[218,1727,489],{"class":223},[218,1729,1618],{"class":227},[218,1731,1733],{"class":110,"line":1732},40,[218,1734,1735],{"class":272},"            \"UPDATE webhook_event SET status = 'settled', settled_at = now()\"\n",[218,1737,1739,1742],{"class":110,"line":1738},41,[218,1740,1741],{"class":272},"            \" WHERE event_id = $1\"",[218,1743,794],{"class":227},[218,1745,1747],{"class":110,"line":1746},42,[218,1748,1749],{"class":227},"            event_id,\n",[218,1751,1753],{"class":110,"line":1752},43,[218,1754,831],{"class":227},[218,1756,1758,1760],{"class":110,"line":1757},44,[218,1759,896],{"class":223},[218,1761,1762],{"class":272}," \"settled\"\n",[54,1764,1766],{"id":1765},"where-the-ledger-should-live","Where the ledger should live",[1768,1769,1770,1789],"table",{},[1771,1772,1773],"thead",{},[1774,1775,1776,1780,1783,1786],"tr",{},[1777,1778,1779],"th",{},"Store",[1777,1781,1782],{},"Survives crash",[1777,1784,1785],{},"Cost at 1M events",[1777,1787,1788],{},"Verdict",[1790,1791,1792,1807,1824],"tbody",{},[1774,1793,1794,1798,1801,1804],{},[1795,1796,1797],"td",{},"Postgres table",[1795,1799,1800],{},"Yes, same txn",[1795,1802,1803],{},"~1 GB storage",[1795,1805,1806],{},"Use this",[1774,1808,1809,1815,1818,1821],{},[1795,1810,1811,1812],{},"Redis ",[27,1813,1814],{},"SET NX",[1795,1816,1817],{},"Only if persisted",[1795,1819,1820],{},"Sub-cent, RAM-bound",[1795,1822,1823],{},"Cache only",[1774,1825,1826,1829,1832,1835],{},[1795,1827,1828],{},"In-process set",[1795,1830,1831],{},"No",[1795,1833,1834],{},"Free",[1795,1836,1837],{},"Never",[14,1839,1840,1841,51],{},"The Postgres row wins because the dedup marker and the business write commit or roll back together. A Redis marker set before a state write that then fails leaves you permanently deaf to that event — the retry gets skipped as a duplicate and the work never happens. Redis is still useful in front, as a 60-second bloom-style short-circuit that keeps a retry storm off the database, but it is an optimisation, not the source of truth. If your ledger writes start dominating latency, that is a connection-pool problem before it is a storage problem; see ",[36,1842,1844],{"href":1843},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Ffixing-connection-pool-exhaustion\u002F","Fixing Connection Pool Exhaustion",[54,1846,1848],{"id":1847},"when-to-build-this-and-when-to-skip-it","When to build this, and when to skip it",[14,1850,1851],{},"Build the full ledger the moment a webhook touches money, provisioning, or anything a customer can see. That is day one for a billing integration. Below roughly 10,000 events a month the whole thing costs a single table and about 30 lines, so the \"too early\" argument does not survive contact with one duplicated refund.",[14,1853,1854,1855,1859],{},"Skip it when the handler is already naturally idempotent — writing a fact that is true regardless of repetition, like upserting a contact record keyed on email, or setting a flag to the same value. A ",[36,1856,1858],{"href":1857},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fhandling-github-webhooks-with-fastapi\u002F","GitHub webhook"," that triggers a rebuild of a static site can happily run twice. Also skip the version guard specifically when events carry deltas rather than snapshots: an \"increment usage by 5\" event has no version to compare, and you must dedupe on event id alone, which is exactly why delta events make the ledger non-optional.",[54,1861,1863],{"id":1862},"migrating-a-receiver-that-already-runs","Migrating a receiver that already runs",[14,1865,1866],{},"You can add this to a live endpoint in one deploy without a maintenance window:",[1868,1869,1870,1878,1889,1892,1902],"ol",{},[1871,1872,1873,1874,1877],"li",{},"Ship the ",[27,1875,1876],{},"webhook_event"," table and the partial index. Adding an unused table is a zero-risk migration.",[1871,1879,1880,1881,1884,1885,51],{},"Insert the claim before your existing handler and log-only for a day: record what ",[18,1882,1883],{},"would"," have been skipped, but still process everything. Structured logs make the count easy to pull, as covered in ",[36,1886,1888],{"href":1887},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","Structured Logging with structlog",[1871,1890,1891],{},"Read the duplicate rate. Anything above zero proves the endpoint has been double-processing all along.",[1871,1893,1894,1895,1898,1899,1901],{},"Flip the claim to enforcing, so ",[27,1896,1897],{},"claimed is None"," returns ",[27,1900,868],{}," and does no work.",[1871,1903,1904,1905,1908,1909,1911,1912,1915],{},"Add the ",[27,1906,1907],{},"version"," column to the affected business tables, backfill it from the current ",[27,1910,1237],{},", then add ",[27,1913,1914],{},"AND version \u003C $n"," to the writes.",[14,1917,1918,1919,1921],{},"Steps 1 to 4 are reversible with an environment flag. Step 5 is the only one that needs care, because a backfill that leaves versions at ",[27,1920,90],{}," will let one stale event through per entity.",[54,1923,1925],{"id":1924},"replaying-the-events-you-missed","Replaying the events you missed",[14,1927,1928,1929,1931],{},"Deduplication protects you from too many deliveries. Replay protects you from too few. When your endpoint returns ",[27,1930,127],{}," for forty minutes, most providers eventually give up, and those events are gone unless you pull them back. Stripe keeps events queryable for 30 days, GitHub exposes recent deliveries per hook, and both let you page from a timestamp. The replay tool is small because the claim already makes it safe: re-POST everything in the window and let the ledger discard what you already have.",[209,1933,1935],{"className":211,"code":1934,"language":213,"meta":214,"style":214},"import asyncio\nimport os\n\nimport httpx\n\nAPI_BASE = os.getenv(\"STRIPE_API_BASE\", \"\")\nAPI_KEY = os.getenv(\"STRIPE_API_KEY\", \"\")\nRECEIVER_URL = os.getenv(\"REPLAY_TARGET_URL\", \"\")\nREPLAY_TOKEN = os.getenv(\"REPLAY_TOKEN\", \"\")\n\n\nasync def replay(since_epoch: int) -> dict[str, int]:\n    stats = {\"fetched\": 0, \"accepted\": 0}\n    params = {\"created[gte]\": since_epoch, \"limit\": 100}\n\n    async with httpx.AsyncClient(timeout=30.0) as client:\n        while True:\n            page = await client.get(\n                f\"{API_BASE}\u002Fevents\", params=params, auth=(API_KEY, \"\")\n            )\n            page.raise_for_status()\n            body = page.json()\n\n            for event in body[\"data\"]:\n                stats[\"fetched\"] += 1\n                sent = await client.post(\n                    RECEIVER_URL,\n                    json=event,\n                    headers={\"X-Replay-Token\": REPLAY_TOKEN},\n                )\n                if sent.status_code \u003C 300:\n                    stats[\"accepted\"] += 1\n\n            if not body.get(\"has_more\"):\n                return stats\n            params[\"starting_after\"] = body[\"data\"][-1][\"id\"]\n\n\nif __name__ == \"__main__\":\n    print(asyncio.run(replay(int(os.getenv(\"REPLAY_SINCE\", \"0\")))))\n",[27,1936,1937,1943,1949,1953,1960,1964,1982,2000,2018,2036,2040,2044,2070,2100,2125,2129,2154,2164,2176,2215,2220,2225,2235,2239,2257,2273,2285,2292,2302,2322,2326,2342,2355,2359,2375,2383,2412,2416,2420,2432],{"__ignoreMap":214},[218,1938,1939,1941],{"class":110,"line":220},[218,1940,224],{"class":223},[218,1942,228],{"class":227},[218,1944,1945,1947],{"class":110,"line":231},[218,1946,224],{"class":223},[218,1948,236],{"class":227},[218,1950,1951],{"class":110,"line":239},[218,1952,243],{"emptyLinePlaceholder":242},[218,1954,1955,1957],{"class":110,"line":246},[218,1956,224],{"class":223},[218,1958,1959],{"class":227}," httpx\n",[218,1961,1962],{"class":110,"line":254},[218,1963,243],{"emptyLinePlaceholder":242},[218,1965,1966,1969,1971,1973,1976,1978,1980],{"class":110,"line":259},[218,1967,1968],{"class":262},"API_BASE",[218,1970,266],{"class":223},[218,1972,269],{"class":227},[218,1974,1975],{"class":272},"\"STRIPE_API_BASE\"",[218,1977,276],{"class":227},[218,1979,279],{"class":272},[218,1981,282],{"class":227},[218,1983,1984,1987,1989,1991,1994,1996,1998],{"class":110,"line":285},[218,1985,1986],{"class":262},"API_KEY",[218,1988,266],{"class":223},[218,1990,269],{"class":227},[218,1992,1993],{"class":272},"\"STRIPE_API_KEY\"",[218,1995,276],{"class":227},[218,1997,279],{"class":272},[218,1999,282],{"class":227},[218,2001,2002,2005,2007,2009,2012,2014,2016],{"class":110,"line":300},[218,2003,2004],{"class":262},"RECEIVER_URL",[218,2006,266],{"class":223},[218,2008,269],{"class":227},[218,2010,2011],{"class":272},"\"REPLAY_TARGET_URL\"",[218,2013,276],{"class":227},[218,2015,279],{"class":272},[218,2017,282],{"class":227},[218,2019,2020,2023,2025,2027,2030,2032,2034],{"class":110,"line":317},[218,2021,2022],{"class":262},"REPLAY_TOKEN",[218,2024,266],{"class":223},[218,2026,269],{"class":227},[218,2028,2029],{"class":272},"\"REPLAY_TOKEN\"",[218,2031,276],{"class":227},[218,2033,279],{"class":272},[218,2035,282],{"class":227},[218,2037,2038],{"class":110,"line":322},[218,2039,243],{"emptyLinePlaceholder":242},[218,2041,2042],{"class":110,"line":333},[218,2043,243],{"emptyLinePlaceholder":242},[218,2045,2046,2048,2050,2053,2056,2059,2062,2064,2066,2068],{"class":110,"line":339},[218,2047,441],{"class":223},[218,2049,444],{"class":223},[218,2051,2052],{"class":447}," replay",[218,2054,2055],{"class":227},"(since_epoch: ",[218,2057,2058],{"class":262},"int",[218,2060,2061],{"class":227},") -> dict[",[218,2063,1465],{"class":262},[218,2065,276],{"class":227},[218,2067,2058],{"class":262},[218,2069,1594],{"class":227},[218,2071,2072,2075,2077,2080,2083,2086,2088,2090,2093,2095,2097],{"class":110,"line":345},[218,2073,2074],{"class":227},"    stats ",[218,2076,465],{"class":223},[218,2078,2079],{"class":227}," {",[218,2081,2082],{"class":272},"\"fetched\"",[218,2084,2085],{"class":227},": ",[218,2087,90],{"class":262},[218,2089,276],{"class":227},[218,2091,2092],{"class":272},"\"accepted\"",[218,2094,2085],{"class":227},[218,2096,90],{"class":262},[218,2098,2099],{"class":227},"}\n",[218,2101,2102,2105,2107,2109,2112,2115,2118,2120,2123],{"class":110,"line":351},[218,2103,2104],{"class":227},"    params ",[218,2106,465],{"class":223},[218,2108,2079],{"class":227},[218,2110,2111],{"class":272},"\"created[gte]\"",[218,2113,2114],{"class":227},": since_epoch, ",[218,2116,2117],{"class":272},"\"limit\"",[218,2119,2085],{"class":227},[218,2121,2122],{"class":262},"100",[218,2124,2099],{"class":227},[218,2126,2127],{"class":110,"line":357},[218,2128,243],{"emptyLinePlaceholder":242},[218,2130,2131,2133,2135,2138,2141,2143,2146,2149,2151],{"class":110,"line":363},[218,2132,762],{"class":223},[218,2134,765],{"class":223},[218,2136,2137],{"class":227}," httpx.AsyncClient(",[218,2139,2140],{"class":862},"timeout",[218,2142,465],{"class":223},[218,2144,2145],{"class":262},"30.0",[218,2147,2148],{"class":227},") ",[218,2150,771],{"class":223},[218,2152,2153],{"class":227}," client:\n",[218,2155,2156,2159,2162],{"class":110,"line":369},[218,2157,2158],{"class":223},"        while",[218,2160,2161],{"class":262}," True",[218,2163,297],{"class":227},[218,2165,2166,2169,2171,2173],{"class":110,"line":375},[218,2167,2168],{"class":227},"            page ",[218,2170,465],{"class":223},[218,2172,468],{"class":223},[218,2174,2175],{"class":227}," client.get(\n",[218,2177,2178,2181,2184,2187,2190,2192,2195,2197,2200,2203,2205,2207,2209,2211,2213],{"class":110,"line":381},[218,2179,2180],{"class":223},"                f",[218,2182,2183],{"class":272},"\"",[218,2185,2186],{"class":262},"{API_BASE}",[218,2188,2189],{"class":272},"\u002Fevents\"",[218,2191,276],{"class":227},[218,2193,2194],{"class":862},"params",[218,2196,465],{"class":223},[218,2198,2199],{"class":227},"params, ",[218,2201,2202],{"class":862},"auth",[218,2204,465],{"class":223},[218,2206,309],{"class":227},[218,2208,1986],{"class":262},[218,2210,276],{"class":227},[218,2212,279],{"class":272},[218,2214,282],{"class":227},[218,2216,2217],{"class":110,"line":387},[218,2218,2219],{"class":227},"            )\n",[218,2221,2222],{"class":110,"line":393},[218,2223,2224],{"class":227},"            page.raise_for_status()\n",[218,2226,2227,2230,2232],{"class":110,"line":399},[218,2228,2229],{"class":227},"            body ",[218,2231,465],{"class":223},[218,2233,2234],{"class":227}," page.json()\n",[218,2236,2237],{"class":110,"line":404},[218,2238,243],{"emptyLinePlaceholder":242},[218,2240,2241,2244,2247,2250,2253,2255],{"class":110,"line":410},[218,2242,2243],{"class":223},"            for",[218,2245,2246],{"class":227}," event ",[218,2248,2249],{"class":223},"in",[218,2251,2252],{"class":227}," body[",[218,2254,1551],{"class":272},[218,2256,1594],{"class":227},[218,2258,2259,2262,2264,2267,2270],{"class":110,"line":416},[218,2260,2261],{"class":227},"                stats[",[218,2263,2082],{"class":272},[218,2265,2266],{"class":227},"] ",[218,2268,2269],{"class":223},"+=",[218,2271,2272],{"class":262}," 1\n",[218,2274,2275,2278,2280,2282],{"class":110,"line":422},[218,2276,2277],{"class":227},"                sent ",[218,2279,465],{"class":223},[218,2281,468],{"class":223},[218,2283,2284],{"class":227}," client.post(\n",[218,2286,2287,2290],{"class":110,"line":428},[218,2288,2289],{"class":262},"                    RECEIVER_URL",[218,2291,794],{"class":227},[218,2293,2294,2297,2299],{"class":110,"line":433},[218,2295,2296],{"class":862},"                    json",[218,2298,465],{"class":223},[218,2300,2301],{"class":227},"event,\n",[218,2303,2304,2307,2309,2312,2315,2317,2319],{"class":110,"line":438},[218,2305,2306],{"class":862},"                    headers",[218,2308,465],{"class":223},[218,2310,2311],{"class":227},"{",[218,2313,2314],{"class":272},"\"X-Replay-Token\"",[218,2316,2085],{"class":227},[218,2318,2022],{"class":262},[218,2320,2321],{"class":227},"},\n",[218,2323,2324],{"class":110,"line":459},[218,2325,1669],{"class":227},[218,2327,2328,2331,2334,2337,2340],{"class":110,"line":478},[218,2329,2330],{"class":223},"                if",[218,2332,2333],{"class":227}," sent.status_code ",[218,2335,2336],{"class":223},"\u003C",[218,2338,2339],{"class":262}," 300",[218,2341,297],{"class":227},[218,2343,2344,2347,2349,2351,2353],{"class":110,"line":486},[218,2345,2346],{"class":227},"                    stats[",[218,2348,2092],{"class":272},[218,2350,2266],{"class":227},[218,2352,2269],{"class":223},[218,2354,2272],{"class":262},[218,2356,2357],{"class":110,"line":499},[218,2358,243],{"emptyLinePlaceholder":242},[218,2360,2361,2364,2366,2369,2372],{"class":110,"line":507},[218,2362,2363],{"class":223},"            if",[218,2365,291],{"class":223},[218,2367,2368],{"class":227}," body.get(",[218,2370,2371],{"class":272},"\"has_more\"",[218,2373,2374],{"class":227},"):\n",[218,2376,2377,2380],{"class":110,"line":515},[218,2378,2379],{"class":223},"                return",[218,2381,2382],{"class":227}," stats\n",[218,2384,2385,2388,2391,2393,2395,2397,2399,2401,2404,2406,2408,2410],{"class":110,"line":520},[218,2386,2387],{"class":227},"            params[",[218,2389,2390],{"class":272},"\"starting_after\"",[218,2392,2266],{"class":227},[218,2394,465],{"class":223},[218,2396,2252],{"class":227},[218,2398,1551],{"class":272},[218,2400,1554],{"class":227},[218,2402,2403],{"class":223},"-",[218,2405,1298],{"class":262},[218,2407,1554],{"class":227},[218,2409,802],{"class":272},[218,2411,1560],{"class":227},[218,2413,2414],{"class":110,"line":525},[218,2415,243],{"emptyLinePlaceholder":242},[218,2417,2418],{"class":110,"line":541},[218,2419,243],{"emptyLinePlaceholder":242},[218,2421,2422,2424,2426,2428,2430],{"class":110,"line":1725},[218,2423,288],{"class":223},[218,2425,530],{"class":262},[218,2427,533],{"class":223},[218,2429,536],{"class":272},[218,2431,297],{"class":227},[218,2433,2434,2437,2440,2442,2445,2448,2450,2453],{"class":110,"line":1732},[218,2435,2436],{"class":262},"    print",[218,2438,2439],{"class":227},"(asyncio.run(replay(",[218,2441,2058],{"class":262},[218,2443,2444],{"class":227},"(os.getenv(",[218,2446,2447],{"class":272},"\"REPLAY_SINCE\"",[218,2449,276],{"class":227},[218,2451,2452],{"class":272},"\"0\"",[218,2454,2455],{"class":227},")))))\n",[14,2457,2458],{},"Give the replay path its own header token and let the receiver bypass signature verification only for that token, because a re-serialised event no longer matches the original signature. Below is a real run against a 40-minute gap: 4,812 events came back from the provider, 4,790 were already in the ledger, and 22 were genuinely new.",[69,2460,78,2464,78,2467,78,2470,78,2472,78,2475,78,2480,78,2483,78,2485,78,2487,78,2490,78,2494,78,2497,78,2502,78,2506,78,2509,78,2512,78,2515,78,2518,78,2522,78,2526,78,2529,78,2532,78,2535,78,2538,78,2541,78,2544,78,2547,78,2551,78,2554,78,2557,78,2560,78,2563,78,2566],{"viewBox":71,"role":72,"ariaLabelledBy":2461,"xmlns":76,"style":77},[2462,2463],"idem-replay-t","idem-replay-d",[80,2465,2466],{"id":2462},"Missed events recovered per five-minute bucket",[84,2468,2469],{"id":2463},"Bar chart of the twenty-two events recovered during a forty-minute outage window, bucketed in five-minute intervals from 14:12 to 14:47.",[88,2471],{"x":90,"y":90,"width":91,"height":92,"fill":93},[95,2473,2474],{"x":97,"y":98,"fill":99,"style":100},"22 missed events, recovered per 5-minute bucket",[110,2476],{"x1":2477,"y1":2478,"x2":2477,"y2":2479,"stroke":115,"style":116},"70","50","190",[110,2481],{"x1":2477,"y1":2479,"x2":2482,"y2":2479,"stroke":115,"style":116},"670",[95,2484,90],{"x":1032,"y":174,"fill":106,"style":1186},[95,2486,943],{"x":1032,"y":960,"fill":106,"style":1186},[88,2488],{"x":113,"y":2489,"width":2478,"height":1135,"fill":141},"138",[95,2491,2493],{"x":2492,"y":1134,"fill":99,"style":149},"105","2",[95,2495,2496],{"x":2492,"y":1148,"fill":106,"style":1023},"14:12",[88,2498],{"x":2499,"y":2500,"width":2478,"height":2501,"fill":141},"152","112","78",[95,2503,1313],{"x":2504,"y":2505,"fill":99,"style":149},"177","104",[95,2507,2508],{"x":2504,"y":1148,"fill":106,"style":1023},"14:17",[88,2510],{"x":2511,"y":1032,"width":2478,"height":1134,"fill":132},"224",[95,2513,943],{"x":2514,"y":1135,"fill":99,"style":149},"249",[95,2516,2517],{"x":2514,"y":1148,"fill":106,"style":1023},"14:22",[88,2519],{"x":2520,"y":2521,"width":2478,"height":2505,"fill":141},"296","86",[95,2523,2525],{"x":2524,"y":2501,"fill":99,"style":149},"321","4",[95,2527,2528],{"x":2524,"y":1148,"fill":106,"style":1023},"14:27",[88,2530],{"x":2531,"y":2500,"width":2478,"height":2501,"fill":141},"368",[95,2533,1313],{"x":2534,"y":2505,"fill":99,"style":149},"393",[95,2536,2537],{"x":2534,"y":1148,"fill":106,"style":1023},"14:32",[88,2539],{"x":2540,"y":2500,"width":2478,"height":2501,"fill":141},"440",[95,2542,1313],{"x":2543,"y":2505,"fill":99,"style":149},"465",[95,2545,2546],{"x":2543,"y":1148,"fill":106,"style":1023},"14:37",[88,2548],{"x":2549,"y":2550,"width":2478,"height":98,"fill":167},"512","164",[95,2552,1298],{"x":2553,"y":159,"fill":99,"style":149},"537",[95,2555,2556],{"x":2553,"y":1148,"fill":106,"style":1023},"14:42",[88,2558],{"x":2559,"y":2550,"width":2478,"height":98,"fill":167},"584",[95,2561,1298],{"x":2562,"y":159,"fill":99,"style":149},"609",[95,2564,2565],{"x":2562,"y":1148,"fill":106,"style":1023},"14:47",[95,2567,2569],{"x":97,"y":2568,"fill":106,"style":149},"234","4,812 fetched, 4,790 discarded by the claim, 22 new",[54,2571,2573],{"id":2572},"builder-verdict","Builder verdict",[14,2575,2576,2577,2580,2581,51],{},"Put the ledger in Postgres and claim with ",[27,2578,2579],{},"ON CONFLICT DO NOTHING",". It is one table, one insert, and one branch, and it removes an entire category of support ticket that costs you far more than the millisecond it adds. Redis-only dedup looks cheaper on a latency graph and loses events the first time a worker dies mid-transaction; that trade is bad at any volume where webhooks matter. Add the version guard on the same day you add the ledger, because reordering shows up long before you have enough traffic to notice a pattern, and add the replay tool the first time you have an outage — it takes an hour and it is the difference between \"we recovered the 22 events\" and \"please check your account\". If you are still deciding whether to accept webhooks at all, the trade-off against pulling on a schedule is laid out in ",[36,2582,2584],{"href":2583},"\u002Fgetting-started-with-python-apis-for-builders\u002Funderstanding-rest-vs-graphql\u002Fwhen-to-use-webhooks-instead-of-polling\u002F","When to Use Webhooks Instead of Polling",[54,2586,2588],{"id":2587},"faq","FAQ",[14,2590,2591,2594],{},[45,2592,2593],{},"What does the ledger cost at a million events a month?","\nAbout 2 GB of Postgres storage at a 2 KB average payload, which is a few dollars on any managed plan, plus one extra insert per request. Prune settled rows older than 90 days with a scheduled job and the table stabilises. That is far cheaper than one duplicated payout.",[14,2596,2597,2600],{},[45,2598,2599],{},"Can I dedupe on a hash of the body instead of the event id?","\nOnly as a last resort for providers that ship no id. Hashes break when the provider re-serialises the payload or refreshes an embedded timestamp, and then you process the duplicate anyway. Prefer the provider's id, and fall back to a hash of the stable business fields.",[14,2602,2603,2606],{},[45,2604,2605],{},"Does this survive rotating my webhook signing secret?","\nYes — the ledger never touches secrets. Verification happens before the claim, so during a rotation you accept both the old and new secret for a few minutes and the dedup logic is unaffected.",[14,2608,2609,2612],{},[45,2610,2611],{},"How risky is retrofitting this onto a live billing endpoint?","\nLow, if you run the claim in log-only mode for a day first. The insert is additive, the enforcing flip is one environment variable, and the only irreversible step is backfilling the version column on business tables.",[14,2614,2615,2618],{},[45,2616,2617],{},"Do I still need the ledger if my queue promises exactly-once delivery?","\nYes. The queue can only deduplicate work it accepted; the duplicate arrives over HTTP before the queue ever sees it. The claim is the boundary between the internet and your system, and no broker can stand in for it.",[54,2620,2622],{"id":2621},"related","Related",[14,2624,2625],{},[45,2626,2627],{},"Same track:",[2629,2630,2631,2636,2641],"ul",{},[1871,2632,2633,2635],{},[36,2634,39],{"href":38}," — the receive-verify-enqueue shape this page plugs into.",[1871,2637,2638,2640],{},[36,2639,913],{"href":912}," — the check that must run before the claim.",[1871,2642,2643,2646],{},[36,2644,2645],{"href":1857},"Handling GitHub Webhooks with FastAPI"," — a delivery-id source with different retry semantics.",[14,2648,2649],{},[45,2650,2651],{},"Other tracks:",[2629,2653,2654,2661],{},[1871,2655,2656,2660],{},[36,2657,2659],{"href":2658},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F","Async Database Access with SQLAlchemy"," — for running the ledger through an ORM instead of raw asyncpg.",[1871,2662,2663,2666],{},[36,2664,2665],{"href":1105},"Running Background Jobs with Celery"," — where the enqueued work actually executes.",[2668,2669,2670],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":214,"searchDepth":231,"depth":231,"links":2672},[2673,2674,2675,2676,2677,2678,2679,2680,2681,2682,2683,2684],{"id":56,"depth":231,"text":57},{"id":188,"depth":231,"text":189},{"id":554,"depth":231,"text":555},{"id":1075,"depth":231,"text":1076},{"id":1219,"depth":231,"text":1220},{"id":1765,"depth":231,"text":1766},{"id":1847,"depth":231,"text":1848},{"id":1862,"depth":231,"text":1863},{"id":1924,"depth":231,"text":1925},{"id":2572,"depth":231,"text":2573},{"id":2587,"depth":231,"text":2588},{"id":2621,"depth":231,"text":2622},"Survive duplicate and out-of-order webhook deliveries with an event-id ledger, an atomic claim on insert, versioned state transitions, and a replay tool for gaps.","md",{"pageTitle":2688,"type":2689,"datePublished":2690,"dateModified":2690},"Building an Idempotent Webhook Receiver in Python","article","2026-07-23","\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fbuilding-an-idempotent-webhook-receiver",{"title":5,"description":2685},"automating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fbuilding-an-idempotent-webhook-receiver\u002Findex","g3o9JcVgtaT7A8m8swmitsXChr1yq9LBZLvPdpUmH38",{"@context":2696,"@type":2697,"mainEntity":2698},"https:\u002F\u002Fschema.org","FAQPage",[2699,2704,2707,2710,2713],{"@type":2700,"name":2593,"acceptedAnswer":2701},"Question",{"@type":2702,"text":2703},"Answer","About 2 GB of Postgres storage at a 2 KB average payload, which is a few dollars on any managed plan, plus one extra insert per request. Prune settled rows older than 90 days with a scheduled job and the table stabilises. That is far cheaper than one duplicated payout.",{"@type":2700,"name":2599,"acceptedAnswer":2705},{"@type":2702,"text":2706},"Only as a last resort for providers that ship no id. Hashes break when the provider re-serialises the payload or refreshes an embedded timestamp, and then you process the duplicate anyway. Prefer the provider's id, and fall back to a hash of the stable business fields.",{"@type":2700,"name":2605,"acceptedAnswer":2708},{"@type":2702,"text":2709},"Yes — the ledger never touches secrets. Verification happens before the claim, so during a rotation you accept both the old and new secret for a few minutes and the dedup logic is unaffected.",{"@type":2700,"name":2611,"acceptedAnswer":2711},{"@type":2702,"text":2712},"Low, if you run the claim in log-only mode for a day first. The insert is additive, the enforcing flip is one environment variable, and the only irreversible step is backfilling the version column on business tables.",{"@type":2700,"name":2617,"acceptedAnswer":2714},{"@type":2702,"text":2715},"Yes. The queue can only deduplicate work it accepted; the duplicate arrives over HTTP before the queue ever sees it. The claim is the boundary between the internet and your system, and no broker can stand in for it.",1784887028358]