[{"data":1,"prerenderedAt":1721},["ShallowReactive",2],{"page-\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures\u002F":3,"faq-schema-\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures\u002F":1700},{"id":4,"title":5,"body":6,"description":1690,"extension":1691,"meta":1692,"navigation":268,"path":1696,"seo":1697,"stem":1698,"__hash__":1699},"content\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures\u002Findex.md","Verifying Stripe Webhook Signatures in Python",{"type":7,"value":8,"toc":1679},"minimark",[9,13,42,54,59,69,76,203,207,220,602,623,627,654,753,1137,1162,1166,1249,1263,1267,1270,1381,1435,1439,1442,1516,1539,1543,1559,1563,1577,1591,1611,1626,1632,1636,1641,1658,1663,1675],[10,11,5],"h1",{"id":12},"verifying-stripe-webhook-signatures-in-python",[14,15,16,17,21,22,25,26,30,31,36,37,41],"p",{},"Stripe signs every webhook with an HMAC over the raw payload and a timestamp, delivered in the ",[18,19,20],"code",{},"Stripe-Signature"," header. If you don't verify it, anyone who learns your endpoint URL can POST a fake ",[18,23,24],{},"payment_intent.succeeded"," and trick your app into shipping product, unlocking a paid tier, or crediting an account for money that never moved. That is not theoretical: your webhook URL leaks into browser network tabs, proxy logs, and error trackers, and an unauthenticated write endpoint that mutates billing state is the most valuable thing an attacker can find on a side-hustle SaaS. This page resolves one narrow decision — ",[27,28,29],"em",{},"how"," you prove a request really came from Stripe — and shows both ways to do it, plus when each is right. It's part of ",[32,33,35],"a",{"href":34},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002F","Processing Webhooks with Python",", which covers the receive-verify-enqueue pattern you'll wrap this verification in, and it pairs with the reasons in ",[32,38,40],{"href":39},"\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",".",[14,43,44,45,53],{},"The decision is short: ",[46,47,48,49,52],"strong",{},"use ",[18,50,51],{},"stripe.Webhook.construct_event"," in production, and understand the manual version so you know exactly what it's checking."," Both verify the same claim — that the bytes you received were signed by your endpoint secret within a recent time window — but the SDK tracks scheme changes and rotation edge cases you shouldn't own.",[55,56,58],"h2",{"id":57},"how-stripe-signs-a-webhook","How Stripe signs a webhook",[14,60,61,62,65,66,68],{},"Before you pick an implementation, get the mechanism straight, because every bug in this area comes from misunderstanding one of its three parts. When Stripe sends an event, it takes the exact raw bytes of the JSON body, prepends the current Unix timestamp and a literal dot, and computes ",[18,63,64],{},"HMAC-SHA256(secret, f\"{t}.{body}\")",". The result and the timestamp travel together in the ",[18,67,20],{}," header. Your job is to recompute that same HMAC with the same endpoint secret and confirm it matches, in constant time, and that the timestamp is recent.",[14,70,71,72,75],{},"Three properties fall out of that design. The timestamp is ",[27,73,74],{},"inside"," the signed message, so an attacker can't rewind it without breaking the signature. Verification needs the byte-for-byte body — reserialize the JSON and you change whitespace and key order, and the HMAC no longer matches. And because the secret never leaves your server or Stripe's, a valid signature is proof of origin, not just integrity.",[77,78,86,87,86,91,86,95,86,102,86,121,86,130,86,137,86,142,86,148,86,151,86,155,86,159,86,164,86,167,86,171,86,174,86,178,86,182,86,186,86,189,86,193,86,197],"svg",{"viewBox":79,"role":80,"ariaLabelledBy":81,"xmlns":84,"style":85},"0 0 720 250","img",[82,83],"stripe-sig-t","stripe-sig-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[88,89,90],"title",{"id":82},"Stripe-Signature verification sequence",[92,93,94],"desc",{"id":83},"Split the Stripe-Signature header into a timestamp and v1 signatures, build the signed payload as timestamp dot raw body, HMAC it with the endpoint secret, compare in constant time, and check the timestamp tolerance.",[96,97],"rect",{"x":98,"y":98,"width":99,"height":100,"fill":101},"0","720","250","var(--c-surface)",[103,104,105,106,86],"defs",{},"\n    ",[107,108,115,116,105],"marker",{"id":109,"viewBox":110,"refX":111,"refY":112,"markerWidth":113,"markerHeight":113,"orient":114},"stripe-sig-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[117,118],"path",{"d":119,"fill":120},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[96,122],{"x":123,"y":124,"width":125,"height":126,"rx":127,"fill":101,"stroke":128,"style":129},"14","20","200","56","10","var(--c-blue)","stroke-width:2;",[131,132,20],"text",{"x":133,"y":134,"fill":135,"style":136},"114","44","var(--c-text)","text-anchor:middle;font-size:13;font-family:var(--font-sans);",[131,138,141],{"x":133,"y":139,"fill":120,"style":140},"62","text-anchor:middle;font-size:11;font-family:var(--font-sans);","t=…, v1=…",[143,144],"line",{"x1":133,"y1":145,"x2":133,"y2":146,"stroke":120,"style":147},"76","100","stroke-width:2;marker-end:url(#stripe-sig-arrow);",[96,149],{"x":123,"y":146,"width":125,"height":126,"rx":127,"fill":101,"stroke":150,"style":129},"var(--c-teal)",[131,152,154],{"x":133,"y":153,"fill":135,"style":136},"124","signed_payload",[131,156,158],{"x":133,"y":157,"fill":120,"style":140},"142","t + \".\" + raw body",[143,160],{"x1":161,"y1":162,"x2":163,"y2":162,"stroke":120,"style":147},"214","128","266",[96,165],{"x":163,"y":146,"width":125,"height":126,"rx":127,"fill":101,"stroke":166,"style":129},"var(--c-yellow)",[131,168,170],{"x":169,"y":153,"fill":135,"style":136},"366","HMAC-SHA256",[131,172,173],{"x":169,"y":157,"fill":120,"style":140},"key = endpoint secret",[143,175],{"x1":176,"y1":162,"x2":177,"y2":162,"stroke":120,"style":147},"466","518",[96,179],{"x":177,"y":146,"width":180,"height":126,"rx":127,"fill":101,"stroke":181,"style":129},"188","var(--c-coral)",[131,183,185],{"x":184,"y":153,"fill":135,"style":136},"612","compare_digest",[131,187,188],{"x":184,"y":157,"fill":120,"style":140},"vs each v1",[143,190],{"x1":184,"y1":191,"x2":184,"y2":192,"stroke":120,"style":147},"156","180",[96,194],{"x":176,"y":192,"width":195,"height":196,"rx":127,"fill":101,"stroke":120,"style":129},"240","50",[131,198,202],{"x":199,"y":200,"fill":120,"style":201},"586","210","text-anchor:middle;font-size:12;font-family:var(--font-sans);","check |now − t| ≤ tolerance",[55,204,206],{"id":205},"option-a-the-stripe-sdk-helper","Option A — the Stripe SDK helper",[14,208,209,211,212,215,216,219],{},[18,210,51],{}," does the whole dance: it parses the header, recomputes the HMAC over the v1 scheme, runs a constant-time compare, enforces the timestamp tolerance, and hands back a typed ",[18,213,214],{},"Event",". This is the right default for anyone actually integrating Stripe. Install with ",[18,217,218],{},"pip install stripe",", load the secret from the environment, and read the raw request body — never a parsed model.",[221,222,227],"pre",{"className":223,"code":224,"language":225,"meta":226,"style":226},"language-python shiki shiki-themes github-light github-dark","import os\nimport stripe\nfrom fastapi import FastAPI, Request, HTTPException\n\napp = FastAPI()\nENDPOINT_SECRET = os.getenv(\"STRIPE_WEBHOOK_SECRET\", \"\")\n\n\n@app.post(\"\u002Fwebhooks\u002Fstripe\")\nasync def stripe_webhook(request: Request):\n    raw = await request.body()\n    sig_header = request.headers.get(\"Stripe-Signature\", \"\")\n\n    try:\n        event = stripe.Webhook.construct_event(\n            payload=raw,\n            sig_header=sig_header,\n            secret=ENDPOINT_SECRET,\n        )\n    except ValueError:\n        raise HTTPException(status_code=400, detail=\"invalid payload\")\n    except stripe.SignatureVerificationError:\n        raise HTTPException(status_code=401, detail=\"invalid signature\")\n\n    # Verified. Enqueue, don't process inline — see the webhooks guide.\n    match event[\"type\"]:\n        case \"payment_intent.succeeded\":\n            ...\n        case \"customer.subscription.deleted\":\n            ...\n    return {\"received\": True}\n","python","",[18,228,229,241,249,263,270,282,308,313,318,333,348,362,382,387,396,407,419,430,443,449,460,489,497,522,527,534,549,560,566,576,581],{"__ignoreMap":226},[230,231,233,237],"span",{"class":143,"line":232},1,[230,234,236],{"class":235},"szBVR","import",[230,238,240],{"class":239},"sVt8B"," os\n",[230,242,244,246],{"class":143,"line":243},2,[230,245,236],{"class":235},[230,247,248],{"class":239}," stripe\n",[230,250,252,255,258,260],{"class":143,"line":251},3,[230,253,254],{"class":235},"from",[230,256,257],{"class":239}," fastapi ",[230,259,236],{"class":235},[230,261,262],{"class":239}," FastAPI, Request, HTTPException\n",[230,264,266],{"class":143,"line":265},4,[230,267,269],{"emptyLinePlaceholder":268},true,"\n",[230,271,273,276,279],{"class":143,"line":272},5,[230,274,275],{"class":239},"app ",[230,277,278],{"class":235},"=",[230,280,281],{"class":239}," FastAPI()\n",[230,283,285,289,292,295,299,302,305],{"class":143,"line":284},6,[230,286,288],{"class":287},"sj4cs","ENDPOINT_SECRET",[230,290,291],{"class":235}," =",[230,293,294],{"class":239}," os.getenv(",[230,296,298],{"class":297},"sZZnC","\"STRIPE_WEBHOOK_SECRET\"",[230,300,301],{"class":239},", ",[230,303,304],{"class":297},"\"\"",[230,306,307],{"class":239},")\n",[230,309,311],{"class":143,"line":310},7,[230,312,269],{"emptyLinePlaceholder":268},[230,314,316],{"class":143,"line":315},8,[230,317,269],{"emptyLinePlaceholder":268},[230,319,321,325,328,331],{"class":143,"line":320},9,[230,322,324],{"class":323},"sScJk","@app.post",[230,326,327],{"class":239},"(",[230,329,330],{"class":297},"\"\u002Fwebhooks\u002Fstripe\"",[230,332,307],{"class":239},[230,334,336,339,342,345],{"class":143,"line":335},10,[230,337,338],{"class":235},"async",[230,340,341],{"class":235}," def",[230,343,344],{"class":323}," stripe_webhook",[230,346,347],{"class":239},"(request: Request):\n",[230,349,351,354,356,359],{"class":143,"line":350},11,[230,352,353],{"class":239},"    raw ",[230,355,278],{"class":235},[230,357,358],{"class":235}," await",[230,360,361],{"class":239}," request.body()\n",[230,363,365,368,370,373,376,378,380],{"class":143,"line":364},12,[230,366,367],{"class":239},"    sig_header ",[230,369,278],{"class":235},[230,371,372],{"class":239}," request.headers.get(",[230,374,375],{"class":297},"\"Stripe-Signature\"",[230,377,301],{"class":239},[230,379,304],{"class":297},[230,381,307],{"class":239},[230,383,385],{"class":143,"line":384},13,[230,386,269],{"emptyLinePlaceholder":268},[230,388,390,393],{"class":143,"line":389},14,[230,391,392],{"class":235},"    try",[230,394,395],{"class":239},":\n",[230,397,399,402,404],{"class":143,"line":398},15,[230,400,401],{"class":239},"        event ",[230,403,278],{"class":235},[230,405,406],{"class":239}," stripe.Webhook.construct_event(\n",[230,408,410,414,416],{"class":143,"line":409},16,[230,411,413],{"class":412},"s4XuR","            payload",[230,415,278],{"class":235},[230,417,418],{"class":239},"raw,\n",[230,420,422,425,427],{"class":143,"line":421},17,[230,423,424],{"class":412},"            sig_header",[230,426,278],{"class":235},[230,428,429],{"class":239},"sig_header,\n",[230,431,433,436,438,440],{"class":143,"line":432},18,[230,434,435],{"class":412},"            secret",[230,437,278],{"class":235},[230,439,288],{"class":287},[230,441,442],{"class":239},",\n",[230,444,446],{"class":143,"line":445},19,[230,447,448],{"class":239},"        )\n",[230,450,452,455,458],{"class":143,"line":451},20,[230,453,454],{"class":235},"    except",[230,456,457],{"class":287}," ValueError",[230,459,395],{"class":239},[230,461,463,466,469,472,474,477,479,482,484,487],{"class":143,"line":462},21,[230,464,465],{"class":235},"        raise",[230,467,468],{"class":239}," HTTPException(",[230,470,471],{"class":412},"status_code",[230,473,278],{"class":235},[230,475,476],{"class":287},"400",[230,478,301],{"class":239},[230,480,481],{"class":412},"detail",[230,483,278],{"class":235},[230,485,486],{"class":297},"\"invalid payload\"",[230,488,307],{"class":239},[230,490,492,494],{"class":143,"line":491},22,[230,493,454],{"class":235},[230,495,496],{"class":239}," stripe.SignatureVerificationError:\n",[230,498,500,502,504,506,508,511,513,515,517,520],{"class":143,"line":499},23,[230,501,465],{"class":235},[230,503,468],{"class":239},[230,505,471],{"class":412},[230,507,278],{"class":235},[230,509,510],{"class":287},"401",[230,512,301],{"class":239},[230,514,481],{"class":412},[230,516,278],{"class":235},[230,518,519],{"class":297},"\"invalid signature\"",[230,521,307],{"class":239},[230,523,525],{"class":143,"line":524},24,[230,526,269],{"emptyLinePlaceholder":268},[230,528,530],{"class":143,"line":529},25,[230,531,533],{"class":532},"sJ8bj","    # Verified. Enqueue, don't process inline — see the webhooks guide.\n",[230,535,537,540,543,546],{"class":143,"line":536},26,[230,538,539],{"class":235},"    match",[230,541,542],{"class":239}," event[",[230,544,545],{"class":297},"\"type\"",[230,547,548],{"class":239},"]:\n",[230,550,552,555,558],{"class":143,"line":551},27,[230,553,554],{"class":235},"        case",[230,556,557],{"class":297}," \"payment_intent.succeeded\"",[230,559,395],{"class":239},[230,561,563],{"class":143,"line":562},28,[230,564,565],{"class":287},"            ...\n",[230,567,569,571,574],{"class":143,"line":568},29,[230,570,554],{"class":235},[230,572,573],{"class":297}," \"customer.subscription.deleted\"",[230,575,395],{"class":239},[230,577,579],{"class":143,"line":578},30,[230,580,565],{"class":287},[230,582,584,587,590,593,596,599],{"class":143,"line":583},31,[230,585,586],{"class":235},"    return",[230,588,589],{"class":239}," {",[230,591,592],{"class":297},"\"received\"",[230,594,595],{"class":239},": ",[230,597,598],{"class":287},"True",[230,600,601],{"class":239},"}\n",[14,603,604,605,608,609,611,612,615,616,618,619,622],{},"Notice the two exceptions doing two distinct jobs. A ",[18,606,607],{},"ValueError"," means the body wasn't parseable as an event Stripe could have sent — that's a ",[18,610,476],{},". A ",[18,613,614],{},"SignatureVerificationError"," means the payload looks like an event but the HMAC or timestamp failed — that's a ",[18,617,510],{},". Collapsing both into one status code throws away the signal you'll want when a spike of rejected deliveries shows up in the dashboard. The SDK defaults its tolerance to 300 seconds; pass ",[18,620,621],{},"tolerance="," only if you have a documented reason to.",[55,624,626],{"id":625},"option-b-manual-hmac-verification","Option B — manual HMAC verification",[14,628,629,630,633,634,637,638,641,642,645,646,649,650,653],{},"Here's the identical check with no SDK, so the v1 scheme is fully in view. The header is a comma-separated list of ",[18,631,632],{},"key=value"," pairs: a single ",[18,635,636],{},"t="," timestamp, one or more ",[18,639,640],{},"v1="," signatures, and a legacy ",[18,643,644],{},"v0="," you ignore. You split it, rebuild ",[18,647,648],{},"signed_payload = f\"{t}.\".encode() + raw_body",", HMAC that with your endpoint secret, and compare the hex digest against every ",[18,651,652],{},"v1"," value in constant time.",[77,655,86,660,86,663,86,666,86,669,86,675,86,682,86,687,86,690,86,694,86,697,86,701,86,706,86,710,86,714,86,718,86,722,86,726,86,729,86,733,86,736,86,739,86,742,86,746,86,749],{"viewBox":656,"role":80,"ariaLabelledBy":657,"xmlns":84,"style":85},"0 0 720 280",[658,659],"stripe-hdr-t","stripe-hdr-d",[88,661,662],{"id":658},"Anatomy of the Stripe-Signature header",[92,664,665],{"id":659},"The header value splits into a timestamp field, one or more v1 HMAC signatures, and a legacy v0 field that is ignored, each part annotated with its role in verification.",[96,667],{"x":98,"y":98,"width":99,"height":668,"fill":101},"280",[131,670,674],{"x":671,"y":672,"fill":135,"style":673},"360","30","text-anchor:middle;font-size:14;font-family:var(--font-sans);","Stripe-Signature: one line, three kinds of field",[96,676],{"x":677,"y":678,"width":679,"height":680,"rx":681,"fill":101,"stroke":128,"style":129},"40","52","150","46","8",[131,683,686],{"x":684,"y":685,"fill":135,"style":136},"115","80","t=1750000000",[96,688],{"x":689,"y":678,"width":125,"height":680,"rx":681,"fill":101,"stroke":150,"style":129},"206",[131,691,693],{"x":692,"y":685,"fill":135,"style":136},"306","v1=5257a8…c1f9",[96,695],{"x":696,"y":678,"width":125,"height":680,"rx":681,"fill":101,"stroke":150,"style":129},"422",[131,698,700],{"x":699,"y":685,"fill":135,"style":136},"522","v1=9b40e…7a02",[96,702],{"x":703,"y":678,"width":704,"height":680,"rx":681,"fill":101,"stroke":120,"style":705},"638","66","stroke-width:2;stroke-dasharray:4 3;",[131,707,709],{"x":708,"y":685,"fill":120,"style":201},"671","v0=…",[143,711],{"x1":684,"y1":712,"x2":684,"y2":679,"stroke":128,"style":713},"98","stroke-width:1.5;",[96,715],{"x":677,"y":679,"width":679,"height":716,"rx":681,"fill":101,"stroke":717,"style":713},"88","var(--c-border)",[131,719,721],{"x":684,"y":720,"fill":135,"style":201},"174","Timestamp",[131,723,725],{"x":684,"y":724,"fill":120,"style":140},"194","signed with the",[131,727,728],{"x":684,"y":200,"fill":120,"style":140},"body; drives the",[131,730,732],{"x":684,"y":731,"fill":120,"style":140},"226","tolerance check",[143,734],{"x1":735,"y1":712,"x2":735,"y2":679,"stroke":150,"style":713},"414",[96,737],{"x":689,"y":679,"width":738,"height":716,"rx":681,"fill":101,"stroke":717,"style":713},"416",[131,740,741],{"x":735,"y":720,"fill":135,"style":201},"One or more v1 HMAC-SHA256 digests",[131,743,745],{"x":735,"y":744,"fill":120,"style":140},"196","Multiple appear during secret rotation — match against ANY.",[131,747,748],{"x":735,"y":161,"fill":120,"style":140},"A plain dict keeps only the last key, so scan every pair.",[131,750,752],{"x":735,"y":751,"fill":120,"style":140},"230","Compare with hmac.compare_digest, never ==.",[221,754,756],{"className":223,"code":755,"language":225,"meta":226,"style":226},"import os\nimport hmac\nimport hashlib\nimport time\n\nENDPOINT_SECRET = os.getenv(\"STRIPE_WEBHOOK_SECRET\", \"\")\nTOLERANCE = int(os.getenv(\"STRIPE_WEBHOOK_TOLERANCE\", \"300\"))\n\n\ndef verify_stripe(raw_body: bytes, sig_header: str) -> bool:\n    parts = [item.split(\"=\", 1) for item in sig_header.split(\",\") if \"=\" in item]\n    timestamp = next((v for k, v in parts if k == \"t\"), None)\n    if not timestamp:\n        return False\n\n    # Reject events outside the tolerance window (replay protection).\n    if abs(time.time() - int(timestamp)) > TOLERANCE:\n        return False\n\n    signed_payload = f\"{timestamp}.\".encode() + raw_body\n    expected = hmac.new(\n        ENDPOINT_SECRET.encode(), signed_payload, hashlib.sha256\n    ).hexdigest()\n\n    # Stripe can include several v1 signatures during secret rotation.\n    provided = [v for k, v in parts if k == \"v1\"]\n    return any(hmac.compare_digest(expected, sig) for sig in provided)\n",[18,757,758,764,771,778,785,789,805,829,833,837,865,915,957,968,976,980,985,1011,1017,1021,1055,1065,1073,1078,1082,1087,1117],{"__ignoreMap":226},[230,759,760,762],{"class":143,"line":232},[230,761,236],{"class":235},[230,763,240],{"class":239},[230,765,766,768],{"class":143,"line":243},[230,767,236],{"class":235},[230,769,770],{"class":239}," hmac\n",[230,772,773,775],{"class":143,"line":251},[230,774,236],{"class":235},[230,776,777],{"class":239}," hashlib\n",[230,779,780,782],{"class":143,"line":265},[230,781,236],{"class":235},[230,783,784],{"class":239}," time\n",[230,786,787],{"class":143,"line":272},[230,788,269],{"emptyLinePlaceholder":268},[230,790,791,793,795,797,799,801,803],{"class":143,"line":284},[230,792,288],{"class":287},[230,794,291],{"class":235},[230,796,294],{"class":239},[230,798,298],{"class":297},[230,800,301],{"class":239},[230,802,304],{"class":297},[230,804,307],{"class":239},[230,806,807,810,812,815,818,821,823,826],{"class":143,"line":310},[230,808,809],{"class":287},"TOLERANCE",[230,811,291],{"class":235},[230,813,814],{"class":287}," int",[230,816,817],{"class":239},"(os.getenv(",[230,819,820],{"class":297},"\"STRIPE_WEBHOOK_TOLERANCE\"",[230,822,301],{"class":239},[230,824,825],{"class":297},"\"300\"",[230,827,828],{"class":239},"))\n",[230,830,831],{"class":143,"line":315},[230,832,269],{"emptyLinePlaceholder":268},[230,834,835],{"class":143,"line":320},[230,836,269],{"emptyLinePlaceholder":268},[230,838,839,842,845,848,851,854,857,860,863],{"class":143,"line":335},[230,840,841],{"class":235},"def",[230,843,844],{"class":323}," verify_stripe",[230,846,847],{"class":239},"(raw_body: ",[230,849,850],{"class":287},"bytes",[230,852,853],{"class":239},", sig_header: ",[230,855,856],{"class":287},"str",[230,858,859],{"class":239},") -> ",[230,861,862],{"class":287},"bool",[230,864,395],{"class":239},[230,866,867,870,872,875,878,880,883,886,889,892,895,898,901,903,906,909,912],{"class":143,"line":350},[230,868,869],{"class":239},"    parts ",[230,871,278],{"class":235},[230,873,874],{"class":239}," [item.split(",[230,876,877],{"class":297},"\"=\"",[230,879,301],{"class":239},[230,881,882],{"class":287},"1",[230,884,885],{"class":239},") ",[230,887,888],{"class":235},"for",[230,890,891],{"class":239}," item ",[230,893,894],{"class":235},"in",[230,896,897],{"class":239}," sig_header.split(",[230,899,900],{"class":297},"\",\"",[230,902,885],{"class":239},[230,904,905],{"class":235},"if",[230,907,908],{"class":297}," \"=\"",[230,910,911],{"class":235}," in",[230,913,914],{"class":239}," item]\n",[230,916,917,920,922,925,928,930,933,935,938,940,943,946,949,952,955],{"class":143,"line":364},[230,918,919],{"class":239},"    timestamp ",[230,921,278],{"class":235},[230,923,924],{"class":287}," next",[230,926,927],{"class":239},"((v ",[230,929,888],{"class":235},[230,931,932],{"class":239}," k, v ",[230,934,894],{"class":235},[230,936,937],{"class":239}," parts ",[230,939,905],{"class":235},[230,941,942],{"class":239}," k ",[230,944,945],{"class":235},"==",[230,947,948],{"class":297}," \"t\"",[230,950,951],{"class":239},"), ",[230,953,954],{"class":287},"None",[230,956,307],{"class":239},[230,958,959,962,965],{"class":143,"line":384},[230,960,961],{"class":235},"    if",[230,963,964],{"class":235}," not",[230,966,967],{"class":239}," timestamp:\n",[230,969,970,973],{"class":143,"line":389},[230,971,972],{"class":235},"        return",[230,974,975],{"class":287}," False\n",[230,977,978],{"class":143,"line":398},[230,979,269],{"emptyLinePlaceholder":268},[230,981,982],{"class":143,"line":409},[230,983,984],{"class":532},"    # Reject events outside the tolerance window (replay protection).\n",[230,986,987,989,992,995,998,1000,1003,1006,1009],{"class":143,"line":421},[230,988,961],{"class":235},[230,990,991],{"class":287}," abs",[230,993,994],{"class":239},"(time.time() ",[230,996,997],{"class":235},"-",[230,999,814],{"class":287},[230,1001,1002],{"class":239},"(timestamp)) ",[230,1004,1005],{"class":235},">",[230,1007,1008],{"class":287}," TOLERANCE",[230,1010,395],{"class":239},[230,1012,1013,1015],{"class":143,"line":432},[230,1014,972],{"class":235},[230,1016,975],{"class":287},[230,1018,1019],{"class":143,"line":445},[230,1020,269],{"emptyLinePlaceholder":268},[230,1022,1023,1026,1028,1031,1034,1037,1040,1043,1046,1049,1052],{"class":143,"line":451},[230,1024,1025],{"class":239},"    signed_payload ",[230,1027,278],{"class":235},[230,1029,1030],{"class":235}," f",[230,1032,1033],{"class":297},"\"",[230,1035,1036],{"class":287},"{",[230,1038,1039],{"class":239},"timestamp",[230,1041,1042],{"class":287},"}",[230,1044,1045],{"class":297},".\"",[230,1047,1048],{"class":239},".encode() ",[230,1050,1051],{"class":235},"+",[230,1053,1054],{"class":239}," raw_body\n",[230,1056,1057,1060,1062],{"class":143,"line":462},[230,1058,1059],{"class":239},"    expected ",[230,1061,278],{"class":235},[230,1063,1064],{"class":239}," hmac.new(\n",[230,1066,1067,1070],{"class":143,"line":491},[230,1068,1069],{"class":287},"        ENDPOINT_SECRET",[230,1071,1072],{"class":239},".encode(), signed_payload, hashlib.sha256\n",[230,1074,1075],{"class":143,"line":499},[230,1076,1077],{"class":239},"    ).hexdigest()\n",[230,1079,1080],{"class":143,"line":524},[230,1081,269],{"emptyLinePlaceholder":268},[230,1083,1084],{"class":143,"line":529},[230,1085,1086],{"class":532},"    # Stripe can include several v1 signatures during secret rotation.\n",[230,1088,1089,1092,1094,1097,1099,1101,1103,1105,1107,1109,1111,1114],{"class":143,"line":536},[230,1090,1091],{"class":239},"    provided ",[230,1093,278],{"class":235},[230,1095,1096],{"class":239}," [v ",[230,1098,888],{"class":235},[230,1100,932],{"class":239},[230,1102,894],{"class":235},[230,1104,937],{"class":239},[230,1106,905],{"class":235},[230,1108,942],{"class":239},[230,1110,945],{"class":235},[230,1112,1113],{"class":297}," \"v1\"",[230,1115,1116],{"class":239},"]\n",[230,1118,1119,1121,1124,1127,1129,1132,1134],{"class":143,"line":551},[230,1120,586],{"class":235},[230,1122,1123],{"class":287}," any",[230,1125,1126],{"class":239},"(hmac.compare_digest(expected, sig) ",[230,1128,888],{"class":235},[230,1130,1131],{"class":239}," sig ",[230,1133,894],{"class":235},[230,1135,1136],{"class":239}," provided)\n",[14,1138,1139,1140,1143,1144,1146,1147,1149,1150,1153,1154,1156,1157,1161],{},"Two details trip everyone up. The timestamp is part of the signed message, so ",[18,1141,1142],{},"f\"{timestamp}.\""," has to prefix the exact raw bytes — build it as bytes, not by string-concatenating a decoded body, or a non-UTF-8 payload corrupts the digest. And ",[18,1145,185],{}," is what makes the comparison timing-safe: a naive ",[18,1148,945],{}," short-circuits on the first differing byte, and the microscopic timing difference leaks the digest one byte at a time to a patient attacker. Note the snippet keeps a list of pairs rather than a ",[18,1151,1152],{},"dict",", because a dict silently drops all but the last ",[18,1155,652],{}," — exactly the values you need during rotation. This same raw-body-plus-HMAC shape is what you use for providers with no Python SDK, which is why ",[32,1158,1160],{"href":1159},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fhandling-github-webhooks-with-fastapi\u002F","Handling GitHub Webhooks with FastAPI"," reads almost identically.",[55,1163,1165],{"id":1164},"when-to-use-the-sdk-vs-manual","When to use the SDK vs manual",[1167,1168,1169,1184],"table",{},[1170,1171,1172],"thead",{},[1173,1174,1175,1178,1181],"tr",{},[1176,1177],"th",{},[1176,1179,1180],{},"SDK construct_event",[1176,1182,1183],{},"Manual HMAC",[1185,1186,1187,1199,1210,1224,1238],"tbody",{},[1173,1188,1189,1193,1196],{},[1190,1191,1192],"td",{},"Tolerance check",[1190,1194,1195],{},"Built in (300s default)",[1190,1197,1198],{},"You write it",[1173,1200,1201,1204,1207],{},[1190,1202,1203],{},"Multiple v1 signatures",[1190,1205,1206],{},"Handled",[1190,1208,1209],{},"You handle it",[1173,1211,1212,1215,1221],{},[1190,1213,1214],{},"Returns",[1190,1216,1217,1218,1220],{},"Typed ",[18,1219,214],{}," object",[1190,1222,1223],{},"A boolean",[1173,1225,1226,1229,1235],{},[1190,1227,1228],{},"Dependency",[1190,1230,1231,1234],{},[18,1232,1233],{},"stripe"," package",[1190,1236,1237],{},"Standard library only",[1173,1239,1240,1243,1246],{},[1190,1241,1242],{},"Best for",[1190,1244,1245],{},"Production Stripe endpoints",[1190,1247,1248],{},"Learning, audits, thin deploys",[14,1250,1251,1252,1255,1256,1259,1260,1262],{},"Reach for ",[46,1253,1254],{},"the SDK"," whenever you're integrating Stripe for real. It tracks scheme changes, gives you a typed event to pattern-match on, and removes an entire class of subtle mistakes from code that touches money. Reach for ",[46,1257,1258],{},"manual verification"," in three narrow cases: you're verifying a provider that ships no Python SDK, you're auditing what a library actually does before trusting it, or you're squeezing a function into a serverless deploy where every dependency adds cold-start time and you've measured that the ",[18,1261,1233],{}," import matters. Outside those, the manual path is a footgun you're choosing to hold.",[55,1264,1266],{"id":1265},"failure-modes-and-edge-cases","Failure modes and edge cases",[14,1268,1269],{},"Signature verification fails loudly when you get it wrong — you find out in the Stripe dashboard's delivery log, not a customer complaint. The catch is that several distinct root causes all surface as the same red \"failed\" row, so knowing the taxonomy saves hours.",[77,1271,86,1276,86,1279,86,1282,86,1285,86,1292,86,1297,86,1300,86,1305,86,1309,86,1313,86,1316,86,1320,86,1324,86,1328,86,1330,86,1332,86,1336,86,1341,86,1343,86,1347,86,1351,86,1354,86,1356,86,1360,86,1363,86,1365,86,1368,86,1372,86,1376,86,1378],{"viewBox":1272,"role":80,"ariaLabelledBy":1273,"xmlns":84,"style":85},"0 0 720 320",[1274,1275],"stripe-out-t","stripe-out-d",[88,1277,1278],{"id":1274},"Verification outcome decision tree and the status code each branch returns",[92,1280,1281],{"id":1275},"An incoming webhook is checked for a parseable body, a present timestamp within tolerance, and a matching HMAC; each failing branch returns 400 or 401 and a match returns 200 after enqueueing.",[96,1283],{"x":98,"y":98,"width":99,"height":1284,"fill":101},"320",[103,1286,105,1287,86],{},[107,1288,115,1290,105],{"id":1289,"viewBox":110,"refX":111,"refY":112,"markerWidth":113,"markerHeight":113,"orient":114},"stripe-out-arrow",[117,1291],{"d":119,"fill":120},[96,1293],{"x":1294,"y":1295,"width":192,"height":1296,"rx":681,"fill":101,"stroke":128,"style":129},"270","16","42",[131,1298,1299],{"x":671,"y":1296,"fill":135,"style":201},"POST + raw body",[143,1301],{"x1":671,"y1":1302,"x2":671,"y2":1303,"stroke":120,"style":1304},"58","86","stroke-width:2;marker-end:url(#stripe-out-arrow);",[96,1306],{"x":1307,"y":1303,"width":1308,"height":1296,"rx":681,"fill":101,"stroke":717,"style":713},"256","208",[131,1310,1312],{"x":671,"y":1311,"fill":135,"style":201},"112","Body parses as event?",[143,1314],{"x1":1307,"y1":1315,"x2":679,"y2":1315,"stroke":181,"style":1304},"107",[96,1317],{"x":1318,"y":1303,"width":1319,"height":1296,"rx":681,"fill":101,"stroke":181,"style":129},"24","120",[131,1321,476],{"x":1322,"y":1323,"fill":135,"style":201},"84","105",[131,1325,1327],{"x":1322,"y":1319,"fill":120,"style":1326},"text-anchor:middle;font-size:10;font-family:var(--font-sans);","malformed",[143,1329],{"x1":671,"y1":162,"x2":671,"y2":191,"stroke":120,"style":1304},[96,1331],{"x":1307,"y":191,"width":1308,"height":1296,"rx":681,"fill":101,"stroke":717,"style":713},[131,1333,1335],{"x":671,"y":1334,"fill":135,"style":201},"182","t present, within tolerance?",[143,1337],{"x1":1338,"y1":1339,"x2":1340,"y2":1339,"stroke":181,"style":1304},"464","177","576",[96,1342],{"x":1340,"y":191,"width":162,"height":1296,"rx":681,"fill":101,"stroke":181,"style":129},[131,1344,510],{"x":1345,"y":1346,"fill":135,"style":201},"640","175",[131,1348,1350],{"x":1345,"y":1349,"fill":120,"style":1326},"190","stale \u002F replay",[143,1352],{"x1":671,"y1":1353,"x2":671,"y2":731,"stroke":120,"style":1304},"198",[96,1355],{"x":1307,"y":731,"width":1308,"height":1296,"rx":681,"fill":101,"stroke":717,"style":713},[131,1357,1359],{"x":671,"y":1358,"fill":135,"style":201},"252","Any v1 digest matches?",[143,1361],{"x1":1307,"y1":1362,"x2":679,"y2":1362,"stroke":181,"style":1304},"247",[96,1364],{"x":1318,"y":731,"width":1319,"height":1296,"rx":681,"fill":101,"stroke":181,"style":129},[131,1366,510],{"x":1322,"y":1367,"fill":135,"style":201},"245",[131,1369,1371],{"x":1322,"y":1370,"fill":120,"style":1326},"260","bad signature",[143,1373],{"x1":671,"y1":1374,"x2":671,"y2":1375,"stroke":150,"style":1304},"268","286",[96,1377],{"x":1307,"y":1375,"width":1308,"height":672,"rx":681,"fill":101,"stroke":150,"style":129},[131,1379,1380],{"x":671,"y":692,"fill":135,"style":201},"200 — enqueue, ack fast",[1382,1383,1384,1395,1401,1414,1424],"ul",{},[1385,1386,1387,1390,1391,1394],"li",{},[46,1388,1389],{},"Raw body, always."," If FastAPI parses the JSON into a Pydantic model first, the re-serialized bytes won't match the signature. Read ",[18,1392,1393],{},"await request.body()"," and verify those exact bytes. This is the same rule the parent webhooks guide opens with, and it's the most common cause of \"it worked in curl but fails from Stripe.\"",[1385,1396,1397,1400],{},[46,1398,1399],{},"Timestamp tolerance is replay protection, not a nicety."," Drop it and a captured valid request can be replayed indefinitely. 300 seconds is Stripe's default; keep it unless your servers have proven clock skew, in which case fix the clock with NTP rather than widening the window.",[1385,1402,1403,1406,1407,1409,1410,41],{},[46,1404,1405],{},"Multiple signatures during rotation."," When you roll an endpoint secret, Stripe signs each event with both the old and new secret for a window, sending two ",[18,1408,652],{}," values. Match against any of them, not just the first — the rotation discipline mirrors ",[32,1411,1413],{"href":1412},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime\u002F","Rotating API Keys Without Downtime",[1385,1415,1416,1419,1420,1423],{},[46,1417,1418],{},"Per-endpoint secrets."," Each Stripe webhook endpoint has its own ",[18,1421,1422],{},"whsec_...",". Point production at a test-mode secret, or reuse one endpoint's secret for another, and every event fails verification with no obvious cause — the signature is real, it just wasn't made with the key you're checking against.",[1385,1425,1426,1429,1430,1434],{},[46,1427,1428],{},"Verification is not deduplication."," A valid signature says the event is authentic, not that you haven't already processed it. Stripe retries, so route verified events into ",[32,1431,1433],{"href":1432},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fbuilding-an-idempotent-webhook-receiver\u002F","Building an Idempotent Webhook Receiver"," before you act on them.",[55,1436,1438],{"id":1437},"cost-and-performance","Cost and performance",[14,1440,1441],{},"Verification is effectively free, and it's worth knowing the numbers so you never treat it as a bottleneck to optimise away. An HMAC-SHA256 over a typical 2 KB Stripe payload runs in the low tens of microseconds on any modern vCPU — call it 0.03 ms. That is one to two orders of magnitude cheaper than the JSON parse, and far cheaper than the single Postgres insert you do to record the event. The security check is the smallest line item in the whole request; dropping it buys you a rounding error in exchange for your billing integrity.",[77,1443,86,1448,86,1451,86,1454,86,1456,86,1461,86,1463,86,1468,86,1473,86,1477,86,1480,86,1483,86,1487,86,1491,86,1495,86,1499,86,1503,86,1507,86,1511],{"viewBox":1444,"role":80,"ariaLabelledBy":1445,"xmlns":84,"style":85},"0 0 720 240",[1446,1447],"stripe-cost-t","stripe-cost-d",[88,1449,1450],{"id":1446},"Relative time cost of each step in a verified webhook request",[92,1452,1453],{"id":1447},"A log-scaled bar chart showing HMAC verification at about 0.03 milliseconds, JSON parse near 0.2 milliseconds, a Postgres insert near 3 milliseconds, and a downstream API call near 120 milliseconds.",[96,1455],{"x":98,"y":98,"width":99,"height":195,"fill":101},[131,1457,1460],{"x":1318,"y":1458,"fill":135,"style":1459},"28","text-anchor:start;font-size:13;font-family:var(--font-sans);","Time per step in a verified webhook (log scale)",[143,1462],{"x1":192,"y1":678,"x2":192,"y2":125,"stroke":717,"style":713},[96,1464],{"x":192,"y":1302,"width":1465,"height":1466,"rx":1467,"fill":150},"60","26","4",[131,1469,1472],{"x":1470,"y":145,"fill":135,"style":1471},"172","text-anchor:end;font-size:12;font-family:var(--font-sans);","HMAC verify",[131,1474,1476],{"x":100,"y":145,"fill":120,"style":1475},"text-anchor:start;font-size:11;font-family:var(--font-sans);","≈ 0.03 ms",[96,1478],{"x":192,"y":1479,"width":679,"height":1466,"rx":1467,"fill":128},"94",[131,1481,1482],{"x":1470,"y":1311,"fill":135,"style":1471},"JSON parse",[131,1484,1486],{"x":1485,"y":1311,"fill":120,"style":1475},"340","≈ 0.2 ms",[96,1488],{"x":192,"y":1489,"width":1490,"height":1466,"rx":1467,"fill":166},"130","300",[131,1492,1494],{"x":1470,"y":1493,"fill":135,"style":1471},"148","Postgres insert",[131,1496,1498],{"x":1497,"y":1493,"fill":120,"style":1475},"490","≈ 3 ms",[96,1500],{"x":192,"y":1501,"width":1502,"height":1466,"rx":1467,"fill":181},"166","470",[131,1504,1506],{"x":1470,"y":1505,"fill":135,"style":1471},"184","Downstream API",[131,1508,1510],{"x":1509,"y":1505,"fill":120,"style":1475},"656","≈ 120 ms",[131,1512,1515],{"x":192,"y":1513,"fill":120,"style":1514},"222","text-anchor:start;font-size:10;font-family:var(--font-sans);","Each bar is ~one order of magnitude — verify then enqueue, keep the slow call off the request path.",[14,1517,1518,1519,1522,1523,1526,1527,1530,1531,1533,1534,1538],{},"The performance rule that ",[27,1520,1521],{},"does"," matter is what happens ",[27,1524,1525],{},"after"," verification: acknowledge fast, then do the work off the request path. Stripe redelivers if it doesn't see a ",[18,1528,1529],{},"2xx"," within its timeout, so verify, write the event row, return ",[18,1532,125],{},", and let a background worker handle the downstream API call that dominates the chart above. Testing this end to end — including simulating the passage of time for subscription events — is worth doing before launch, which is where ",[32,1535,1537],{"href":1536},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Ftesting-stripe-integrations-with-test-clocks\u002F","Testing Stripe Integrations with Test Clocks"," earns its keep.",[55,1540,1542],{"id":1541},"builder-verdict","Builder verdict",[14,1544,1545,1546,1548,1549,1551,1552,1554,1555,41],{},"Use ",[18,1547,51],{}," and move on. It's a single call that handles the v1 scheme, the tolerance window, and secret rotation, and it returns a typed event you can pattern-match on. Reimplementing the HMAC by hand is a great way to understand the mechanism and a poor way to run it in production — the surface area for a subtle mistake (forgetting the timestamp prefix, comparing with ",[18,1550,945],{},", dropping a ",[18,1553,652],{},", reserializing the body) is exactly where a payment-handling bug becomes expensive. Verify with the SDK, enqueue the verified event for a background worker so your endpoint acks fast, and dedupe before you act. When you're ready to turn these events into revenue, the billing flow lives in ",[32,1556,1558],{"href":1557},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fhow-to-charge-for-api-access-using-stripe\u002F","How to Charge for API Access Using Stripe",[55,1560,1562],{"id":1561},"faq","FAQ",[14,1564,1565,1568,1569,1572,1573,1576],{},[46,1566,1567],{},"Where do I get the endpoint secret, and how much does a leak cost me?","\nIn the Stripe Dashboard under Developers → Webhooks, each endpoint shows a signing secret starting with ",[18,1570,1571],{},"whsec_",". Load it from ",[18,1574,1575],{},"STRIPE_WEBHOOK_SECRET"," and never hardcode it. If it leaks, an attacker can forge events that pass verification, so treat it like a production credential: rotate it immediately, and because Stripe signs with both secrets during the overlap, you can rotate with zero dropped events. Test-mode and live-mode endpoints have separate secrets.",[14,1578,1579,1582,1583,1586,1587,1590],{},[46,1580,1581],{},"Why does the timestamp go into the signature instead of alongside it?","\nSo the signature is only valid for a short window. Stripe signs ",[18,1584,1585],{},"t.payload",", and you reject anything where ",[18,1588,1589],{},"now − t"," exceeds your tolerance. Because the timestamp is inside the signed message, an attacker can't rewind it to make a captured request look fresh without invalidating the HMAC — that's what stops one legitimate request from being replayed later.",[14,1592,1593,1596,1597,301,1600,1603,1604,1607,1608,1610],{},[46,1594,1595],{},"Can I verify without the stripe package to cut cold-start cost?","\nYes — the manual option uses only ",[18,1598,1599],{},"hmac",[18,1601,1602],{},"hashlib",", and ",[18,1605,1606],{},"time"," from the standard library, which shaves the ",[18,1609,1233],{}," import off a cold serverless function. But measure before you commit: on most platforms the import costs a few milliseconds you'll never notice, and hand-rolled verification is where the expensive bugs live. For a real Stripe integration the SDK is worth its weight; keep the manual path for SDK-less providers.",[14,1612,1613,1616,1617,1619,1620,1622,1623,1625],{},[46,1614,1615],{},"What status code should a bad signature return, and does it affect retries?","\nReturn ",[18,1618,476],{}," for a payload Stripe couldn't have sent (malformed) and ",[18,1621,510],{}," for an authentic-looking request that fails verification. Either way it's non-2xx, so Stripe records the failure and retries on its schedule — which is correct, because a transient bug on your side shouldn't silently drop a real event. Never return ",[18,1624,125],{}," on an unverified event; that tells Stripe to stop retrying something you never actually processed.",[14,1627,1628,1631],{},[46,1629,1630],{},"Does verifying the signature mean I can process the event once and trust it?","\nNo. Verification proves origin, not uniqueness. Stripe delivers at least once and retries on timeouts, so a valid, verified event will arrive more than once. Combine signature verification with an idempotency ledger keyed on the event id so a duplicate is a no-op rather than a double charge.",[55,1633,1635],{"id":1634},"related","Related",[14,1637,1638],{},[46,1639,1640],{},"Same track:",[1382,1642,1643,1648,1653],{},[1385,1644,1645,1647],{},[32,1646,35],{"href":34}," — the receive-verify-enqueue shape this verification slots into.",[1385,1649,1650,1652],{},[32,1651,1160],{"href":1159}," — the same HMAC pattern for a provider with no Python SDK.",[1385,1654,1655,1657],{},[32,1656,1433],{"href":1432}," — dedupe verified events so retries don't double-charge.",[14,1659,1660],{},[46,1661,1662],{},"Other tracks:",[1382,1664,1665,1670],{},[1385,1666,1667,1669],{},[32,1668,1558],{"href":1557}," — turn verified events into billed usage.",[1385,1671,1672,1674],{},[32,1673,1537],{"href":1536}," — simulate time to test subscription webhooks before launch.",[1676,1677,1678],"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 pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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);}",{"title":226,"searchDepth":243,"depth":243,"links":1680},[1681,1682,1683,1684,1685,1686,1687,1688,1689],{"id":57,"depth":243,"text":58},{"id":205,"depth":243,"text":206},{"id":625,"depth":243,"text":626},{"id":1164,"depth":243,"text":1165},{"id":1265,"depth":243,"text":1266},{"id":1437,"depth":243,"text":1438},{"id":1541,"depth":243,"text":1542},{"id":1561,"depth":243,"text":1562},{"id":1634,"depth":243,"text":1635},"Verify Stripe-Signature headers in Python two ways: stripe.Webhook.construct_event and manual HMAC-SHA256 with the v1 scheme, timestamp tolerance, and secret rotation.","md",{"pageTitle":5,"type":1693,"datePublished":1694,"dateModified":1695},"article","2026-06-18","2026-07-23","\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures",{"title":5,"description":1690},"automating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures\u002Findex","yLQia6e8ctNcNsF-vhq8xuaz5j1bAdyHUSh6Ndf2rso",{"@context":1701,"@type":1702,"mainEntity":1703},"https:\u002F\u002Fschema.org","FAQPage",[1704,1709,1712,1715,1718],{"@type":1705,"name":1567,"acceptedAnswer":1706},"Question",{"@type":1707,"text":1708},"Answer","In the Stripe Dashboard under Developers → Webhooks, each endpoint shows a signing secret starting with whsec_. Load it from STRIPE_WEBHOOK_SECRET and never hardcode it. If it leaks, an attacker can forge events that pass verification, so treat it like a production credential: rotate it immediately, and because Stripe signs with both secrets during the overlap, you can rotate with zero dropped events. Test-mode and live-mode endpoints have separate secrets.",{"@type":1705,"name":1581,"acceptedAnswer":1710},{"@type":1707,"text":1711},"So the signature is only valid for a short window. Stripe signs t.payload, and you reject anything where now − t exceeds your tolerance. Because the timestamp is inside the signed message, an attacker can't rewind it to make a captured request look fresh without invalidating the HMAC — that's what stops one legitimate request from being replayed later.",{"@type":1705,"name":1595,"acceptedAnswer":1713},{"@type":1707,"text":1714},"Yes — the manual option uses only hmac, hashlib, and time from the standard library, which shaves the stripe import off a cold serverless function. But measure before you commit: on most platforms the import costs a few milliseconds you'll never notice, and hand-rolled verification is where the expensive bugs live. For a real Stripe integration the SDK is worth its weight; keep the manual path for SDK-less providers.",{"@type":1705,"name":1615,"acceptedAnswer":1716},{"@type":1707,"text":1717},"Return 400 for a payload Stripe couldn't have sent (malformed) and 401 for an authentic-looking request that fails verification. Either way it's non-2xx, so Stripe records the failure and retries on its schedule — which is correct, because a transient bug on your side shouldn't silently drop a real event. Never return 200 on an unverified event; that tells Stripe to stop retrying something you never actually processed.",{"@type":1705,"name":1630,"acceptedAnswer":1719},{"@type":1707,"text":1720},"No. Verification proves origin, not uniqueness. Stripe delivers at least once and retries on timeouts, so a valid, verified event will arrive more than once. Combine signature verification with an idempotency ledger keyed on the event id so a duplicate is a no-op rather than a double charge.",1784887028387]