[{"data":1,"prerenderedAt":2156},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook":3,"surround:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook":2123,"ancestors:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook":2132,"children:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook":2137,"related:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook":2138},{"id":4,"title":5,"body":6,"dateModified":2089,"datePublished":2089,"description":2090,"extension":2091,"faq":2092,"keyword":2101,"kind":2102,"meta":2103,"navigation":126,"path":2112,"seo":2113,"slug":2116,"stem":2117,"topics":2118,"type":2121,"__hash__":2122},"docs\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook\u002Findex.md","Trigger an Excel Report from a Webhook",{"type":7,"value":8,"toc":2077},"minimark",[9,28,33,71,77,81,84,383,390,501,505,508,838,849,994,998,1400,1415,1553,1557,1564,1927,1935,1939,1954,1958,1999,2003,2012,2016,2022,2028,2034,2040,2044,2073],[10,11,12,13,17,18,21,22,27],"p",{},"Most report jobs run on a schedule — every morning at seven, every Monday. Some reports are better built when something ",[14,15,16],"em",{},"happens",": a month is closed in the accounting system, an order batch is approved, a survey closes, a pipeline finishes loading data. Many systems can announce such events by calling a URL you give them — a ",[14,19,20],{},"webhook"," — with a small JSON payload. A Python endpoint that receives that call can build the Excel report straight away, so it arrives minutes after the event rather than the next morning, and never runs on stale data. Doing it safely takes more than a route that calls the report function: the endpoint must reject calls that did not come from the real sender, cope with the same event being delivered twice, and answer quickly even if the report takes a minute to build. This page builds such an endpoint with FastAPI and tests it without deploying anything. It belongs to ",[23,24,26],"a",{"href":25},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002F","Scheduling Python Excel Scripts with Cron",".",[29,30,32],"h2",{"id":31},"prerequisites","Prerequisites",[34,35,40],"pre",{"className":36,"code":37,"language":38,"meta":39,"style":39},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install fastapi uvicorn httpx pandas openpyxl\n","bash","",[41,42,43],"code",{"__ignoreMap":39},[44,45,48,52,56,59,62,65,68],"span",{"class":46,"line":47},"line",1,[44,49,51],{"class":50},"sMTad","pip",[44,53,55],{"class":54},"srMev"," install",[44,57,58],{"class":54}," fastapi",[44,60,61],{"class":54}," uvicorn",[44,63,64],{"class":54}," httpx",[44,66,67],{"class":54}," pandas",[44,69,70],{"class":54}," openpyxl\n",[10,72,73,76],{},[41,74,75],{},"httpx"," is needed by FastAPI's test client. The sending system and its secret are illustrative: most webhook providers — accounting and payment platforms, form tools, GitHub, CI services — sign their requests with HMAC-SHA256 in much the same way, but the header names and the exact string that is signed differ, so check your provider's documentation for those two details.",[29,78,80],{"id":79},"step-1-the-report-the-event-triggers","Step 1 — The report the event triggers",[10,82,83],{},"The report itself is an ordinary function that takes the event's data — here, which period was closed — and writes a workbook:",[34,85,89],{"className":86,"code":87,"language":88,"meta":39,"style":39},"language-python shiki shiki-themes github-light github-dark-high-contrast","from pathlib import Path\nimport pandas as pd\n\nOUT = Path(\"reports\")\nOUT.mkdir(exist_ok=True)\n\ndef build_close_report(period):\n    ledger = pd.DataFrame({\"account\": [\"4000 Sales\", \"5000 Cost of sales\", \"6100 Wages\", \"6200 Rent\"],\n                           \"amount\": [182400.0, -96350.0, -41200.0, -8500.0]})\n    path = OUT \u002F f\"close_report_{period}.xlsx\"\n    with pd.ExcelWriter(path) as xw:\n        ledger.to_excel(xw, sheet_name=\"Ledger\", index=False)\n        pd.DataFrame({\"period\": [period], \"result\": [ledger[\"amount\"].sum()]}).to_excel(xw, sheet_name=\"Summary\", index=False)\n    return path\n","python",[41,90,91,107,121,128,147,167,172,185,224,260,293,307,333,374],{"__ignoreMap":39},[44,92,93,97,101,104],{"class":46,"line":47},[44,94,96],{"class":95},"s-kum","from",[44,98,100],{"class":99},"skGVy"," pathlib ",[44,102,103],{"class":95},"import",[44,105,106],{"class":99}," Path\n",[44,108,110,112,115,118],{"class":46,"line":109},2,[44,111,103],{"class":95},[44,113,114],{"class":99}," pandas ",[44,116,117],{"class":95},"as",[44,119,120],{"class":99}," pd\n",[44,122,124],{"class":46,"line":123},3,[44,125,127],{"emptyLinePlaceholder":126},true,"\n",[44,129,131,135,138,141,144],{"class":46,"line":130},4,[44,132,134],{"class":133},"sP0c6","OUT",[44,136,137],{"class":95}," =",[44,139,140],{"class":99}," Path(",[44,142,143],{"class":54},"\"reports\"",[44,145,146],{"class":99},")\n",[44,148,150,152,155,159,162,165],{"class":46,"line":149},5,[44,151,134],{"class":133},[44,153,154],{"class":99},".mkdir(",[44,156,158],{"class":157},"sa561","exist_ok",[44,160,161],{"class":95},"=",[44,163,164],{"class":133},"True",[44,166,146],{"class":99},[44,168,170],{"class":46,"line":169},6,[44,171,127],{"emptyLinePlaceholder":126},[44,173,175,178,182],{"class":46,"line":174},7,[44,176,177],{"class":95},"def",[44,179,181],{"class":180},"s_Opv"," build_close_report",[44,183,184],{"class":99},"(period):\n",[44,186,188,191,193,196,199,202,205,208,211,213,216,218,221],{"class":46,"line":187},8,[44,189,190],{"class":99},"    ledger ",[44,192,161],{"class":95},[44,194,195],{"class":99}," pd.DataFrame({",[44,197,198],{"class":54},"\"account\"",[44,200,201],{"class":99},": [",[44,203,204],{"class":54},"\"4000 Sales\"",[44,206,207],{"class":99},", ",[44,209,210],{"class":54},"\"5000 Cost of sales\"",[44,212,207],{"class":99},[44,214,215],{"class":54},"\"6100 Wages\"",[44,217,207],{"class":99},[44,219,220],{"class":54},"\"6200 Rent\"",[44,222,223],{"class":99},"],\n",[44,225,227,230,232,235,237,240,243,245,247,250,252,254,257],{"class":46,"line":226},9,[44,228,229],{"class":54},"                           \"amount\"",[44,231,201],{"class":99},[44,233,234],{"class":133},"182400.0",[44,236,207],{"class":99},[44,238,239],{"class":95},"-",[44,241,242],{"class":133},"96350.0",[44,244,207],{"class":99},[44,246,239],{"class":95},[44,248,249],{"class":133},"41200.0",[44,251,207],{"class":99},[44,253,239],{"class":95},[44,255,256],{"class":133},"8500.0",[44,258,259],{"class":99},"]})\n",[44,261,263,266,268,271,274,277,280,284,287,290],{"class":46,"line":262},10,[44,264,265],{"class":99},"    path ",[44,267,161],{"class":95},[44,269,270],{"class":133}," OUT",[44,272,273],{"class":95}," \u002F",[44,275,276],{"class":95}," f",[44,278,279],{"class":54},"\"close_report_",[44,281,283],{"class":282},"sSjpA","{",[44,285,286],{"class":99},"period",[44,288,289],{"class":282},"}",[44,291,292],{"class":54},".xlsx\"\n",[44,294,296,299,302,304],{"class":46,"line":295},11,[44,297,298],{"class":95},"    with",[44,300,301],{"class":99}," pd.ExcelWriter(path) ",[44,303,117],{"class":95},[44,305,306],{"class":99}," xw:\n",[44,308,310,313,316,318,321,323,326,328,331],{"class":46,"line":309},12,[44,311,312],{"class":99},"        ledger.to_excel(xw, ",[44,314,315],{"class":157},"sheet_name",[44,317,161],{"class":95},[44,319,320],{"class":54},"\"Ledger\"",[44,322,207],{"class":99},[44,324,325],{"class":157},"index",[44,327,161],{"class":95},[44,329,330],{"class":133},"False",[44,332,146],{"class":99},[44,334,336,339,342,345,348,351,354,357,359,361,364,366,368,370,372],{"class":46,"line":335},13,[44,337,338],{"class":99},"        pd.DataFrame({",[44,340,341],{"class":54},"\"period\"",[44,343,344],{"class":99},": [period], ",[44,346,347],{"class":54},"\"result\"",[44,349,350],{"class":99},": [ledger[",[44,352,353],{"class":54},"\"amount\"",[44,355,356],{"class":99},"].sum()]}).to_excel(xw, ",[44,358,315],{"class":157},[44,360,161],{"class":95},[44,362,363],{"class":54},"\"Summary\"",[44,365,207],{"class":99},[44,367,325],{"class":157},[44,369,161],{"class":95},[44,371,330],{"class":133},[44,373,146],{"class":99},[44,375,377,380],{"class":46,"line":376},14,[44,378,379],{"class":95},"    return",[44,381,382],{"class":99}," path\n",[10,384,385,386,27],{},"In a real job this would query the accounting system for the closed period; the point is that it is the same function a scheduled job would call. Keeping it separate from the web endpoint means it can be tested, rerun by hand and triggered by other means. Making it safe to run twice for the same period — deterministic file names, atomic writes — is covered in ",[23,387,389],{"href":388},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun\u002F","Make an Excel Report Job Safe to Rerun",[391,392,400,401,400,405,400,409,400,417,400,427,400,438,400,445,400,451,400,456,400,460,400,464,400,468,400,473,400,480,400,485,400,489,400,492,400,495,400,498],"svg",{"viewBox":393,"role":394,"ariaLabelledBy":395,"xmlns":398,"style":399},"4 0 752 233","img",[396,397],"wh-vs-t","wh-vs-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[402,403,404],"title",{"id":396},"A schedule or a webhook",[406,407,408],"desc",{"id":397},"A scheduled job runs at fixed times whether or not anything changed and may run before the data is ready. A webhook-triggered job runs as soon as the event happens, only when it happens, but needs a public endpoint and protection against forged and repeated calls.",[410,411],"rect",{"x":412,"y":413,"width":414,"height":415,"fill":416},"4","0","752","233","#ffffff",[410,418],{"x":419,"y":420,"width":421,"height":422,"rx":423,"fill":424,"stroke":425,"style":426},"20.0","16.0","351.0","201.0","14","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[428,429,431],"g",{"transform":430},"translate(182.5,29.0) scale(1.083)",[432,433],"path",{"d":434,"fill":435,"stroke":436,"style":437},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2","none","var(--text,#172033)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[439,440,444],"text",{"x":441,"y":442,"style":443},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Scheduled job",[46,446],{"x1":447,"y1":448,"x2":449,"y2":448,"stroke":425,"style":450},"36.0","91.0","355.0","stroke-width:1px",[439,452,455],{"x":441,"y":453,"style":454},"111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","runs at fixed times",[439,457,459],{"x":441,"y":458,"style":454},"137.0","may run on stale data",[439,461,463],{"x":441,"y":462,"style":454},"163.0","no public endpoint",[439,465,467],{"x":441,"y":466,"style":454},"189.0","simple to operate",[410,469],{"x":470,"y":420,"width":421,"height":422,"rx":423,"fill":471,"stroke":472,"style":426},"389.0","#d9f4f1","var(--teal,#0f9488)",[428,474,476],{"transform":475},"translate(551.5,29.0) scale(1.083)",[432,477],{"d":478,"fill":435,"stroke":479,"style":437},"M13 2L4 14h7l-1 8 9-12h-7z","var(--teal-ink,#0b6157)",[439,481,484],{"x":482,"y":442,"style":483},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Webhook trigger",[46,486],{"x1":487,"y1":448,"x2":488,"y2":448,"stroke":472,"style":450},"405.0","724.0",[439,490,491],{"x":482,"y":453,"style":454},"runs when the event happens",[439,493,494],{"x":482,"y":458,"style":454},"data is ready by definition",[439,496,497],{"x":482,"y":462,"style":454},"needs an HTTPS endpoint",[439,499,500],{"x":482,"y":466,"style":454},"verify, deduplicate, respond fast",[29,502,504],{"id":503},"step-2-verify-the-signature-and-timestamp","Step 2 — Verify the signature and timestamp",[10,506,507],{},"Anyone who learns the URL can POST to it, so the endpoint must check that each request came from the real sender. The sender and receiver share a secret; the sender computes an HMAC-SHA256 of the timestamp and the raw body with that secret and puts it in a header. The receiver recomputes it and compares:",[34,509,511],{"className":86,"code":510,"language":88,"meta":39,"style":39},"import hashlib\nimport hmac\nimport time\n\nSECRET = b\"whsec_test_3f9a1c\"          # in production: from an environment variable or secret store\nTOLERANCE = 300                         # seconds\n\ndef sign(body: bytes, timestamp: int, secret: bytes = SECRET) -> str:\n    message = str(timestamp).encode() + b\".\" + body\n    return \"sha256=\" + hmac.new(secret, message, hashlib.sha256).hexdigest()\n\ndef verify(body: bytes, timestamp: str | None, signature: str | None, now: float | None = None) -> str | None:\n    if not timestamp or not signature:\n        return \"missing signature headers\"\n    try:\n        ts = int(timestamp)\n    except ValueError:\n        return \"bad timestamp\"\n    if abs((now or time.time()) - ts) > TOLERANCE:\n        return \"timestamp outside tolerance\"\n    if not hmac.compare_digest(sign(body, ts), signature):\n        return \"signature mismatch\"\n    return None\n",[41,512,513,520,527,534,538,555,568,572,610,637,649,653,707,726,734,742,756,767,775,804,812,822,830],{"__ignoreMap":39},[44,514,515,517],{"class":46,"line":47},[44,516,103],{"class":95},[44,518,519],{"class":99}," hashlib\n",[44,521,522,524],{"class":46,"line":109},[44,523,103],{"class":95},[44,525,526],{"class":99}," hmac\n",[44,528,529,531],{"class":46,"line":123},[44,530,103],{"class":95},[44,532,533],{"class":99}," time\n",[44,535,536],{"class":46,"line":130},[44,537,127],{"emptyLinePlaceholder":126},[44,539,540,543,545,548,551],{"class":46,"line":149},[44,541,542],{"class":133},"SECRET",[44,544,137],{"class":95},[44,546,547],{"class":95}," b",[44,549,550],{"class":54},"\"whsec_test_3f9a1c\"",[44,552,554],{"class":553},"s-wDw","          # in production: from an environment variable or secret store\n",[44,556,557,560,562,565],{"class":46,"line":169},[44,558,559],{"class":133},"TOLERANCE",[44,561,137],{"class":95},[44,563,564],{"class":133}," 300",[44,566,567],{"class":553},"                         # seconds\n",[44,569,570],{"class":46,"line":174},[44,571,127],{"emptyLinePlaceholder":126},[44,573,574,576,579,582,585,588,591,594,596,598,601,604,607],{"class":46,"line":187},[44,575,177],{"class":95},[44,577,578],{"class":180}," sign",[44,580,581],{"class":99},"(body: ",[44,583,584],{"class":133},"bytes",[44,586,587],{"class":99},", timestamp: ",[44,589,590],{"class":133},"int",[44,592,593],{"class":99},", secret: ",[44,595,584],{"class":133},[44,597,137],{"class":95},[44,599,600],{"class":133}," SECRET",[44,602,603],{"class":99},") -> ",[44,605,606],{"class":133},"str",[44,608,609],{"class":99},":\n",[44,611,612,615,617,620,623,626,628,631,634],{"class":46,"line":226},[44,613,614],{"class":99},"    message ",[44,616,161],{"class":95},[44,618,619],{"class":133}," str",[44,621,622],{"class":99},"(timestamp).encode() ",[44,624,625],{"class":95},"+",[44,627,547],{"class":95},[44,629,630],{"class":54},"\".\"",[44,632,633],{"class":95}," +",[44,635,636],{"class":99}," body\n",[44,638,639,641,644,646],{"class":46,"line":262},[44,640,379],{"class":95},[44,642,643],{"class":54}," \"sha256=\"",[44,645,633],{"class":95},[44,647,648],{"class":99}," hmac.new(secret, message, hashlib.sha256).hexdigest()\n",[44,650,651],{"class":46,"line":295},[44,652,127],{"emptyLinePlaceholder":126},[44,654,655,657,660,662,664,666,668,671,674,677,679,681,683,686,689,691,693,695,697,699,701,703,705],{"class":46,"line":309},[44,656,177],{"class":95},[44,658,659],{"class":180}," verify",[44,661,581],{"class":99},[44,663,584],{"class":133},[44,665,587],{"class":99},[44,667,606],{"class":133},[44,669,670],{"class":95}," |",[44,672,673],{"class":133}," None",[44,675,676],{"class":99},", signature: ",[44,678,606],{"class":133},[44,680,670],{"class":95},[44,682,673],{"class":133},[44,684,685],{"class":99},", now: ",[44,687,688],{"class":133},"float",[44,690,670],{"class":95},[44,692,673],{"class":133},[44,694,137],{"class":95},[44,696,673],{"class":133},[44,698,603],{"class":99},[44,700,606],{"class":133},[44,702,670],{"class":95},[44,704,673],{"class":133},[44,706,609],{"class":99},[44,708,709,712,715,718,721,723],{"class":46,"line":335},[44,710,711],{"class":95},"    if",[44,713,714],{"class":95}," not",[44,716,717],{"class":99}," timestamp ",[44,719,720],{"class":95},"or",[44,722,714],{"class":95},[44,724,725],{"class":99}," signature:\n",[44,727,728,731],{"class":46,"line":376},[44,729,730],{"class":95},"        return",[44,732,733],{"class":54}," \"missing signature headers\"\n",[44,735,737,740],{"class":46,"line":736},15,[44,738,739],{"class":95},"    try",[44,741,609],{"class":99},[44,743,745,748,750,753],{"class":46,"line":744},16,[44,746,747],{"class":99},"        ts ",[44,749,161],{"class":95},[44,751,752],{"class":133}," int",[44,754,755],{"class":99},"(timestamp)\n",[44,757,759,762,765],{"class":46,"line":758},17,[44,760,761],{"class":95},"    except",[44,763,764],{"class":133}," ValueError",[44,766,609],{"class":99},[44,768,770,772],{"class":46,"line":769},18,[44,771,730],{"class":95},[44,773,774],{"class":54}," \"bad timestamp\"\n",[44,776,778,780,783,786,788,791,793,796,799,802],{"class":46,"line":777},19,[44,779,711],{"class":95},[44,781,782],{"class":133}," abs",[44,784,785],{"class":99},"((now ",[44,787,720],{"class":95},[44,789,790],{"class":99}," time.time()) ",[44,792,239],{"class":95},[44,794,795],{"class":99}," ts) ",[44,797,798],{"class":95},">",[44,800,801],{"class":133}," TOLERANCE",[44,803,609],{"class":99},[44,805,807,809],{"class":46,"line":806},20,[44,808,730],{"class":95},[44,810,811],{"class":54}," \"timestamp outside tolerance\"\n",[44,813,815,817,819],{"class":46,"line":814},21,[44,816,711],{"class":95},[44,818,714],{"class":95},[44,820,821],{"class":99}," hmac.compare_digest(sign(body, ts), signature):\n",[44,823,825,827],{"class":46,"line":824},22,[44,826,730],{"class":95},[44,828,829],{"class":54}," \"signature mismatch\"\n",[44,831,833,835],{"class":46,"line":832},23,[44,834,379],{"class":95},[44,836,837],{"class":133}," None\n",[10,839,840,841,844,845,848],{},"Three details matter. The signature is computed over the ",[14,842,843],{},"raw bytes"," of the body, exactly as received — parsing the JSON and re-serialising it changes whitespace or key order and breaks the comparison. ",[41,846,847],{},"hmac.compare_digest"," compares in constant time, so an attacker cannot discover the correct signature byte by byte from response timings. And including a timestamp in the signed message, then rejecting requests more than five minutes old, stops someone who captured a genuine request from replaying it later.",[391,850,400,855,400,858,400,861,400,864,400,872,400,880,400,885,400,889,400,893,400,896,400,901,400,909,400,914,400,921,400,928,400,933,400,938,400,940,400,943,400,947,400,950,400,953,400,959,400,963,400,966,400,968,400,971,400,975,400,978,400,981,400,987,400,991],{"viewBox":851,"role":394,"ariaLabelledBy":852,"xmlns":398,"style":399},"4 0 752 272",[853,854],"wh-verify-t","wh-verify-d",[402,856,857],{"id":853},"Checks before a webhook is accepted",[406,859,860],{"id":854},"A request without signature headers, with a timestamp more than five minutes from now, or with a signature that does not match the HMAC of timestamp and body is rejected with 401. A valid request with an event ID already seen is acknowledged but not processed again. Only a new, valid event starts the report.",[410,862],{"x":412,"y":413,"width":414,"height":863,"fill":416},"272",[410,865],{"x":866,"y":420,"width":867,"height":868,"rx":869,"fill":870,"stroke":871,"style":426},"235.0","290.0","75.5","12","#f0f4ff","var(--brand,#5b5cf0)",[428,873,875],{"transform":874},"translate(368.0,29.0) scale(1.000)",[432,876],{"d":877,"fill":435,"stroke":878,"style":879},"M8 7l-5 5 5 5 M16 7l5 5-5 5 M14 4l-4 16","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[439,881,884],{"x":882,"y":868,"style":883},"380.0","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Incoming POST \u002Fwebhooks\u002Fclose",[46,886],{"x1":882,"y1":887,"x2":882,"y2":888,"stroke":871,"style":426},"91.5","111.5",[46,890],{"x1":891,"y1":888,"x2":892,"y2":888,"stroke":871,"style":426},"134.7","625.3",[46,894],{"x1":891,"y1":888,"x2":891,"y2":895,"stroke":871,"style":426},"157.5",[897,898],"polygon",{"points":899,"fill":900},"134.7,161.5 129.7,152.5 139.7,152.5","#5b5cf0",[410,902],{"x":903,"y":904,"width":905,"height":906,"rx":907,"fill":416,"stroke":425,"style":908},"52.8","121.5","163.6","21.0","10","stroke-width:1.2px",[439,910,913],{"x":891,"y":911,"style":912},"136.0","font-size:11.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","bad or missing signature",[410,915],{"x":419,"y":916,"width":917,"height":918,"rx":869,"fill":919,"stroke":920,"style":426},"163.5","229.3","93.0","#fdefd8","var(--gold,#b4740a)",[428,922,924],{"transform":923},"translate(122.7,176.5) scale(1.000)",[432,925],{"d":926,"fill":435,"stroke":927,"style":879},"M6 11h12v9H6z M8.5 11V8a3.5 3.5 0 0 1 7 0v3","var(--gold-ink,#7a4e06)",[439,929,932],{"x":891,"y":930,"style":931},"223.0","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","401 rejected",[439,934,937],{"x":891,"y":935,"style":936},"242.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","no work done",[46,939],{"x1":882,"y1":888,"x2":882,"y2":895,"stroke":871,"style":426},[897,941],{"points":942,"fill":900},"380.0,161.5 375.0,152.5 385.0,152.5",[410,944],{"x":945,"y":904,"width":946,"height":906,"rx":907,"fill":416,"stroke":425,"style":908},"331.6","96.9",[439,948,949],{"x":882,"y":911,"style":912},"seen event id",[410,951],{"x":952,"y":916,"width":917,"height":918,"rx":869,"fill":424,"stroke":425,"style":426},"265.3",[428,954,956],{"transform":955},"translate(368.0,176.5) scale(1.000)",[432,957],{"d":958,"fill":435,"stroke":436,"style":879},"M20 11a8 8 0 1 0-2.3 5.7 M20 4v7h-7",[439,960,962],{"x":882,"y":930,"style":961},"font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","200, duplicate",[439,964,965],{"x":882,"y":935,"style":936},"ignored",[46,967],{"x1":892,"y1":888,"x2":892,"y2":895,"stroke":871,"style":426},[897,969],{"points":970,"fill":900},"625.3,161.5 620.3,152.5 630.3,152.5",[410,972],{"x":973,"y":904,"width":974,"height":906,"rx":907,"fill":416,"stroke":425,"style":908},"586.2","78.3",[439,976,977],{"x":892,"y":911,"style":912},"valid, new",[410,979],{"x":980,"y":916,"width":917,"height":918,"rx":869,"fill":471,"stroke":472,"style":426},"510.7",[428,982,984],{"transform":983},"translate(613.3,176.5) scale(1.000)",[432,985],{"d":986,"fill":435,"stroke":479,"style":879},"M5 12.5l4.5 4.5L19 7",[439,988,990],{"x":892,"y":930,"style":989},"font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","202 accepted",[439,992,993],{"x":892,"y":935,"style":936},"report in background",[29,995,997],{"id":996},"step-3-the-endpoint-verify-deduplicate-accept-work-later","Step 3 — The endpoint: verify, deduplicate, accept, work later",[34,999,1001],{"className":86,"code":1000,"language":88,"meta":39,"style":39},"from fastapi import BackgroundTasks, FastAPI, Header, Request\nfrom fastapi.responses import JSONResponse\n\napp = FastAPI()\nseen_events: set[str] = set()                     # use a database table in production\nbuilt: list[str] = []\n\ndef run_report(period: str):\n    built.append(build_close_report(period).name)\n\n@app.post(\"\u002Fwebhooks\u002Fclose\")\nasync def period_closed(request: Request, background: BackgroundTasks,\n                        x_webhook_timestamp: str | None = Header(None), x_webhook_signature: str | None = Header(None)):\n    body = await request.body()\n    problem = verify(body, x_webhook_timestamp, x_webhook_signature)\n    if problem:\n        return JSONResponse({\"error\": problem}, status_code=401)\n    event = await request.json()\n    if event[\"id\"] in seen_events:\n        return JSONResponse({\"status\": \"duplicate\", \"id\": event[\"id\"]}, status_code=200)\n    seen_events.add(event[\"id\"])\n    if event.get(\"type\") != \"period.closed\":\n        return JSONResponse({\"status\": \"ignored\", \"type\": event.get(\"type\")}, status_code=200)\n    background.add_task(run_report, event[\"data\"][\"period\"])\n    return JSONResponse({\"status\": \"accepted\", \"id\": event[\"id\"]}, status_code=202)\n",[41,1002,1003,1015,1027,1031,1041,1062,1076,1080,1095,1100,1104,1117,1131,1168,1181,1191,1198,1221,1233,1251,1287,1297,1318,1351,1367],{"__ignoreMap":39},[44,1004,1005,1007,1010,1012],{"class":46,"line":47},[44,1006,96],{"class":95},[44,1008,1009],{"class":99}," fastapi ",[44,1011,103],{"class":95},[44,1013,1014],{"class":99}," BackgroundTasks, FastAPI, Header, Request\n",[44,1016,1017,1019,1022,1024],{"class":46,"line":109},[44,1018,96],{"class":95},[44,1020,1021],{"class":99}," fastapi.responses ",[44,1023,103],{"class":95},[44,1025,1026],{"class":99}," JSONResponse\n",[44,1028,1029],{"class":46,"line":123},[44,1030,127],{"emptyLinePlaceholder":126},[44,1032,1033,1036,1038],{"class":46,"line":130},[44,1034,1035],{"class":99},"app ",[44,1037,161],{"class":95},[44,1039,1040],{"class":99}," FastAPI()\n",[44,1042,1043,1046,1048,1051,1053,1056,1059],{"class":46,"line":149},[44,1044,1045],{"class":99},"seen_events: set[",[44,1047,606],{"class":133},[44,1049,1050],{"class":99},"] ",[44,1052,161],{"class":95},[44,1054,1055],{"class":133}," set",[44,1057,1058],{"class":99},"()                     ",[44,1060,1061],{"class":553},"# use a database table in production\n",[44,1063,1064,1067,1069,1071,1073],{"class":46,"line":169},[44,1065,1066],{"class":99},"built: list[",[44,1068,606],{"class":133},[44,1070,1050],{"class":99},[44,1072,161],{"class":95},[44,1074,1075],{"class":99}," []\n",[44,1077,1078],{"class":46,"line":174},[44,1079,127],{"emptyLinePlaceholder":126},[44,1081,1082,1084,1087,1090,1092],{"class":46,"line":187},[44,1083,177],{"class":95},[44,1085,1086],{"class":180}," run_report",[44,1088,1089],{"class":99},"(period: ",[44,1091,606],{"class":133},[44,1093,1094],{"class":99},"):\n",[44,1096,1097],{"class":46,"line":226},[44,1098,1099],{"class":99},"    built.append(build_close_report(period).name)\n",[44,1101,1102],{"class":46,"line":262},[44,1103,127],{"emptyLinePlaceholder":126},[44,1105,1106,1109,1112,1115],{"class":46,"line":295},[44,1107,1108],{"class":180},"@app.post",[44,1110,1111],{"class":99},"(",[44,1113,1114],{"class":54},"\"\u002Fwebhooks\u002Fclose\"",[44,1116,146],{"class":99},[44,1118,1119,1122,1125,1128],{"class":46,"line":309},[44,1120,1121],{"class":95},"async",[44,1123,1124],{"class":95}," def",[44,1126,1127],{"class":180}," period_closed",[44,1129,1130],{"class":99},"(request: Request, background: BackgroundTasks,\n",[44,1132,1133,1136,1138,1140,1142,1144,1147,1150,1153,1155,1157,1159,1161,1163,1165],{"class":46,"line":335},[44,1134,1135],{"class":99},"                        x_webhook_timestamp: ",[44,1137,606],{"class":133},[44,1139,670],{"class":95},[44,1141,673],{"class":133},[44,1143,137],{"class":95},[44,1145,1146],{"class":99}," Header(",[44,1148,1149],{"class":133},"None",[44,1151,1152],{"class":99},"), x_webhook_signature: ",[44,1154,606],{"class":133},[44,1156,670],{"class":95},[44,1158,673],{"class":133},[44,1160,137],{"class":95},[44,1162,1146],{"class":99},[44,1164,1149],{"class":133},[44,1166,1167],{"class":99},")):\n",[44,1169,1170,1173,1175,1178],{"class":46,"line":376},[44,1171,1172],{"class":99},"    body ",[44,1174,161],{"class":95},[44,1176,1177],{"class":95}," await",[44,1179,1180],{"class":99}," request.body()\n",[44,1182,1183,1186,1188],{"class":46,"line":736},[44,1184,1185],{"class":99},"    problem ",[44,1187,161],{"class":95},[44,1189,1190],{"class":99}," verify(body, x_webhook_timestamp, x_webhook_signature)\n",[44,1192,1193,1195],{"class":46,"line":744},[44,1194,711],{"class":95},[44,1196,1197],{"class":99}," problem:\n",[44,1199,1200,1202,1205,1208,1211,1214,1216,1219],{"class":46,"line":758},[44,1201,730],{"class":95},[44,1203,1204],{"class":99}," JSONResponse({",[44,1206,1207],{"class":54},"\"error\"",[44,1209,1210],{"class":99},": problem}, ",[44,1212,1213],{"class":157},"status_code",[44,1215,161],{"class":95},[44,1217,1218],{"class":133},"401",[44,1220,146],{"class":99},[44,1222,1223,1226,1228,1230],{"class":46,"line":769},[44,1224,1225],{"class":99},"    event ",[44,1227,161],{"class":95},[44,1229,1177],{"class":95},[44,1231,1232],{"class":99}," request.json()\n",[44,1234,1235,1237,1240,1243,1245,1248],{"class":46,"line":777},[44,1236,711],{"class":95},[44,1238,1239],{"class":99}," event[",[44,1241,1242],{"class":54},"\"id\"",[44,1244,1050],{"class":99},[44,1246,1247],{"class":95},"in",[44,1249,1250],{"class":99}," seen_events:\n",[44,1252,1253,1255,1257,1260,1263,1266,1268,1270,1273,1275,1278,1280,1282,1285],{"class":46,"line":806},[44,1254,730],{"class":95},[44,1256,1204],{"class":99},[44,1258,1259],{"class":54},"\"status\"",[44,1261,1262],{"class":99},": ",[44,1264,1265],{"class":54},"\"duplicate\"",[44,1267,207],{"class":99},[44,1269,1242],{"class":54},[44,1271,1272],{"class":99},": event[",[44,1274,1242],{"class":54},[44,1276,1277],{"class":99},"]}, ",[44,1279,1213],{"class":157},[44,1281,161],{"class":95},[44,1283,1284],{"class":133},"200",[44,1286,146],{"class":99},[44,1288,1289,1292,1294],{"class":46,"line":814},[44,1290,1291],{"class":99},"    seen_events.add(event[",[44,1293,1242],{"class":54},[44,1295,1296],{"class":99},"])\n",[44,1298,1299,1301,1304,1307,1310,1313,1316],{"class":46,"line":824},[44,1300,711],{"class":95},[44,1302,1303],{"class":99}," event.get(",[44,1305,1306],{"class":54},"\"type\"",[44,1308,1309],{"class":99},") ",[44,1311,1312],{"class":95},"!=",[44,1314,1315],{"class":54}," \"period.closed\"",[44,1317,609],{"class":99},[44,1319,1320,1322,1324,1326,1328,1331,1333,1335,1338,1340,1343,1345,1347,1349],{"class":46,"line":832},[44,1321,730],{"class":95},[44,1323,1204],{"class":99},[44,1325,1259],{"class":54},[44,1327,1262],{"class":99},[44,1329,1330],{"class":54},"\"ignored\"",[44,1332,207],{"class":99},[44,1334,1306],{"class":54},[44,1336,1337],{"class":99},": event.get(",[44,1339,1306],{"class":54},[44,1341,1342],{"class":99},")}, ",[44,1344,1213],{"class":157},[44,1346,161],{"class":95},[44,1348,1284],{"class":133},[44,1350,146],{"class":99},[44,1352,1354,1357,1360,1363,1365],{"class":46,"line":1353},24,[44,1355,1356],{"class":99},"    background.add_task(run_report, event[",[44,1358,1359],{"class":54},"\"data\"",[44,1361,1362],{"class":99},"][",[44,1364,341],{"class":54},[44,1366,1296],{"class":99},[44,1368,1370,1372,1374,1376,1378,1381,1383,1385,1387,1389,1391,1393,1395,1398],{"class":46,"line":1369},25,[44,1371,379],{"class":95},[44,1373,1204],{"class":99},[44,1375,1259],{"class":54},[44,1377,1262],{"class":99},[44,1379,1380],{"class":54},"\"accepted\"",[44,1382,207],{"class":99},[44,1384,1242],{"class":54},[44,1386,1272],{"class":99},[44,1388,1242],{"class":54},[44,1390,1277],{"class":99},[44,1392,1213],{"class":157},[44,1394,161],{"class":95},[44,1396,1397],{"class":133},"202",[44,1399,146],{"class":99},[10,1401,1402,1403,1406,1407,1410,1411,1414],{},"The endpoint reads the raw body first, verifies it, and only then parses the JSON. Each event carries an ID, and webhook senders deliver ",[14,1404,1405],{},"at least once",": if your response is slow or lost, they retry, so the same event can arrive two or three times. Recording IDs and acknowledging repeats without acting on them prevents duplicate reports; in production the set must be a database table (with a unique constraint) so it survives restarts and works across several server processes. Events of other types are acknowledged and ignored, so the sender does not keep retrying them. Finally the report is handed to ",[41,1408,1409],{},"BackgroundTasks"," and the endpoint returns ",[41,1412,1413],{},"202 Accepted"," immediately — most senders wait only a few seconds for a response before treating the delivery as failed and retrying it.",[391,1416,400,1422,400,1425,400,1428,400,1432,400,1436,400,1442,400,1444,400,1448,400,1451,400,1455,400,1460,400,1462,400,1464,400,1469,400,1472,400,1477,400,1483,400,1488,400,1491,400,1496,400,1500,400,1504,400,1507,400,1512,400,1517,400,1522,400,1525,400,1530,400,1535,400,1540,400,1543,400,1548],{"viewBox":1417,"role":394,"ariaLabelledBy":1418,"xmlns":398,"style":1421},"4 0 758 340",[1419,1420],"wh-seq-t","wh-seq-d","width:100%;max-width:758px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[402,1423,1424],{"id":1419},"One delivery of a period-closed event",[406,1426,1427],{"id":1420},"The accounting system posts a signed event. The endpoint verifies the signature and timestamp, records the event ID, schedules the report as a background task and returns 202 within milliseconds. The report is then built and saved after the response has been sent.",[410,1429],{"x":412,"y":413,"width":1430,"height":1431,"fill":416},"758","340",[410,1433],{"x":419,"y":420,"width":1434,"height":1435,"rx":869,"fill":870,"stroke":871,"style":426},"180.0","48.0",[439,1437,1441],{"x":1438,"y":1439,"style":1440},"110.0","44.5","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Accounting system",[410,1443],{"x":867,"y":420,"width":1434,"height":1435,"rx":869,"fill":424,"stroke":425,"style":426},[439,1445,1447],{"x":882,"y":1439,"style":1446},"font-size:13px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Webhook endpoint",[410,1449],{"x":1450,"y":420,"width":1434,"height":1435,"rx":869,"fill":424,"stroke":425,"style":426},"560.0",[439,1452,1454],{"x":1453,"y":1439,"style":1446},"650.0","Report task",[46,1456],{"x1":1438,"y1":1457,"x2":1438,"y2":1458,"stroke":425,"style":1459},"64.0","324.0","stroke-width:1.5px;stroke-dasharray:5 4",[46,1461],{"x1":882,"y1":1457,"x2":882,"y2":1458,"stroke":425,"style":1459},[46,1463],{"x1":1453,"y1":1457,"x2":1453,"y2":1458,"stroke":425,"style":1459},[46,1465],{"x1":1466,"y1":1467,"x2":1468,"y2":1467,"stroke":871,"style":426},"115.0","104.0","371.0",[897,1470],{"points":1471,"fill":900},"375.0,104.0 366.0,109.0 366.0,99.0",[410,1473],{"x":1474,"y":1475,"width":1476,"height":906,"rx":907,"fill":416,"stroke":425,"style":908},"155.4","75.0","179.2",[439,1478,1482],{"x":1479,"y":1480,"style":1481},"245.0","89.5","font-size:11.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","1. POST, signed, id=evt_101",[46,1484],{"x1":1485,"y1":1486,"x2":1487,"y2":1486,"stroke":871,"style":426},"375.0","152.0","381.0",[897,1489],{"points":1490,"fill":900},"385.0,152.0 376.0,157.0 376.0,147.0",[410,1492],{"x":1493,"y":1494,"width":1495,"height":906,"rx":907,"fill":416,"stroke":425,"style":908},"316.5","123.0","127.0",[439,1497,1499],{"x":882,"y":1498,"style":1481},"137.5","2. verify, record id",[46,1501],{"x1":1485,"y1":1502,"x2":1503,"y2":1502,"stroke":871,"style":426},"200.0","119.0",[897,1505],{"points":1506,"fill":900},"115.0,200.0 124.0,195.0 124.0,205.0",[410,1508],{"x":1509,"y":1510,"width":1511,"height":906,"rx":907,"fill":471,"stroke":472,"style":908},"186.9","171.0","116.2",[439,1513,1516],{"x":1479,"y":1514,"style":1515},"185.5","font-size:11.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","3. 202 Accepted",[46,1518],{"x1":1519,"y1":1520,"x2":1521,"y2":1520,"stroke":871,"style":426},"385.0","248.0","641.0",[897,1523],{"points":1524,"fill":900},"645.0,248.0 636.0,253.0 636.0,243.0",[410,1526],{"x":1527,"y":1528,"width":1529,"height":906,"rx":907,"fill":416,"stroke":425,"style":908},"456.0","219.0","118.0",[439,1531,1534],{"x":1532,"y":1533,"style":1481},"515.0","233.5","4. build 2026-09",[46,1536],{"x1":1537,"y1":1538,"x2":1539,"y2":1538,"stroke":871,"style":426},"645.0","296.0","651.0",[897,1541],{"points":1542,"fill":900},"655.0,296.0 646.0,301.0 646.0,291.0",[410,1544],{"x":1545,"y":1546,"width":1547,"height":906,"rx":907,"fill":870,"stroke":871,"style":908},"553.5","267.0","193.0",[439,1549,1552],{"x":1453,"y":1550,"style":1551},"281.5","font-size:11.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","5. close_report_2026-09.xlsx",[29,1554,1556],{"id":1555},"step-4-test-it-end-to-end","Step 4 — Test it end to end",[10,1558,1559,1560,1563],{},"FastAPI's ",[41,1561,1562],{},"TestClient"," sends real HTTP requests to the app in-process, background tasks included, so the whole flow can be tested without a server:",[34,1565,1567],{"className":86,"code":1566,"language":88,"meta":39,"style":39},"import json\nfrom fastapi.testclient import TestClient\n\nclient = TestClient(app)\n\ndef deliver(event, secret=SECRET, timestamp=None):\n    body = json.dumps(event).encode()\n    ts = timestamp or int(time.time())\n    headers = {\"X-Webhook-Timestamp\": str(ts), \"X-Webhook-Signature\": sign(body, ts, secret), \"Content-Type\": \"application\u002Fjson\"}\n    r = client.post(\"\u002Fwebhooks\u002Fclose\", content=body, headers=headers)\n    return r.status_code, r.json()\n\nevent = {\"id\": \"evt_101\", \"type\": \"period.closed\", \"data\": {\"period\": \"2026-09\"}}\nprint(\"genuine:      \", deliver(event))\nprint(\"redelivered:  \", deliver(event))\nprint(\"wrong secret: \", deliver({**event, \"id\": \"evt_102\"}, secret=b\"guess\"))\nprint(\"replayed old: \", deliver({**event, \"id\": \"evt_103\"}, timestamp=int(time.time()) - 3600))\nprint(\"other type:   \", deliver({\"id\": \"evt_104\", \"type\": \"invoice.paid\", \"data\": {}}))\nprint(\"reports built:\", built)\n",[41,1568,1569,1576,1588,1592,1602,1606,1629,1638,1654,1691,1721,1728,1732,1774,1787,1798,1840,1881,1915],{"__ignoreMap":39},[44,1570,1571,1573],{"class":46,"line":47},[44,1572,103],{"class":95},[44,1574,1575],{"class":99}," json\n",[44,1577,1578,1580,1583,1585],{"class":46,"line":109},[44,1579,96],{"class":95},[44,1581,1582],{"class":99}," fastapi.testclient ",[44,1584,103],{"class":95},[44,1586,1587],{"class":99}," TestClient\n",[44,1589,1590],{"class":46,"line":123},[44,1591,127],{"emptyLinePlaceholder":126},[44,1593,1594,1597,1599],{"class":46,"line":130},[44,1595,1596],{"class":99},"client ",[44,1598,161],{"class":95},[44,1600,1601],{"class":99}," TestClient(app)\n",[44,1603,1604],{"class":46,"line":149},[44,1605,127],{"emptyLinePlaceholder":126},[44,1607,1608,1610,1613,1616,1618,1620,1623,1625,1627],{"class":46,"line":169},[44,1609,177],{"class":95},[44,1611,1612],{"class":180}," deliver",[44,1614,1615],{"class":99},"(event, secret",[44,1617,161],{"class":95},[44,1619,542],{"class":133},[44,1621,1622],{"class":99},", timestamp",[44,1624,161],{"class":95},[44,1626,1149],{"class":133},[44,1628,1094],{"class":99},[44,1630,1631,1633,1635],{"class":46,"line":174},[44,1632,1172],{"class":99},[44,1634,161],{"class":95},[44,1636,1637],{"class":99}," json.dumps(event).encode()\n",[44,1639,1640,1643,1645,1647,1649,1651],{"class":46,"line":187},[44,1641,1642],{"class":99},"    ts ",[44,1644,161],{"class":95},[44,1646,717],{"class":99},[44,1648,720],{"class":95},[44,1650,752],{"class":133},[44,1652,1653],{"class":99},"(time.time())\n",[44,1655,1656,1659,1661,1664,1667,1669,1671,1674,1677,1680,1683,1685,1688],{"class":46,"line":226},[44,1657,1658],{"class":99},"    headers ",[44,1660,161],{"class":95},[44,1662,1663],{"class":99}," {",[44,1665,1666],{"class":54},"\"X-Webhook-Timestamp\"",[44,1668,1262],{"class":99},[44,1670,606],{"class":133},[44,1672,1673],{"class":99},"(ts), ",[44,1675,1676],{"class":54},"\"X-Webhook-Signature\"",[44,1678,1679],{"class":99},": sign(body, ts, secret), ",[44,1681,1682],{"class":54},"\"Content-Type\"",[44,1684,1262],{"class":99},[44,1686,1687],{"class":54},"\"application\u002Fjson\"",[44,1689,1690],{"class":99},"}\n",[44,1692,1693,1696,1698,1701,1703,1705,1708,1710,1713,1716,1718],{"class":46,"line":262},[44,1694,1695],{"class":99},"    r ",[44,1697,161],{"class":95},[44,1699,1700],{"class":99}," client.post(",[44,1702,1114],{"class":54},[44,1704,207],{"class":99},[44,1706,1707],{"class":157},"content",[44,1709,161],{"class":95},[44,1711,1712],{"class":99},"body, ",[44,1714,1715],{"class":157},"headers",[44,1717,161],{"class":95},[44,1719,1720],{"class":99},"headers)\n",[44,1722,1723,1725],{"class":46,"line":295},[44,1724,379],{"class":95},[44,1726,1727],{"class":99}," r.status_code, r.json()\n",[44,1729,1730],{"class":46,"line":309},[44,1731,127],{"emptyLinePlaceholder":126},[44,1733,1734,1737,1739,1741,1743,1745,1748,1750,1752,1754,1757,1759,1761,1764,1766,1768,1771],{"class":46,"line":335},[44,1735,1736],{"class":99},"event ",[44,1738,161],{"class":95},[44,1740,1663],{"class":99},[44,1742,1242],{"class":54},[44,1744,1262],{"class":99},[44,1746,1747],{"class":54},"\"evt_101\"",[44,1749,207],{"class":99},[44,1751,1306],{"class":54},[44,1753,1262],{"class":99},[44,1755,1756],{"class":54},"\"period.closed\"",[44,1758,207],{"class":99},[44,1760,1359],{"class":54},[44,1762,1763],{"class":99},": {",[44,1765,341],{"class":54},[44,1767,1262],{"class":99},[44,1769,1770],{"class":54},"\"2026-09\"",[44,1772,1773],{"class":99},"}}\n",[44,1775,1776,1779,1781,1784],{"class":46,"line":376},[44,1777,1778],{"class":133},"print",[44,1780,1111],{"class":99},[44,1782,1783],{"class":54},"\"genuine:      \"",[44,1785,1786],{"class":99},", deliver(event))\n",[44,1788,1789,1791,1793,1796],{"class":46,"line":736},[44,1790,1778],{"class":133},[44,1792,1111],{"class":99},[44,1794,1795],{"class":54},"\"redelivered:  \"",[44,1797,1786],{"class":99},[44,1799,1800,1802,1804,1807,1810,1813,1816,1818,1820,1823,1826,1829,1831,1834,1837],{"class":46,"line":744},[44,1801,1778],{"class":133},[44,1803,1111],{"class":99},[44,1805,1806],{"class":54},"\"wrong secret: \"",[44,1808,1809],{"class":99},", deliver({",[44,1811,1812],{"class":95},"**",[44,1814,1815],{"class":99},"event, ",[44,1817,1242],{"class":54},[44,1819,1262],{"class":99},[44,1821,1822],{"class":54},"\"evt_102\"",[44,1824,1825],{"class":99},"}, ",[44,1827,1828],{"class":157},"secret",[44,1830,161],{"class":95},[44,1832,1833],{"class":95},"b",[44,1835,1836],{"class":54},"\"guess\"",[44,1838,1839],{"class":99},"))\n",[44,1841,1842,1844,1846,1849,1851,1853,1855,1857,1859,1862,1864,1867,1869,1871,1874,1876,1879],{"class":46,"line":758},[44,1843,1778],{"class":133},[44,1845,1111],{"class":99},[44,1847,1848],{"class":54},"\"replayed old: \"",[44,1850,1809],{"class":99},[44,1852,1812],{"class":95},[44,1854,1815],{"class":99},[44,1856,1242],{"class":54},[44,1858,1262],{"class":99},[44,1860,1861],{"class":54},"\"evt_103\"",[44,1863,1825],{"class":99},[44,1865,1866],{"class":157},"timestamp",[44,1868,161],{"class":95},[44,1870,590],{"class":133},[44,1872,1873],{"class":99},"(time.time()) ",[44,1875,239],{"class":95},[44,1877,1878],{"class":133}," 3600",[44,1880,1839],{"class":99},[44,1882,1883,1885,1887,1890,1892,1894,1896,1899,1901,1903,1905,1908,1910,1912],{"class":46,"line":769},[44,1884,1778],{"class":133},[44,1886,1111],{"class":99},[44,1888,1889],{"class":54},"\"other type:   \"",[44,1891,1809],{"class":99},[44,1893,1242],{"class":54},[44,1895,1262],{"class":99},[44,1897,1898],{"class":54},"\"evt_104\"",[44,1900,207],{"class":99},[44,1902,1306],{"class":54},[44,1904,1262],{"class":99},[44,1906,1907],{"class":54},"\"invoice.paid\"",[44,1909,207],{"class":99},[44,1911,1359],{"class":54},[44,1913,1914],{"class":99},": {}}))\n",[44,1916,1917,1919,1921,1924],{"class":46,"line":777},[44,1918,1778],{"class":133},[44,1920,1111],{"class":99},[44,1922,1923],{"class":54},"\"reports built:\"",[44,1925,1926],{"class":99},", built)\n",[10,1928,1929,1930,1934],{},"The genuine event is accepted and the report is built once; the redelivery of the same event is acknowledged as a duplicate and builds nothing; a request signed with the wrong secret and a replay of an hour-old request are both rejected with 401; an event of another type is acknowledged and ignored. The final line shows a single report file — exactly one, despite five deliveries. These assertions belong in your test suite, with a fixed secret for tests and the real one read from the environment in production; ",[23,1931,1933],{"href":1932},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-credentials-for-excel-report-scripts-safely\u002F","Store Credentials for Excel Report Scripts Safely"," covers the latter.",[29,1936,1938],{"id":1937},"running-it-for-real","Running it for real",[10,1940,1941,1942,1945,1946,1948,1949,1953],{},"Run the app with ",[41,1943,1944],{},"uvicorn webhook_app:app --host 0.0.0.0 --port 8000"," behind HTTPS — webhook senders require it, and the signature protects authenticity but not confidentiality. ",[41,1947,1409],{}," runs the report in the same process after the response; that is fine for reports that take seconds to a minute. For longer reports, or if losing an in-progress report on a restart is unacceptable, have the endpoint write the event to a queue or database table and let a separate worker build reports, as described in ",[23,1950,1952],{"href":1951},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fgenerate-large-excel-exports-in-a-background-job\u002F","Generate Large Excel Exports in a Background Job",". Log every delivery with its event ID and outcome, and keep a scheduled fallback — a nightly job that builds any closed period without a report — so a webhook the sender never delivered does not mean a report nobody receives.",[29,1955,1957],{"id":1956},"common-pitfalls","Common pitfalls",[1959,1960,1961,1969,1981,1987,1993],"ul",{},[1962,1963,1964,1968],"li",{},[1965,1966,1967],"strong",{},"Verifying parsed JSON."," Sign and verify the raw request body.",[1962,1970,1971,1978,1979,27],{},[1965,1972,1973,1974,1977],{},"Plain ",[41,1975,1976],{},"=="," on signatures."," Use ",[41,1980,847],{},[1962,1982,1983,1986],{},[1965,1984,1985],{},"No timestamp check."," Captured requests can be replayed later.",[1962,1988,1989,1992],{},[1965,1990,1991],{},"Building the report before responding."," The sender times out and retries; return 202 and work in the background.",[1962,1994,1995,1998],{},[1965,1996,1997],{},"Assuming one delivery per event."," Record event IDs and ignore repeats.",[29,2000,2002],{"id":2001},"conclusion","Conclusion",[10,2004,2005,2006,2008,2009,2011],{},"To build an Excel report when an event happens, expose a webhook endpoint that verifies an HMAC-SHA256 signature over the timestamp and raw body with ",[41,2007,847],{},", rejects stale timestamps, records event IDs so redeliveries are ignored, and returns ",[41,2010,1413],{}," at once while the report is built in the background. Test the genuine, duplicate, forged and replayed cases with FastAPI's test client, and keep a scheduled fallback for events that never arrive.",[29,2013,2015],{"id":2014},"frequently-asked-questions","Frequently asked questions",[10,2017,2018,2021],{},[1965,2019,2020],{},"How do I verify a webhook signature in Python?","\nRecompute an HMAC-SHA256 over the exact raw request body (and timestamp, if the provider signs it) with the shared secret, and compare it with the header value using hmac.compare_digest.",[10,2023,2024,2027],{},[1965,2025,2026],{},"Why does my webhook get delivered twice?","\nSenders retry when a response is slow or lost, so delivery is at least once; record each event ID and acknowledge repeats without processing them again.",[10,2029,2030,2033],{},[1965,2031,2032],{},"Should a webhook endpoint build the report before responding?","\nNo. Return 202 Accepted quickly and build the report in a background task or worker, because senders time out after a few seconds and retry.",[10,2035,2036,2039],{},[1965,2037,2038],{},"How do I test a FastAPI webhook without deploying it?","\nUse fastapi.testclient.TestClient to post signed requests to the app in-process; background tasks run as part of the request.",[29,2041,2043],{"id":2042},"related","Related",[1959,2045,2046,2052,2059,2066],{},[1962,2047,2048,2049,2051],{},"Up one level: ",[23,2050,26],{"href":25}," — running reports automatically.",[1962,2053,2054,2058],{},[23,2055,2057],{"href":2056},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Frun-a-report-when-an-excel-file-changes-in-onedrive\u002F","Run a Report When an Excel File Changes in OneDrive"," — Microsoft Graph change notifications.",[1962,2060,2061,2065],{},[23,2062,2064],{"href":2063},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fwatch-a-folder-and-process-new-excel-files\u002F","Watch a Folder and Process New Excel Files with Python"," — reacting to files instead of events.",[1962,2067,2068,2072],{},[23,2069,2071],{"href":2070},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fstream-an-excel-file-from-a-fastapi-endpoint\u002F","Stream an Excel File from a FastAPI Endpoint"," — FastAPI for downloads.",[2074,2075,2076],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":39,"searchDepth":109,"depth":109,"links":2078},[2079,2080,2081,2082,2083,2084,2085,2086,2087,2088],{"id":31,"depth":109,"text":32},{"id":79,"depth":109,"text":80},{"id":503,"depth":109,"text":504},{"id":996,"depth":109,"text":997},{"id":1555,"depth":109,"text":1556},{"id":1937,"depth":109,"text":1938},{"id":1956,"depth":109,"text":1957},{"id":2001,"depth":109,"text":2002},{"id":2014,"depth":109,"text":2015},{"id":2042,"depth":109,"text":2043},"2026-10-02","Build an Excel report the moment something happens instead of on a timer: a small FastAPI endpoint that receives a webhook, verifies its HMAC signature and timestamp, ignores duplicate deliveries, answers within milliseconds with 202 Accepted, and builds the workbook in the background — tested end to end with FastAPI's test client.","md",[2093,2095,2097,2099],{"q":2020,"a":2094},"Recompute an HMAC-SHA256 over the exact raw request body (and timestamp, if the provider signs it) with the shared secret, and compare it with the header value using hmac.compare_digest.",{"q":2026,"a":2096},"Senders retry when a response is slow or lost, so delivery is at least once; record each event ID and acknowledge repeats without processing them again.",{"q":2032,"a":2098},"No. Return 202 Accepted quickly and build the report in a background task or worker, because senders time out after a few seconds and retry.",{"q":2038,"a":2100},"Use fastapi.testclient.TestClient to post signed requests to the app in-process; background tasks run as part of the request.","trigger an excel report from a webhook",null,{"breadcrumb":2104},[2105,2108,2111],{"name":2106,"item":2107},"Home","\u002F",{"name":2109,"item":2110},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":26,"item":25},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook",{"title":2114,"description":2115},"Trigger an Excel Report from a Webhook with Python","Event-driven Excel reports with FastAPI: receiving webhooks, HMAC-SHA256 signature and timestamp checks, idempotent handling of redelivered events, 202 responses with background work, and tests with TestClient.","trigger-an-excel-report-from-a-webhook","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook\u002Findex",[2119,2120],"scheduling","web-apps","how-to","WEavLTebgb2sSv6Ze593CAtbhQ5m7hVgYKjbob3XjIQ",[2124,2128],{"title":2125,"path":2126,"stem":2127,"children":-1},"Schedule Reports Correctly Across Time Zones and DST","\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst\u002Findex",{"title":2129,"path":2130,"stem":2131,"children":-1},"Wait for Upstream Data Before Running a Report","\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fwait-for-upstream-data-before-running-a-report","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fwait-for-upstream-data-before-running-a-report\u002Findex",[2133,2135],{"path":2134,"title":2109},"\u002Fautomating-reporting-workflows",{"path":2136,"title":26},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron",[],[2139,2142,2145,2148,2150,2153],{"path":2140,"title":2141,"kind":2121},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fdownload-new-excel-files-from-an-sftp-server","Download New Excel Files from an SFTP Server",{"path":2143,"title":2144,"kind":2121},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fread-excel-attachments-into-a-database-nightly","Read Excel Attachments into a Database Nightly",{"path":2146,"title":2147,"kind":2121},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Frefresh-an-excel-report-from-a-database-on-a-schedule","Refresh an Excel Report from a Database on a Schedule",{"path":2149,"title":2057,"kind":2121},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Frun-a-report-when-an-excel-file-changes-in-onedrive",{"path":2151,"title":2152,"kind":2121},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time","Send Report Emails at Each Recipient's Local Time",{"path":2154,"title":2155,"kind":2121},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run","Detect a Scheduled Report That Did Not Run",1790972140200]