[{"data":1,"prerenderedAt":1608},["ShallowReactive",2],{"page-\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fhandling-github-webhooks-with-fastapi\u002F":3,"faq-schema-\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fhandling-github-webhooks-with-fastapi\u002F":1587},{"id":4,"title":5,"body":6,"description":1576,"extension":1577,"meta":1578,"navigation":419,"path":1583,"seo":1584,"stem":1585,"__hash__":1586},"content\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fhandling-github-webhooks-with-fastapi\u002Findex.md","Handling GitHub Webhooks with FastAPI",{"type":7,"value":8,"toc":1564},"minimark",[9,13,37,50,55,66,85,218,222,233,253,333,337,355,895,916,920,931,1218,1241,1308,1312,1319,1385,1404,1408,1411,1418,1422,1435,1439,1451,1455,1461,1474,1483,1492,1505,1509,1514,1541,1546,1560],[10,11,5],"h1",{"id":12},"handling-github-webhooks-with-fastapi",[14,15,16,17,21,22,25,26,31,32,36],"p",{},"GitHub will POST to your endpoint on practically anything — a push, a new issue, a closed PR, a release. That makes it a clean trigger for build-your-own automation: deploy on push, post to Slack on release, label issues on open, kick off a changelog job when a tag lands. This page builds a FastAPI handler that verifies GitHub's ",[18,19,20],"code",{},"X-Hub-Signature-256"," HMAC, routes on the ",[18,23,24],{},"X-GitHub-Event"," header, deduplicates retries, and acknowledges fast. It is part of ",[27,28,30],"a",{"href":29},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002F","Processing Webhooks with Python",", which covers the receive-verify-enqueue pattern this page specializes for GitHub, and it sits inside the wider work of ",[27,33,35],{"href":34},"\u002Fautomating-side-hustle-operations-with-apis\u002F","automating side-hustle operations with APIs",".",[14,38,39,40,44,45,49],{},"The decision framing is simple: GitHub webhooks are the right tool when you want to ",[41,42,43],"em",{},"react to repository events"," the instant they happen, without polling the REST API on a cron. If you only need state on a schedule, polling is simpler and you should read ",[27,46,48],{"href":47},"\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"," before you commit. If you need to act the moment something happens — and you want to stay far under GitHub's REST rate limit while doing it — the webhook below is the pattern.",[51,52,54],"h2",{"id":53},"when-to-reach-for-github-webhooks","When to reach for GitHub webhooks",[14,56,57,58,61,62,65],{},"Reach for a webhook when the value is in ",[41,59,60],{},"immediacy",". Deploy the second ",[18,63,64],{},"main"," moves. Announce a release the instant it is published. Triage an issue as it lands rather than an hour later when a cron wakes up. A webhook is also far kinder to your rate limit than polling: GitHub pushes one small JSON body to you when a real event occurs, instead of you burning 5,000 authenticated requests an hour asking \"anything new yet?\" against a repository that changes twice a day.",[14,67,68,69,72,73,76,77,80,81,84],{},"Skip webhooks when you need history or batch state. \"All PRs merged this week\" is a query against the API, not a stream of events — you cannot reconstruct it reliably from webhook deliveries because you were not listening last Tuesday and GitHub does not backfill. Skip them, too, when your endpoint cannot be publicly reachable; for local development, GitHub's own webhook forwarding or a tunnel like ",[18,70,71],{},"ngrok"," exposes your machine so deliveries can reach it. The three events most side-hustle automations key on are ",[18,74,75],{},"push",", ",[18,78,79],{},"pull_request",", and ",[18,82,83],{},"release",", so the handler routes those first and acknowledges everything else.",[86,87,95,96,95,100,95,104,95,123,95,130,95,140,95,148,95,153,95,160,95,164,95,168,95,171,95,175,95,178,95,182,95,184,95,189,95,191,95,195,95,202,95,207,95,209,95,212,95,215],"svg",{"viewBox":88,"role":89,"ariaLabelledBy":90,"xmlns":93,"style":94},"0 0 720 250","img",[91,92],"ghwh-route-t","ghwh-route-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[97,98,99],"title",{"id":91},"GitHub event routing in FastAPI",[101,102,103],"desc",{"id":92},"A GitHub event is verified against the X-Hub-Signature-256 HMAC, then routed by the X-GitHub-Event header to a push, pull request, or release handler.",[105,106,107,108,95],"defs",{},"\n    ",[109,110,117,118,107],"marker",{"id":111,"viewBox":112,"refX":113,"refY":114,"markerWidth":115,"markerHeight":115,"orient":116},"ghwh-route-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[119,120],"path",{"d":121,"fill":122},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[124,125],"rect",{"x":126,"y":126,"width":127,"height":128,"fill":129},"0","720","250","var(--c-surface)",[124,131],{"x":132,"y":133,"width":134,"height":135,"rx":136,"fill":137,"stroke":138,"style":139},"14","95","150","60","10","var(--c-surface-alt)","var(--c-blue)","stroke-width:2;",[141,142,147],"text",{"x":143,"y":144,"fill":145,"style":146},"89","121","var(--c-text)","text-anchor:middle;font-size:13;font-family:var(--font-sans);","GitHub event",[141,149,152],{"x":143,"y":150,"fill":122,"style":151},"139","text-anchor:middle;font-size:11;font-family:var(--font-sans);","POST + headers",[154,155],"line",{"x1":156,"y1":157,"x2":158,"y2":157,"stroke":122,"style":159},"164","125","210","stroke-width:2;marker-end:url(#ghwh-route-arrow);",[124,161],{"x":158,"y":133,"width":162,"height":135,"rx":136,"fill":137,"stroke":163,"style":139},"160","var(--c-teal)",[141,165,167],{"x":166,"y":144,"fill":145,"style":146},"290","verify sha256",[141,169,170],{"x":166,"y":150,"fill":122,"style":151},"X-Hub-Signature",[154,172],{"x1":173,"y1":157,"x2":174,"y2":157,"stroke":122,"style":159},"370","416",[124,176],{"x":174,"y":133,"width":162,"height":135,"rx":136,"fill":137,"stroke":177,"style":139},"var(--c-yellow)",[141,179,181],{"x":180,"y":144,"fill":145,"style":146},"496","route by event",[141,183,24],{"x":180,"y":150,"fill":122,"style":151},[154,185],{"x1":186,"y1":187,"x2":188,"y2":135,"stroke":122,"style":159},"576","105","618",[154,190],{"x1":186,"y1":157,"x2":188,"y2":157,"stroke":122,"style":159},[154,192],{"x1":186,"y1":193,"x2":188,"y2":194,"stroke":122,"style":159},"145","190",[124,196],{"x":188,"y":197,"width":198,"height":199,"rx":200,"fill":137,"stroke":201,"style":139},"36","90","40","8","var(--c-coral)",[141,203,75],{"x":204,"y":205,"fill":145,"style":206},"663","61","text-anchor:middle;font-size:12;font-family:var(--font-sans);",[124,208],{"x":188,"y":187,"width":198,"height":199,"rx":200,"fill":137,"stroke":201,"style":139},[141,210,79],{"x":204,"y":211,"fill":145,"style":206},"130",[124,213],{"x":188,"y":214,"width":198,"height":199,"rx":200,"fill":137,"stroke":201,"style":139},"174",[141,216,83],{"x":204,"y":217,"fill":145,"style":206},"199",[51,219,221],{"id":220},"verify-the-signature-first","Verify the signature first",[14,223,224,225,228,229,232],{},"GitHub signs the raw request body with HMAC-SHA256 using the secret you set on the webhook, and sends the result as ",[18,226,227],{},"X-Hub-Signature-256: sha256=\u003Chex>",". Verification is the single most important line of the handler: without it, anyone who learns your endpoint URL can POST a fake ",[18,230,231],{},"release published"," event and trigger a deploy. The check is pure standard library — GitHub ships no signing SDK, so there is nothing to import.",[14,234,235,236,240,241,244,245,248,249,252],{},"Two rules make or break the check. First, hash the ",[237,238,239],"strong",{},"raw bytes"," exactly as received; if you let FastAPI parse the JSON and then re-serialize it, the whitespace changes and the signature will never match. Read ",[18,242,243],{},"await request.body()"," and hash that. Second, compare with ",[18,246,247],{},"hmac.compare_digest",", not ",[18,250,251],{},"=="," — a plain string equality leaks timing information that a determined attacker can use to forge a signature byte by byte. The sequence below is what happens on every delivery.",[86,254,95,259,95,262,95,265,95,272,95,275,95,280,95,285,95,288,95,292,95,298,95,300,95,305,95,309,95,314,95,319,95,322,95,326,95,330],{"viewBox":255,"role":89,"ariaLabelledBy":256,"xmlns":93,"style":94},"0 0 720 320",[257,258],"ghwh-hmac-t","ghwh-hmac-d",[97,260,261],{"id":257},"HMAC signature verification sequence",[101,263,264],{"id":258},"GitHub signs the raw body and POSTs it with the signature header; the endpoint recomputes the HMAC over the raw bytes, compares with compare_digest, and returns 200 on a match or 401 on a mismatch.",[105,266,107,267,95],{},[109,268,117,270,107],{"id":269,"viewBox":112,"refX":113,"refY":114,"markerWidth":115,"markerHeight":115,"orient":116},"ghwh-hmac-arrow",[119,271],{"d":121,"fill":122},[124,273],{"x":126,"y":126,"width":127,"height":274,"fill":129},"320",[124,276],{"x":199,"y":277,"width":278,"height":279,"rx":136,"fill":137,"stroke":138,"style":139},"20","200","46",[141,281,284],{"x":282,"y":283,"fill":145,"style":146},"140","48","GitHub",[124,286],{"x":287,"y":277,"width":278,"height":279,"rx":136,"fill":137,"stroke":163,"style":139},"480",[141,289,291],{"x":290,"y":283,"fill":145,"style":146},"580","Your FastAPI endpoint",[154,293],{"x1":282,"y1":294,"x2":282,"y2":295,"stroke":296,"style":297},"66","296","var(--c-border)","stroke-width:1;stroke-dasharray:4 4;",[154,299],{"x1":290,"y1":294,"x2":290,"y2":295,"stroke":296,"style":297},[141,301,304],{"x":302,"y":303,"fill":122,"style":151},"360","98","POST raw body + sha256=... signature",[154,306],{"x1":282,"y1":307,"x2":186,"y2":307,"stroke":122,"style":308},"110","stroke-width:2;marker-end:url(#ghwh-hmac-arrow);",[124,310],{"x":311,"y":312,"width":128,"height":313,"rx":200,"fill":137,"stroke":177,"style":139},"430","132","38",[141,315,318],{"x":316,"y":317,"fill":145,"style":206},"555","156","recompute HMAC over raw bytes",[124,320],{"x":311,"y":321,"width":128,"height":313,"rx":200,"fill":137,"stroke":177,"style":139},"182",[141,323,325],{"x":316,"y":324,"fill":145,"style":206},"206","compare_digest(expected, sent)",[141,327,329],{"x":302,"y":328,"fill":122,"style":151},"252","200 valid  \u002F  401 mismatch",[154,331],{"x1":186,"y1":332,"x2":282,"y2":332,"stroke":122,"style":308},"264",[51,334,336],{"id":335},"the-full-handler","The full handler",[14,338,339,340,342,343,346,347,350,351,36],{},"The event type arrives in a separate header, ",[18,341,24],{},", and a unique delivery id in ",[18,344,345],{},"X-GitHub-Delivery"," — your dedupe key. Install with ",[18,348,349],{},"pip install fastapi \"uvicorn[standard]\"","; everything else is standard library. Read the secret from the environment so it never lands in source control, the same discipline you would apply on any ",[27,352,354],{"href":353},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002F","async FastAPI setup",[356,357,362],"pre",{"className":358,"code":359,"language":360,"meta":361,"style":361},"language-python shiki shiki-themes github-light github-dark","import os\nimport hmac\nimport hashlib\nimport json\nfrom fastapi import FastAPI, Request, HTTPException\n\napp = FastAPI()\nWEBHOOK_SECRET = os.getenv(\"GITHUB_WEBHOOK_SECRET\", \"\")\n\n\ndef verify_github(raw_body: bytes, signature_header: str) -> bool:\n    if not signature_header.startswith(\"sha256=\"):\n        return False\n    sent = signature_header.removeprefix(\"sha256=\")\n    expected = hmac.new(\n        WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256\n    ).hexdigest()\n    return hmac.compare_digest(expected, sent)\n\n\n@app.post(\"\u002Fwebhooks\u002Fgithub\")\nasync def github_webhook(request: Request):\n    raw = await request.body()\n    signature = request.headers.get(\"X-Hub-Signature-256\", \"\")\n    if not verify_github(raw, signature):\n        raise HTTPException(status_code=401, detail=\"bad signature\")\n\n    event_type = request.headers.get(\"X-GitHub-Event\", \"\")\n    delivery_id = request.headers.get(\"X-GitHub-Delivery\", \"\")\n    payload = json.loads(raw)\n\n    # Route, but do the heavy lifting in a background worker.\n    match event_type:\n        case \"push\":\n            await on_push(payload, delivery_id)\n        case \"pull_request\":\n            await on_pull_request(payload, delivery_id)\n        case \"release\":\n            await on_release(payload, delivery_id)\n        case \"ping\":\n            return {\"pong\": True}\n        case _:\n            pass  # acknowledge unhandled events with 200\n\n    return {\"received\": True, \"event\": event_type}\n","python","",[18,363,364,376,384,392,400,414,421,433,458,463,468,499,517,526,541,552,561,567,576,581,586,600,615,629,649,659,689,694,713,732,743,748,755,764,775,784,794,802,812,820,830,851,859,868,873],{"__ignoreMap":361},[365,366,368,372],"span",{"class":154,"line":367},1,[365,369,371],{"class":370},"szBVR","import",[365,373,375],{"class":374},"sVt8B"," os\n",[365,377,379,381],{"class":154,"line":378},2,[365,380,371],{"class":370},[365,382,383],{"class":374}," hmac\n",[365,385,387,389],{"class":154,"line":386},3,[365,388,371],{"class":370},[365,390,391],{"class":374}," hashlib\n",[365,393,395,397],{"class":154,"line":394},4,[365,396,371],{"class":370},[365,398,399],{"class":374}," json\n",[365,401,403,406,409,411],{"class":154,"line":402},5,[365,404,405],{"class":370},"from",[365,407,408],{"class":374}," fastapi ",[365,410,371],{"class":370},[365,412,413],{"class":374}," FastAPI, Request, HTTPException\n",[365,415,417],{"class":154,"line":416},6,[365,418,420],{"emptyLinePlaceholder":419},true,"\n",[365,422,424,427,430],{"class":154,"line":423},7,[365,425,426],{"class":374},"app ",[365,428,429],{"class":370},"=",[365,431,432],{"class":374}," FastAPI()\n",[365,434,436,440,443,446,450,452,455],{"class":154,"line":435},8,[365,437,439],{"class":438},"sj4cs","WEBHOOK_SECRET",[365,441,442],{"class":370}," =",[365,444,445],{"class":374}," os.getenv(",[365,447,449],{"class":448},"sZZnC","\"GITHUB_WEBHOOK_SECRET\"",[365,451,76],{"class":374},[365,453,454],{"class":448},"\"\"",[365,456,457],{"class":374},")\n",[365,459,461],{"class":154,"line":460},9,[365,462,420],{"emptyLinePlaceholder":419},[365,464,466],{"class":154,"line":465},10,[365,467,420],{"emptyLinePlaceholder":419},[365,469,471,474,478,481,484,487,490,493,496],{"class":154,"line":470},11,[365,472,473],{"class":370},"def",[365,475,477],{"class":476},"sScJk"," verify_github",[365,479,480],{"class":374},"(raw_body: ",[365,482,483],{"class":438},"bytes",[365,485,486],{"class":374},", signature_header: ",[365,488,489],{"class":438},"str",[365,491,492],{"class":374},") -> ",[365,494,495],{"class":438},"bool",[365,497,498],{"class":374},":\n",[365,500,502,505,508,511,514],{"class":154,"line":501},12,[365,503,504],{"class":370},"    if",[365,506,507],{"class":370}," not",[365,509,510],{"class":374}," signature_header.startswith(",[365,512,513],{"class":448},"\"sha256=\"",[365,515,516],{"class":374},"):\n",[365,518,520,523],{"class":154,"line":519},13,[365,521,522],{"class":370},"        return",[365,524,525],{"class":438}," False\n",[365,527,529,532,534,537,539],{"class":154,"line":528},14,[365,530,531],{"class":374},"    sent ",[365,533,429],{"class":370},[365,535,536],{"class":374}," signature_header.removeprefix(",[365,538,513],{"class":448},[365,540,457],{"class":374},[365,542,544,547,549],{"class":154,"line":543},15,[365,545,546],{"class":374},"    expected ",[365,548,429],{"class":370},[365,550,551],{"class":374}," hmac.new(\n",[365,553,555,558],{"class":154,"line":554},16,[365,556,557],{"class":438},"        WEBHOOK_SECRET",[365,559,560],{"class":374},".encode(), raw_body, hashlib.sha256\n",[365,562,564],{"class":154,"line":563},17,[365,565,566],{"class":374},"    ).hexdigest()\n",[365,568,570,573],{"class":154,"line":569},18,[365,571,572],{"class":370},"    return",[365,574,575],{"class":374}," hmac.compare_digest(expected, sent)\n",[365,577,579],{"class":154,"line":578},19,[365,580,420],{"emptyLinePlaceholder":419},[365,582,584],{"class":154,"line":583},20,[365,585,420],{"emptyLinePlaceholder":419},[365,587,589,592,595,598],{"class":154,"line":588},21,[365,590,591],{"class":476},"@app.post",[365,593,594],{"class":374},"(",[365,596,597],{"class":448},"\"\u002Fwebhooks\u002Fgithub\"",[365,599,457],{"class":374},[365,601,603,606,609,612],{"class":154,"line":602},22,[365,604,605],{"class":370},"async",[365,607,608],{"class":370}," def",[365,610,611],{"class":476}," github_webhook",[365,613,614],{"class":374},"(request: Request):\n",[365,616,618,621,623,626],{"class":154,"line":617},23,[365,619,620],{"class":374},"    raw ",[365,622,429],{"class":370},[365,624,625],{"class":370}," await",[365,627,628],{"class":374}," request.body()\n",[365,630,632,635,637,640,643,645,647],{"class":154,"line":631},24,[365,633,634],{"class":374},"    signature ",[365,636,429],{"class":370},[365,638,639],{"class":374}," request.headers.get(",[365,641,642],{"class":448},"\"X-Hub-Signature-256\"",[365,644,76],{"class":374},[365,646,454],{"class":448},[365,648,457],{"class":374},[365,650,652,654,656],{"class":154,"line":651},25,[365,653,504],{"class":370},[365,655,507],{"class":370},[365,657,658],{"class":374}," verify_github(raw, signature):\n",[365,660,662,665,668,672,674,677,679,682,684,687],{"class":154,"line":661},26,[365,663,664],{"class":370},"        raise",[365,666,667],{"class":374}," HTTPException(",[365,669,671],{"class":670},"s4XuR","status_code",[365,673,429],{"class":370},[365,675,676],{"class":438},"401",[365,678,76],{"class":374},[365,680,681],{"class":670},"detail",[365,683,429],{"class":370},[365,685,686],{"class":448},"\"bad signature\"",[365,688,457],{"class":374},[365,690,692],{"class":154,"line":691},27,[365,693,420],{"emptyLinePlaceholder":419},[365,695,697,700,702,704,707,709,711],{"class":154,"line":696},28,[365,698,699],{"class":374},"    event_type ",[365,701,429],{"class":370},[365,703,639],{"class":374},[365,705,706],{"class":448},"\"X-GitHub-Event\"",[365,708,76],{"class":374},[365,710,454],{"class":448},[365,712,457],{"class":374},[365,714,716,719,721,723,726,728,730],{"class":154,"line":715},29,[365,717,718],{"class":374},"    delivery_id ",[365,720,429],{"class":370},[365,722,639],{"class":374},[365,724,725],{"class":448},"\"X-GitHub-Delivery\"",[365,727,76],{"class":374},[365,729,454],{"class":448},[365,731,457],{"class":374},[365,733,735,738,740],{"class":154,"line":734},30,[365,736,737],{"class":374},"    payload ",[365,739,429],{"class":370},[365,741,742],{"class":374}," json.loads(raw)\n",[365,744,746],{"class":154,"line":745},31,[365,747,420],{"emptyLinePlaceholder":419},[365,749,751],{"class":154,"line":750},32,[365,752,754],{"class":753},"sJ8bj","    # Route, but do the heavy lifting in a background worker.\n",[365,756,758,761],{"class":154,"line":757},33,[365,759,760],{"class":370},"    match",[365,762,763],{"class":374}," event_type:\n",[365,765,767,770,773],{"class":154,"line":766},34,[365,768,769],{"class":370},"        case",[365,771,772],{"class":448}," \"push\"",[365,774,498],{"class":374},[365,776,778,781],{"class":154,"line":777},35,[365,779,780],{"class":370},"            await",[365,782,783],{"class":374}," on_push(payload, delivery_id)\n",[365,785,787,789,792],{"class":154,"line":786},36,[365,788,769],{"class":370},[365,790,791],{"class":448}," \"pull_request\"",[365,793,498],{"class":374},[365,795,797,799],{"class":154,"line":796},37,[365,798,780],{"class":370},[365,800,801],{"class":374}," on_pull_request(payload, delivery_id)\n",[365,803,805,807,810],{"class":154,"line":804},38,[365,806,769],{"class":370},[365,808,809],{"class":448}," \"release\"",[365,811,498],{"class":374},[365,813,815,817],{"class":154,"line":814},39,[365,816,780],{"class":370},[365,818,819],{"class":374}," on_release(payload, delivery_id)\n",[365,821,823,825,828],{"class":154,"line":822},40,[365,824,769],{"class":370},[365,826,827],{"class":448}," \"ping\"",[365,829,498],{"class":374},[365,831,833,836,839,842,845,848],{"class":154,"line":832},41,[365,834,835],{"class":370},"            return",[365,837,838],{"class":374}," {",[365,840,841],{"class":448},"\"pong\"",[365,843,844],{"class":374},": ",[365,846,847],{"class":438},"True",[365,849,850],{"class":374},"}\n",[365,852,854,856],{"class":154,"line":853},42,[365,855,769],{"class":370},[365,857,858],{"class":374}," _:\n",[365,860,862,865],{"class":154,"line":861},43,[365,863,864],{"class":370},"            pass",[365,866,867],{"class":753},"  # acknowledge unhandled events with 200\n",[365,869,871],{"class":154,"line":870},44,[365,872,420],{"emptyLinePlaceholder":419},[365,874,876,878,880,883,885,887,889,892],{"class":154,"line":875},45,[365,877,572],{"class":370},[365,879,838],{"class":374},[365,881,882],{"class":448},"\"received\"",[365,884,844],{"class":374},[365,886,847],{"class":438},[365,888,76],{"class":374},[365,890,891],{"class":448},"\"event\"",[365,893,894],{"class":374},": event_type}\n",[14,896,897,898,901,902,904,905,908,909,911,912,915],{},"The ",[18,899,900],{},"ping"," case matters more than it looks: GitHub sends a ",[18,903,900],{}," event the moment you create the webhook, and the delivery dashboard shows a red X against the hook if you do not return a 2xx. The wildcard ",[18,906,907],{},"case _"," returns ",[18,910,278],{}," for events you have not subscribed to logic for, which stops GitHub from retrying deliveries you do not care about. One guard the snippet omits for brevity but production needs: reject a request whose body is larger than a sane ceiling (GitHub caps payloads at 25 MB) before you call ",[18,913,914],{},"json.loads",", so a malformed or hostile body cannot balloon your memory.",[51,917,919],{"id":918},"route-handlers-and-acknowledge-fast","Route handlers, and acknowledge fast",[14,921,922,923,926,927,930],{},"Each handler should enqueue and return — the same fast-ack rule from the ",[27,924,925],{"href":29},"webhooks guide",". GitHub expects a response within roughly 10 seconds; cloning a repo, running a build, or making an outbound API call inline will blow that budget and trigger a retry, which arrives as a ",[41,928,929],{},"second"," delivery of the same event and doubles your work.",[356,932,934],{"className":358,"code":933,"language":360,"meta":361,"style":361},"import logging\n\nlog = logging.getLogger(\"gh-webhook\")\n\n\nasync def on_push(payload: dict, delivery_id: str) -> None:\n    ref = payload.get(\"ref\", \"\")\n    if ref == \"refs\u002Fheads\u002Fmain\":\n        log.info(\"push to main %s — enqueue deploy\", delivery_id)\n        # await queue.enqueue(\"deploy\", payload[\"after\"])\n\n\nasync def on_pull_request(payload: dict, delivery_id: str) -> None:\n    if payload.get(\"action\") == \"opened\":\n        log.info(\"PR #%s opened\", payload[\"pull_request\"][\"number\"])\n        # await queue.enqueue(\"label_pr\", payload[\"pull_request\"][\"url\"])\n\n\nasync def on_release(payload: dict, delivery_id: str) -> None:\n    if payload.get(\"action\") == \"published\":\n        tag = payload[\"release\"][\"tag_name\"]\n        log.info(\"release %s published — enqueue announce\", tag)\n",[18,935,936,943,947,962,966,970,997,1016,1030,1047,1052,1056,1060,1083,1102,1129,1134,1138,1142,1165,1182,1203],{"__ignoreMap":361},[365,937,938,940],{"class":154,"line":367},[365,939,371],{"class":370},[365,941,942],{"class":374}," logging\n",[365,944,945],{"class":154,"line":378},[365,946,420],{"emptyLinePlaceholder":419},[365,948,949,952,954,957,960],{"class":154,"line":386},[365,950,951],{"class":374},"log ",[365,953,429],{"class":370},[365,955,956],{"class":374}," logging.getLogger(",[365,958,959],{"class":448},"\"gh-webhook\"",[365,961,457],{"class":374},[365,963,964],{"class":154,"line":394},[365,965,420],{"emptyLinePlaceholder":419},[365,967,968],{"class":154,"line":402},[365,969,420],{"emptyLinePlaceholder":419},[365,971,972,974,976,979,982,985,988,990,992,995],{"class":154,"line":416},[365,973,605],{"class":370},[365,975,608],{"class":370},[365,977,978],{"class":476}," on_push",[365,980,981],{"class":374},"(payload: ",[365,983,984],{"class":438},"dict",[365,986,987],{"class":374},", delivery_id: ",[365,989,489],{"class":438},[365,991,492],{"class":374},[365,993,994],{"class":438},"None",[365,996,498],{"class":374},[365,998,999,1002,1004,1007,1010,1012,1014],{"class":154,"line":423},[365,1000,1001],{"class":374},"    ref ",[365,1003,429],{"class":370},[365,1005,1006],{"class":374}," payload.get(",[365,1008,1009],{"class":448},"\"ref\"",[365,1011,76],{"class":374},[365,1013,454],{"class":448},[365,1015,457],{"class":374},[365,1017,1018,1020,1023,1025,1028],{"class":154,"line":435},[365,1019,504],{"class":370},[365,1021,1022],{"class":374}," ref ",[365,1024,251],{"class":370},[365,1026,1027],{"class":448}," \"refs\u002Fheads\u002Fmain\"",[365,1029,498],{"class":374},[365,1031,1032,1035,1038,1041,1044],{"class":154,"line":460},[365,1033,1034],{"class":374},"        log.info(",[365,1036,1037],{"class":448},"\"push to main ",[365,1039,1040],{"class":438},"%s",[365,1042,1043],{"class":448}," — enqueue deploy\"",[365,1045,1046],{"class":374},", delivery_id)\n",[365,1048,1049],{"class":154,"line":465},[365,1050,1051],{"class":753},"        # await queue.enqueue(\"deploy\", payload[\"after\"])\n",[365,1053,1054],{"class":154,"line":470},[365,1055,420],{"emptyLinePlaceholder":419},[365,1057,1058],{"class":154,"line":501},[365,1059,420],{"emptyLinePlaceholder":419},[365,1061,1062,1064,1066,1069,1071,1073,1075,1077,1079,1081],{"class":154,"line":519},[365,1063,605],{"class":370},[365,1065,608],{"class":370},[365,1067,1068],{"class":476}," on_pull_request",[365,1070,981],{"class":374},[365,1072,984],{"class":438},[365,1074,987],{"class":374},[365,1076,489],{"class":438},[365,1078,492],{"class":374},[365,1080,994],{"class":438},[365,1082,498],{"class":374},[365,1084,1085,1087,1089,1092,1095,1097,1100],{"class":154,"line":528},[365,1086,504],{"class":370},[365,1088,1006],{"class":374},[365,1090,1091],{"class":448},"\"action\"",[365,1093,1094],{"class":374},") ",[365,1096,251],{"class":370},[365,1098,1099],{"class":448}," \"opened\"",[365,1101,498],{"class":374},[365,1103,1104,1106,1109,1111,1114,1117,1120,1123,1126],{"class":154,"line":543},[365,1105,1034],{"class":374},[365,1107,1108],{"class":448},"\"PR #",[365,1110,1040],{"class":438},[365,1112,1113],{"class":448}," opened\"",[365,1115,1116],{"class":374},", payload[",[365,1118,1119],{"class":448},"\"pull_request\"",[365,1121,1122],{"class":374},"][",[365,1124,1125],{"class":448},"\"number\"",[365,1127,1128],{"class":374},"])\n",[365,1130,1131],{"class":154,"line":554},[365,1132,1133],{"class":753},"        # await queue.enqueue(\"label_pr\", payload[\"pull_request\"][\"url\"])\n",[365,1135,1136],{"class":154,"line":563},[365,1137,420],{"emptyLinePlaceholder":419},[365,1139,1140],{"class":154,"line":569},[365,1141,420],{"emptyLinePlaceholder":419},[365,1143,1144,1146,1148,1151,1153,1155,1157,1159,1161,1163],{"class":154,"line":578},[365,1145,605],{"class":370},[365,1147,608],{"class":370},[365,1149,1150],{"class":476}," on_release",[365,1152,981],{"class":374},[365,1154,984],{"class":438},[365,1156,987],{"class":374},[365,1158,489],{"class":438},[365,1160,492],{"class":374},[365,1162,994],{"class":438},[365,1164,498],{"class":374},[365,1166,1167,1169,1171,1173,1175,1177,1180],{"class":154,"line":583},[365,1168,504],{"class":370},[365,1170,1006],{"class":374},[365,1172,1091],{"class":448},[365,1174,1094],{"class":374},[365,1176,251],{"class":370},[365,1178,1179],{"class":448}," \"published\"",[365,1181,498],{"class":374},[365,1183,1184,1187,1189,1192,1195,1197,1200],{"class":154,"line":588},[365,1185,1186],{"class":374},"        tag ",[365,1188,429],{"class":370},[365,1190,1191],{"class":374}," payload[",[365,1193,1194],{"class":448},"\"release\"",[365,1196,1122],{"class":374},[365,1198,1199],{"class":448},"\"tag_name\"",[365,1201,1202],{"class":374},"]\n",[365,1204,1205,1207,1210,1212,1215],{"class":154,"line":602},[365,1206,1034],{"class":374},[365,1208,1209],{"class":448},"\"release ",[365,1211,1040],{"class":438},[365,1213,1214],{"class":448}," published — enqueue announce\"",[365,1216,1217],{"class":374},", tag)\n",[14,1219,897,1220,1223,1224,1226,1227,1230,1231,1235,1236,1240],{},[18,1221,1222],{},"action"," field is how GitHub distinguishes sub-events within one type — ",[18,1225,79],{}," alone covers opened, closed, synchronized, reopened, and a dozen more. Branch on ",[18,1228,1229],{},"payload[\"action\"]",", not just the event header, or your \"PR opened\" automation will also fire on every force-push to the branch. The queue calls are commented out because the queue is your choice: for a solo build, ",[27,1232,1234],{"href":1233},"\u002Fscaling-and-operating-production-python-apis\u002Frunning-background-jobs-with-celery\u002Fcelery-vs-rq-vs-arq\u002F","arq or RQ"," backed by Redis is enough, and you can graduate to full ",[27,1237,1239],{"href":1238},"\u002Fscaling-and-operating-production-python-apis\u002Frunning-background-jobs-with-celery\u002F","background jobs with Celery"," when concurrency demands it. The timeline below is the whole reason to enqueue rather than process inline.",[86,1242,95,1247,95,1250,95,1253,95,1256,95,1260,95,1264,95,1269,95,1273,95,1277,95,1281,95,1285,95,1290,95,1294,95,1299,95,1304],{"viewBox":1243,"role":89,"ariaLabelledBy":1244,"xmlns":93,"style":94},"0 0 720 240",[1245,1246],"ghwh-ack-t","ghwh-ack-d",[97,1248,1249],{"id":1245},"Fast-ack timeline against GitHub's delivery timeout",[101,1251,1252],{"id":1246},"The HTTP handler verifies and enqueues in about five milliseconds and returns 200, while a background worker does the slow clone, deploy, and notify work, well inside GitHub's ten-second delivery timeout.",[124,1254],{"x":126,"y":126,"width":127,"height":1255,"fill":129},"240",[141,1257,1259],{"x":307,"y":279,"fill":145,"style":1258},"text-anchor:end;font-size:12;font-family:var(--font-sans);","HTTP handler",[141,1261,1263],{"x":307,"y":1262,"fill":145,"style":1258},"112","Background worker",[154,1265],{"x1":1266,"y1":134,"x2":1267,"y2":134,"stroke":296,"style":1268},"120","680","stroke-width:1;",[141,1270,1272],{"x":1266,"y":1271,"fill":122,"style":151},"172","t=0",[124,1274],{"x":1266,"y":1275,"width":294,"height":1276,"rx":115,"fill":137,"stroke":163,"style":139},"26","34",[141,1278,1280],{"x":1279,"y":283,"fill":145,"style":151},"153","verify",[141,1282,1284],{"x":1283,"y":277,"fill":122,"style":151},"205","200 in ~5ms",[154,1286],{"x1":1287,"y1":1288,"x2":1287,"y2":134,"stroke":163,"style":1289},"186","43","stroke-width:1;stroke-dasharray:3 3;",[124,1291],{"x":1287,"y":1292,"width":1293,"height":1276,"rx":115,"fill":137,"stroke":138,"style":139},"92","374",[141,1295,1298],{"x":1296,"y":1297,"fill":145,"style":151},"373","114","clone \u002F deploy \u002F notify (seconds, off the request)",[154,1300],{"x1":1301,"y1":1302,"x2":1301,"y2":321,"stroke":201,"style":1303},"620","18","stroke-width:2;stroke-dasharray:5 4;",[141,1305,1307],{"x":1301,"y":1306,"fill":145,"style":151},"204","GitHub 10s timeout",[51,1309,1311],{"id":1310},"production-configuration-and-failure-modes","Production configuration and failure modes",[14,1313,1314,1315,1318],{},"A few environment-driven settings turn the snippet into something you can leave running. Keep the secret and any tuning knobs in ",[18,1316,1317],{},"os.getenv"," so staging and production differ only by config, never by code.",[1320,1321,1322,1338],"table",{},[1323,1324,1325],"thead",{},[1326,1327,1328,1332,1335],"tr",{},[1329,1330,1331],"th",{},"Env var",[1329,1333,1334],{},"Default",[1329,1336,1337],{},"Production note",[1339,1340,1341,1355,1370],"tbody",{},[1326,1342,1343,1349,1352],{},[1344,1345,1346],"td",{},[18,1347,1348],{},"GITHUB_WEBHOOK_SECRET",[1344,1350,1351],{},"empty",[1344,1353,1354],{},"Required; 32+ random bytes. Rotate on leak.",[1326,1356,1357,1362,1367],{},[1344,1358,1359],{},[18,1360,1361],{},"WEBHOOK_MAX_BYTES",[1344,1363,1364],{},[18,1365,1366],{},"1048576",[1344,1368,1369],{},"Reject larger bodies before parsing.",[1326,1371,1372,1377,1382],{},[1344,1373,1374],{},[18,1375,1376],{},"DEDUPE_TTL_SECONDS",[1344,1378,1379],{},[18,1380,1381],{},"86400",[1344,1383,1384],{},"How long to remember delivery ids.",[14,1386,1387,1388,1390,1391,1393,1394,1398,1399,1403],{},"Three failure modes bite builders most often. The first is verifying against a re-serialized body instead of the raw bytes, which fails every signature silently — always hash ",[18,1389,243],{},". The second is treating a retry as a new event: GitHub redelivers on any non-2xx or timeout, so a slow handler that eventually 200s can still fire your deploy twice. Dedupe on ",[18,1392,345],{},", which is stable across retries, by storing seen ids in ",[27,1395,1397],{"href":1396},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F","Redis"," with a TTL; the full pattern lives in ",[27,1400,1402],{"href":1401},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fbuilding-an-idempotent-webhook-receiver\u002F","building an idempotent webhook receiver",". The third is losing events during a deploy of your own service — if your endpoint is down when GitHub delivers, GitHub retries only a handful of times over a short window, so a long outage drops events for good. Enqueue first, process later, and keep the receiver itself trivially cheap to restart.",[51,1405,1407],{"id":1406},"cost-and-performance-at-scale","Cost and performance at scale",[14,1409,1410],{},"The economics here are lopsided in your favor, which is exactly why webhooks beat polling for automation. A verified-and-enqueued delivery does an HMAC over a few kilobytes and one Redis write: call it 2–5 milliseconds of CPU. A single small instance — a $7\u002Fmonth box, or even a free tier — handles a busy organization's entire event firehose, because the expensive work never touches the request thread. At one million deliveries a month you are looking at a couple of dollars of compute if the worker count is matched to your vCPUs, plus whatever the enqueued jobs themselves cost.",[14,1412,1413,1414,36],{},"Compare that to polling: to notice a push within 30 seconds you would poll every repo every 30 seconds, roughly 86,000 requests per repo per day, which shreds GitHub's 5,000-requests-per-hour authenticated limit after a handful of repositories and still adds up to 30 seconds of latency. The webhook delivers in well under a second and costs GitHub the request, not you. The only real spend is the downstream work you trigger — and if that work eventually charges customers, you fold the billing in via ",[27,1415,1417],{"href":1416},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002F","integrating Stripe with Python APIs",[51,1419,1421],{"id":1420},"when-to-use-when-to-avoid","When to use, when to avoid",[14,1423,1424,1425,1429,1430,1434],{},"Use GitHub webhooks when immediacy is the product: deploy-on-push, release announcements, instant issue triage, ChatOps. Avoid them when you need historical or aggregate state, when your endpoint cannot be public, or when a once-a-day batch job would do the same job with less moving infrastructure. And if you find yourself wiring the same receive-route-enqueue shape for GitHub, then Stripe, then a form provider, you are reimplementing a generic event router — at that point read ",[27,1426,1428],{"href":1427},"\u002Fautomating-side-hustle-operations-with-apis\u002Fbuilding-zapier-alternatives-with-python\u002F","building Zapier alternatives with Python"," and build the abstraction on purpose. The sibling pattern for payment events, ",[27,1431,1433],{"href":1432},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures\u002F","verifying Stripe webhook signatures",", differs only in the header and the signing scheme.",[51,1436,1438],{"id":1437},"builder-verdict","Builder verdict",[14,1440,1441,1442,1444,1445,1447,1448,1450],{},"Verify with the standard-library HMAC shown above — six lines, nothing to import. Route on ",[18,1443,24],{},", branch on the payload's ",[18,1446,1222],{},", dedupe on ",[18,1449,345],{},", and enqueue everything heavier than a log line. The one mistake that bites people is doing real work — a clone, a build, an outbound API call — inside the handler and tripping GitHub's delivery timeout, which turns one event into a retry pile-up and, without dedupe, a double deploy. Acknowledge in milliseconds, process in a worker, and a single small box will absorb an entire organization's event traffic on a rounding-error budget. That is the whole trade: near-zero receiver cost in exchange for the discipline of never blocking the request.",[51,1452,1454],{"id":1453},"faq","FAQ",[14,1456,1457,1460],{},[237,1458,1459],{},"How much does it cost to run this at a million deliveries a month?","\nA couple of dollars of compute for the receiver, because each verified-and-enqueued delivery is 2–5 milliseconds of CPU plus one Redis write. The real spend is the downstream work you trigger — the deploy, the notification, the API call — not the webhook endpoint itself, which a $7 box or a free tier absorbs easily.",[14,1462,1463,1466,1467,1469,1470,1473],{},[237,1464,1465],{},"What's the difference between X-Hub-Signature and X-Hub-Signature-256?","\nThe unsuffixed header is the legacy SHA-1 signature; ",[18,1468,20],{}," is the SHA-256 version. Always verify the ",[18,1471,1472],{},"-256"," header and ignore the SHA-1 one — SHA-1 is deprecated for this purpose and offers no security benefit to check.",[14,1475,1476,1479,1480,1482],{},[237,1477,1478],{},"How do I avoid processing the same delivery twice and double-deploying?","\nDedupe on ",[18,1481,345],{},", which is unique per delivery and stable across GitHub's retries. Store seen ids in Redis with a TTL and skip any id you have already handled. GitHub retries on any non-2xx or timeout, so without this a slow handler can fire your automation more than once.",[14,1484,1485,1488,1489,1491],{},[237,1486,1487],{},"How should I rotate the webhook secret without missing events?","\nAdd the new secret in the GitHub webhook settings, then update ",[18,1490,1348],{}," and redeploy; during the overlap, verify against both the old and new secret and accept a match on either. Once every delivery signs with the new secret, drop the old one. This mirrors zero-downtime key rotation for any signed integration.",[14,1493,1494,1497,1498,1501,1502,1504],{},[237,1495,1496],{},"Can I test the endpoint without pushing real commits?","\nYes. The GitHub webhook settings page has a \"Recent Deliveries\" tab with a Redeliver button, and you can copy a payload and replay it locally with a ",[18,1499,1500],{},"curl"," request carrying a self-computed ",[18,1503,20],{}," header. That lets you develop against real payloads without touching the repository.",[51,1506,1508],{"id":1507},"related","Related",[14,1510,1511],{},[237,1512,1513],{},"Same track:",[1515,1516,1517,1523,1529,1535],"ul",{},[1518,1519,1520,1522],"li",{},[27,1521,30],{"href":29}," — the parent guide with the general receive-verify-enqueue pattern.",[1518,1524,1525,1528],{},[27,1526,1527],{"href":1401},"Building an Idempotent Webhook Receiver"," — the dedupe store that stops double deploys.",[1518,1530,1531,1534],{},[27,1532,1533],{"href":1432},"Verifying Stripe Webhook Signatures"," — the same idea for payment events.",[1518,1536,1537,1540],{},[27,1538,1539],{"href":1427},"Building Zapier Alternatives with Python"," — when to turn ad-hoc handlers into a real event router.",[14,1542,1543],{},[237,1544,1545],{},"Other tracks:",[1515,1547,1548,1554],{},[1518,1549,1550,1553],{},[27,1551,1552],{"href":47},"When to Use Webhooks Instead of Polling"," — the decision behind this whole page.",[1518,1555,1556,1559],{},[27,1557,1558],{"href":1238},"Running Background Jobs with Celery"," — where the enqueued work actually runs.",[1561,1562,1563],"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":361,"searchDepth":378,"depth":378,"links":1565},[1566,1567,1568,1569,1570,1571,1572,1573,1574,1575],{"id":53,"depth":378,"text":54},{"id":220,"depth":378,"text":221},{"id":335,"depth":378,"text":336},{"id":918,"depth":378,"text":919},{"id":1310,"depth":378,"text":1311},{"id":1406,"depth":378,"text":1407},{"id":1420,"depth":378,"text":1421},{"id":1437,"depth":378,"text":1438},{"id":1453,"depth":378,"text":1454},{"id":1507,"depth":378,"text":1508},"Receive GitHub webhooks in FastAPI: verify the X-Hub-Signature-256 HMAC, route by X-GitHub-Event, dedupe deliveries, and respond fast so nothing times out.","md",{"pageTitle":1579,"type":1580,"datePublished":1581,"dateModified":1582},"Handling GitHub Webhooks with FastAPI: A Builder Guide","article","2026-06-18","2026-07-23","\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fhandling-github-webhooks-with-fastapi",{"title":5,"description":1576},"automating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fhandling-github-webhooks-with-fastapi\u002Findex","AISafBBn6lIUbZjb3ycCKAuqNk56MvUO-1Bkl9G96eg",{"@context":1588,"@type":1589,"mainEntity":1590},"https:\u002F\u002Fschema.org","FAQPage",[1591,1596,1599,1602,1605],{"@type":1592,"name":1459,"acceptedAnswer":1593},"Question",{"@type":1594,"text":1595},"Answer","A couple of dollars of compute for the receiver, because each verified-and-enqueued delivery is 2–5 milliseconds of CPU plus one Redis write. The real spend is the downstream work you trigger — the deploy, the notification, the API call — not the webhook endpoint itself, which a $7 box or a free tier absorbs easily.",{"@type":1592,"name":1465,"acceptedAnswer":1597},{"@type":1594,"text":1598},"The unsuffixed header is the legacy SHA-1 signature; X-Hub-Signature-256 is the SHA-256 version. Always verify the -256 header and ignore the SHA-1 one — SHA-1 is deprecated for this purpose and offers no security benefit to check.",{"@type":1592,"name":1478,"acceptedAnswer":1600},{"@type":1594,"text":1601},"Dedupe on X-GitHub-Delivery, which is unique per delivery and stable across GitHub's retries. Store seen ids in Redis with a TTL and skip any id you have already handled. GitHub retries on any non-2xx or timeout, so without this a slow handler can fire your automation more than once.",{"@type":1592,"name":1487,"acceptedAnswer":1603},{"@type":1594,"text":1604},"Add the new secret in the GitHub webhook settings, then update GITHUB_WEBHOOK_SECRET and redeploy; during the overlap, verify against both the old and new secret and accept a match on either. Once every delivery signs with the new secret, drop the old one. This mirrors zero-downtime key rotation for any signed integration.",{"@type":1592,"name":1496,"acceptedAnswer":1606},{"@type":1594,"text":1607},"Yes. The GitHub webhook settings page has a \"Recent Deliveries\" tab with a Redeliver button, and you can copy a payload and replay it locally with a curl request carrying a self-computed X-Hub-Signature-256 header. That lets you develop against real payloads without touching the repository.",1784887028618]