TRENDING
Rows of identical brass-colored apartment mailboxes with small locks and name labels along an orange corridor wall
October 9, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
Street-level upward view of the Monetary Authority of Singapore building and neighbouring office towers under a pale sky
October 9, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
Cast-iron late Qing dynasty coin minting press with a large flywheel, displayed in a museum case
October 9, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google
Rows of closed oak library card catalog drawers, each with a brass pull and a blank label holder
October 9, 2026
How to Encrypt PII in Python and Keep It Searchable With Blind Indexes
Close-up of a vintage Western Electric manual telephone switchboard with orange lamps, red patch cords plugged into jacks, a rotary dial and a black handset
October 9, 2026
Microsoft’s Agent Lightning v1.0 Turns Agent Training Into a Sample-Accounting Problem
09 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two orange safety relief valves on grey pressure vessels in an industrial plant
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
Yellow diamond-shaped merging traffic warning sign showing a side road joining a main road
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A lugworm lying on wet sand and mud at low tide
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 232 Posts
News 234 Posts
Learning Hub 204 Posts
Home/Learning Hub/How to Verify Webhook Signatures With HMAC and Stop Replay Attacks in Python
Learning Hub

How to Verify Webhook Signatures With HMAC and Stop Replay Attacks in Python

A step-by-step tutorial on verifying webhook signatures with HMAC in Python, fixing the raw-body gotcha, and blocking replay attacks.

August 3, 2026 15 Min Read
86

A webhook is just an HTTP POST request that another service sends to a URL you control whenever something happens, a payment clears, a repository gets pushed to, a support ticket gets closed. The problem is that your endpoint has no built-in way to tell a real event from a forged one. If an attacker finds or guesses your webhook URL, they can POST whatever JSON they want and your server will process it as if it were genuine, unless you verify it first. This tutorial builds that verification from scratch in Python: you will start with a receiver that accepts anything, watch it get fooled, then add HMAC signature verification, fix a subtle bug that breaks signature checks in real deployments, and finish with timestamp and delivery-ID based replay protection so a captured, valid request cannot simply be resent later.

Table Of Content

  • What HMAC signature verification actually buys you
  • Prerequisites
  • Step 1: Set up the project
  • Step 2: See why an unverified endpoint is dangerous
  • Step 3: Add HMAC-SHA256 signature verification
  • The convention: how GitHub signs its webhooks
  • Building the receiver
  • Building a sender to simulate the provider
  • Step 4: Test every case
  • Step 5: Avoid the gotcha that breaks signature checks in production
  • Watch it fail
  • Fix it: capture raw bytes first
  • Step 6: Add replay protection with a signed timestamp
  • Building it
  • Test it, including the surprising part
  • Step 7: Close the replay window with delivery-ID deduplication
  • Common mistakes and gotchas
  • How to verify everything works end to end
  • Next steps

Every piece of code in this tutorial was written and executed end to end against a real local Python 3.11.15 environment with FastAPI 0.141.1, uvicorn 0.52.1, and requests 2.34.2. Every HTTP status code and response body shown below is copied from an actual terminal run, not invented. The signature header format follows GitHub’s own documented convention, and the timestamp-based replay protection follows Stripe’s own documented convention; both are cited and linked in the relevant steps so you can go read the authoritative source yourself.

What HMAC signature verification actually buys you

HMAC stands for Hash-based Message Authentication Code. It is a way to prove two things about a message using a secret that both sides, and only both sides, know: first, that the message really came from whoever holds the secret (authenticity), and second, that nobody altered it in transit (integrity). The sender computes a keyed hash of the exact bytes it is sending and attaches that hash as a signature. The receiver recomputes the same keyed hash over the bytes it received, using the same shared secret, and checks whether the two hashes match. Since the hash depends on both the secret and the exact message content, an attacker who does not know the secret cannot produce a signature that matches a message they wrote, and an attacker who intercepts and modifies a legitimate message will invalidate its signature in the process.

This is different from encryption. HMAC does not hide the payload, anyone who can see the HTTP request can read the JSON body in plain text. What it guarantees is that the body was not tampered with and did come from someone who holds the shared secret. That is exactly the guarantee a webhook receiver needs: you are not trying to keep the event data secret, you are trying to make sure a stranger cannot pretend to be Stripe, GitHub, or whatever service you integrated with.

Prerequisites

  • A Linux or macOS machine with a terminal. The code is plain Python and does not depend on any OS-specific feature.
  • Python 3.10 or newer. This tutorial was built and tested on Python 3.11.15. Check your version with python3 --version.
  • pip for installing packages, and basic comfort running commands in a terminal.
  • Basic familiarity with what an HTTP request and a JSON body look like. Every new concept, including HMAC itself, is explained the first time it appears, so no cryptography background is assumed.
  • No account with any real webhook provider is required. Everything in this tutorial runs entirely on your own machine.

Step 1: Set up the project

Create a project folder and an isolated virtual environment so the packages you install here do not affect anything else on your machine:

# Context: any Linux/macOS terminal with Python 3.10+ installed
mkdir webhook-tutorial && cd webhook-tutorial
python3 -m venv venv
source venv/bin/activate
pip install fastapi uvicorn requests

This installs three things you will use throughout the tutorial: FastAPI, a Python web framework for building the webhook receiver; uvicorn, the ASGI server that actually runs a FastAPI application; and requests, which the sender scripts use to simulate a webhook provider POSTing events to your receiver. Confirm the install worked:

pip show fastapi uvicorn requests | grep -E "Name|Version"

Expected output looks like this (your exact version numbers may differ slightly, that is fine):

Name: fastapi
Version: 0.141.1
Name: uvicorn
Version: 0.52.1
Name: requests
Version: 2.34.2

Step 2: See why an unverified endpoint is dangerous

Before adding any protection, build the naive version so you can see the actual problem, not just take it on faith. Save this as receiver.py:

from fastapi import FastAPI, Request

app = FastAPI()


@app.post("/webhook")
async def receive_webhook(request: Request):
    payload = await request.json()
    print(f"Received event: {payload}")
    return {"status": "accepted"}

Run it in one terminal:

uvicorn receiver:app --host 127.0.0.1 --port 8000

In a second terminal, send a completely forged event, nothing about this request proves it came from a real payment provider:

curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://127.0.0.1:8000/webhook \
  -H "Content-Type: application/json" \
  -d '{"event": "payment.completed", "amount": 999999, "account": "attacker-controlled"}'

Real captured output:

HTTP 200

The server happily accepted a payment confirmation for an account it has never heard of, for an amount nobody actually paid. If this receiver triggered a real action, like marking an order as paid or granting access to a resource, anyone with the URL could trigger that action for free. That is the entire problem HMAC verification solves.

Step 3: Add HMAC-SHA256 signature verification

The convention: how GitHub signs its webhooks

Rather than invent a signature scheme, this tutorial follows the convention GitHub actually uses in production, documented in GitHub’s own webhook validation guide. GitHub computes an HMAC-SHA256 digest of the raw request body using a secret you configured when you set up the webhook, then sends it in a request header:

X-Hub-Signature-256: sha256=<hex-encoded digest>

GitHub’s documentation is explicit that the comparison on the receiving end matters as much as the computation: “Never use a plain == operator… which performs a ‘constant time’ string comparison to help mitigate certain timing attacks.” Python’s own hmac module documentation backs this up directly: hmac.compare_digest(a, b) “uses an approach designed to prevent timing analysis by avoiding content-based short circuiting behaviour, making it appropriate for cryptography,” specifically so that “it is recommended to use the compare_digest() function instead of the == operator to reduce the vulnerability to timing attacks.” A naive == comparison on strings returns as soon as it finds the first mismatched character, so how quickly a comparison fails can leak, character by character, how much of the guess was correct. compare_digest is designed to take the same amount of time no matter where in the string a mismatch occurs, so a wrong guess does not fail measurably faster or slower depending on how close it was.

Building the receiver

Save this as receiver.py, replacing the naive version from Step 2:

import hashlib
import hmac
import os

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()


def verify_signature(raw_body: bytes, signature_header: str | None) -> None:
    if not signature_header or not signature_header.startswith("sha256="):
        raise HTTPException(status_code=401, detail="Missing or malformed signature header")

    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    provided = signature_header.removeprefix("sha256=")

    if not hmac.compare_digest(expected, provided):
        raise HTTPException(status_code=401, detail="Signature mismatch")


@app.post("/webhook")
async def receive_webhook(request: Request):
    raw_body = await request.body()
    signature_header = request.headers.get("X-Hub-Signature-256")

    verify_signature(raw_body, signature_header)

    payload = await request.json()
    print(f"Verified event: {payload}")
    return {"status": "accepted"}

Two details matter here beyond the HMAC math itself. First, the secret comes from an environment variable, never hardcode it in the source file, since that file tends to end up in version control sooner or later. Second, notice the handler calls await request.body() to get the raw bytes before it ever calls await request.json(). Step 5 explains exactly why that ordering is not optional.

Building a sender to simulate the provider

In a real integration, the webhook provider (Stripe, GitHub, your payment processor) signs the request on their end. To test locally, write a small script that plays that role. Save this as sender.py:

import hashlib
import hmac
import json
import sys

import requests

SECRET = b"my-shared-webhook-secret"
URL = "http://127.0.0.1:8000/webhook"


def sign(body_bytes: bytes, secret: bytes = SECRET) -> str:
    digest = hmac.new(secret, body_bytes, hashlib.sha256).hexdigest()
    return f"sha256={digest}"


def send(payload: dict, secret: bytes = SECRET, corrupt_after_signing: bool = False):
    body_bytes = json.dumps(payload).encode()
    signature = sign(body_bytes, secret)

    if corrupt_after_signing:
        payload["amount"] = 999999
        body_bytes = json.dumps(payload).encode()

    resp = requests.post(
        URL,
        data=body_bytes,
        headers={
            "Content-Type": "application/json",
            "X-Hub-Signature-256": signature,
        },
    )
    print(f"HTTP {resp.status_code}: {resp.text}")


if __name__ == "__main__":
    scenario = sys.argv[1] if len(sys.argv) > 1 else "valid"
    demo_payload = {"event": "payment.completed", "amount": 42, "account": "acct_123"}

    if scenario == "valid":
        send(dict(demo_payload))
    elif scenario == "tampered":
        send(dict(demo_payload), corrupt_after_signing=True)
    elif scenario == "wrong-secret":
        send(dict(demo_payload), secret=b"a-secret-the-receiver-does-not-know")
    elif scenario == "no-signature":
        body_bytes = json.dumps(demo_payload).encode()
        resp = requests.post(URL, data=body_bytes, headers={"Content-Type": "application/json"})
        print(f"HTTP {resp.status_code}: {resp.text}")

The corrupt_after_signing path is worth reading closely: it signs the original payload first, then changes the amount and sends the changed bytes with the old signature. That mirrors exactly what an attacker who intercepted a valid request and tried to alter it in flight would produce.

Step 4: Test every case

Start the receiver with the shared secret set, then run each scenario from a second terminal:

# Context: run in the webhook-tutorial folder with the venv active
WEBHOOK_SECRET=my-shared-webhook-secret uvicorn receiver:app --host 127.0.0.1 --port 8000
python3 sender.py valid
python3 sender.py tampered
python3 sender.py wrong-secret
python3 sender.py no-signature

Real captured output, one line per scenario:

HTTP 200: {"status":"accepted"}
HTTP 401: {"detail":"Signature mismatch"}
HTTP 401: {"detail":"Signature mismatch"}
HTTP 401: {"detail":"Missing or malformed signature header"}

Walk through why each one resolved the way it did. The valid request’s signature was computed over the exact bytes that were sent with the exact secret the receiver expects, so the recomputed hash matches. The tampered request carries a signature computed over the original payload, but the bytes on the wire changed after signing, so the hashes diverge. The wrong-secret request uses a completely different key to sign, and HMAC output is only reproducible if you know the exact key, so there is no way the receiver’s recomputation can land on the same digest. The no-signature request never had a signature attached at all, and the receiver correctly refuses to guess.

Step 5: Avoid the gotcha that breaks signature checks in production

This is the mistake that catches people even after they have correctly implemented HMAC verification, because it does not show up in testing with hand-written JSON, only against a real provider. The bug: verifying the signature against a re-serialized copy of the parsed payload instead of the exact raw bytes that were actually sent.

Here is why it happens. FastAPI (and most web frameworks) make it very easy to grab await request.json() and get a nice Python dict back. It is tempting to compute the expected signature from that dict, since you already have it in hand. The problem is that json.dumps() on that dict will almost never reproduce the exact bytes the sender originally transmitted, real providers serialize JSON with their own language’s serializer (often Go, Ruby, or Node.js internally), with their own key ordering and their own whitespace conventions, and none of that is guaranteed to match what Python’s json.dumps() produces from the parsed-and-reconstructed object.

Watch it fail

Save this as broken_receiver.py:

import hashlib
import hmac
import json
import os

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()


@app.post("/webhook")
async def receive_webhook(request: Request):
    body = await request.json()  # BUG: parses first, raw bytes are gone
    signature_header = request.headers.get("X-Hub-Signature-256", "")

    # Re-serializing a parsed object almost never reproduces the exact
    # bytes the sender signed (key order, spacing, float formatting).
    recomputed_bytes = json.dumps(body).encode()
    expected = hmac.new(WEBHOOK_SECRET, recomputed_bytes, hashlib.sha256).hexdigest()
    provided = signature_header.removeprefix("sha256=")

    if not hmac.compare_digest(expected, provided):
        raise HTTPException(status_code=401, detail="Signature mismatch")

    print(f"Verified event: {body}")
    return {"status": "accepted"}

And a sender that signs compact, no-space JSON, which is a realistic stand-in for how a non-Python provider would actually serialize a payload, save it as gotcha_sender.py:

import hashlib
import hmac
import json

import requests

SECRET = b"my-shared-webhook-secret"
URL = "http://127.0.0.1:8000/webhook"

payload = {"event": "payment.completed", "amount": 42, "account": "acct_123"}

# Real providers use their own JSON serializer, so the bytes on the wire
# are rarely spaced the way Python's json.dumps() would produce them.
# We simulate that here with compact (no-space) separators.
body_bytes = json.dumps(payload, separators=(",", ":")).encode()
signature = "sha256=" + hmac.new(SECRET, body_bytes, hashlib.sha256).hexdigest()

print(f"Raw bytes on the wire: {body_bytes}")
print(f"Signature computed over those bytes: {signature}")

resp = requests.post(
    URL,
    data=body_bytes,
    headers={"Content-Type": "application/json", "X-Hub-Signature-256": signature},
)
print(f"HTTP {resp.status_code}: {resp.text}")

Run the broken receiver and send this perfectly legitimate, completely untampered request at it:

WEBHOOK_SECRET=my-shared-webhook-secret uvicorn broken_receiver:app --host 127.0.0.1 --port 8000
python3 gotcha_sender.py

Real captured output:

Raw bytes on the wire: b'{"event":"payment.completed","amount":42,"account":"acct_123"}'
Signature computed over those bytes: sha256=cdb5a63942e9774649f04c9e2db3afdf601c80bd78fa32f54078ec8f0bc407a1
HTTP 401: {"detail":"Signature mismatch"}

Nothing was tampered with. The signature was computed correctly on the sending side over the real bytes. It fails purely because the receiver reconstructed a different byte sequence before checking it, Python’s default json.dumps() inserts a space after every colon and comma, so {"event":"payment.completed",...} on the wire becomes {"event": "payment.completed", ...} once Python re-serializes it, and that single space changes the HMAC output completely.

Fix it: capture raw bytes first

Save this as fixed_receiver.py:

import hashlib
import hmac
import json
import os

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()


@app.post("/webhook")
async def receive_webhook(request: Request):
    raw_body = await request.body()  # FIX: capture raw bytes before any parsing
    signature_header = request.headers.get("X-Hub-Signature-256", "")

    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    provided = signature_header.removeprefix("sha256=")

    if not hmac.compare_digest(expected, provided):
        raise HTTPException(status_code=401, detail="Signature mismatch")

    payload = json.loads(raw_body)  # safe to parse now, verification already done
    print(f"Verified event: {payload}")
    return {"status": "accepted"}

Restart the server against this fixed version and send the exact same request again:

WEBHOOK_SECRET=my-shared-webhook-secret uvicorn fixed_receiver:app --host 127.0.0.1 --port 8000
python3 gotcha_sender.py

Real captured output:

Raw bytes on the wire: b'{"event":"payment.completed","amount":42,"account":"acct_123"}'
Signature computed over those bytes: sha256=cdb5a63942e9774649f04c9e2db3afdf601c80bd78fa32f54078ec8f0bc407a1
HTTP 200: {"status":"accepted"}

Same request, same signature, opposite outcome, purely because the receiver now verifies against the bytes that were actually signed instead of a reconstruction of them. The rule to take away: capture request.body() as the very first thing your handler does, verify the signature against those raw bytes, and only parse JSON out of it afterward.

Step 6: Add replay protection with a signed timestamp

HMAC verification proves a request came from someone who holds the secret and was not altered. It does not prove the request is fresh. If an attacker captures a validly signed request off the wire, perhaps from an insecure network or a compromised logging system, they can resend that exact same request tomorrow and it will still pass signature verification, because nothing about the signature encodes when it was created. This is called a replay attack.

Stripe’s webhook documentation solves this by folding a timestamp into the signed data itself. Stripe’s Stripe-Signature header looks like t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd, and critically, the string that actually gets hashed is not just the body, it is the timestamp and the body concatenated. Stripe’s docs describe the signed_payload string as created by concatenating the timestamp (as a string), the character ., and the actual JSON payload (the request body). Because the timestamp is inside the signed data, an attacker cannot swap in a newer timestamp to make an old captured request look fresh, doing so would invalidate the signature just like editing the body would. Stripe’s documented default is to reject anything more than 5 minutes (300 seconds) old, and their docs explicitly warn: “Don’t use a tolerance value of 0. Using a tolerance value of 0 disables the recency check entirely.” They also recommend keeping your server’s clock synced with NTP, since the whole scheme depends on your clock and the sender’s clock roughly agreeing.

Building it

Save this as timestamp_sender.py:

import hashlib
import hmac
import json
import sys
import time

import requests

SECRET = b"my-shared-webhook-secret"
URL = "http://127.0.0.1:8000/webhook"


def send(payload: dict, timestamp_offset: int = 0, delivery_id: str = "evt_001"):
    timestamp = int(time.time()) + timestamp_offset
    body_bytes = json.dumps(payload, separators=(",", ":")).encode()
    signed_payload = f"{timestamp}.".encode() + body_bytes
    signature = hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()

    resp = requests.post(
        URL,
        data=body_bytes,
        headers={
            "Content-Type": "application/json",
            "X-Webhook-Signature": f"t={timestamp},v1={signature}",
            "X-Webhook-Delivery-Id": delivery_id,
        },
    )
    print(f"timestamp={timestamp} delivery_id={delivery_id} -> HTTP {resp.status_code}: {resp.text}")


if __name__ == "__main__":
    scenario = sys.argv[1] if len(sys.argv) > 1 else "fresh"
    demo_payload = {"event": "payment.completed", "amount": 42, "account": "acct_123"}

    if scenario == "fresh":
        send(demo_payload, delivery_id="evt_001")
    elif scenario == "replay-same":
        send(demo_payload, delivery_id="evt_001")
    elif scenario == "old":
        send(demo_payload, timestamp_offset=-400, delivery_id="evt_002")

Save this as timestamp_receiver.py:

import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300  # matches Stripe's documented default tolerance


@app.post("/webhook")
async def receive_webhook(request: Request):
    raw_body = await request.body()
    sig_header = request.headers.get("X-Webhook-Signature", "")

    parts = dict(item.split("=", 1) for item in sig_header.split(",") if "=" in item)
    timestamp = parts.get("t")
    provided_signature = parts.get("v1")

    if not timestamp or not provided_signature:
        raise HTTPException(status_code=401, detail="Missing timestamp or signature")

    signed_payload = f"{timestamp}.".encode() + raw_body
    expected_signature = hmac.new(WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected_signature, provided_signature):
        raise HTTPException(status_code=401, detail="Signature mismatch")

    age = abs(time.time() - int(timestamp))
    if age > TOLERANCE_SECONDS:
        raise HTTPException(status_code=401, detail=f"Timestamp outside {TOLERANCE_SECONDS}s tolerance (age={age:.0f}s)")

    payload = json.loads(raw_body)
    print(f"Verified event (age={age:.1f}s): {payload}")
    return {"status": "accepted"}

Test it, including the surprising part

WEBHOOK_SECRET=my-shared-webhook-secret uvicorn timestamp_receiver:app --host 127.0.0.1 --port 8000
python3 timestamp_sender.py fresh
python3 timestamp_sender.py replay-same
python3 timestamp_sender.py old

Real captured output:

timestamp=1785740788 delivery_id=evt_001 -> HTTP 200: {"status":"accepted"}
timestamp=1785740788 delivery_id=evt_001 -> HTTP 200: {"status":"accepted"}
timestamp=1785740388 delivery_id=evt_002 -> HTTP 401: {"detail":"Timestamp outside 300s tolerance (age=400s)"}

Read that second line carefully: the exact same request, resent immediately, was accepted a second time. This is not a bug in the code above, it is the honest limitation of timestamp checking by itself. A tolerance window bounds how old a replayed request is allowed to be, it does not stop someone from replaying a request within that window. The old request in the third line was correctly rejected because 400 seconds exceeds the 300 second tolerance, but a replay sent one second after the original would sail right through. Timestamp tolerance and duplicate detection solve two different problems, and you need both.

Step 7: Close the replay window with delivery-ID deduplication

Real providers attach a unique ID to every individual delivery attempt. GitHub’s webhook events and payloads documentation calls this header X-GitHub-Delivery and describes it as “a globally unique identifier (GUID) to identify the event.” The fix for the gap in Step 6 is to remember which delivery IDs you have already processed and reject any repeat outright, regardless of whether it is still within the timestamp tolerance window.

Save this as final_receiver.py:

import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300

# In-memory for this tutorial. Use Redis (with a matching TTL) or a
# database unique constraint in a real, multi-process deployment.
seen_delivery_ids: set[str] = set()


@app.post("/webhook")
async def receive_webhook(request: Request):
    raw_body = await request.body()
    sig_header = request.headers.get("X-Webhook-Signature", "")
    delivery_id = request.headers.get("X-Webhook-Delivery-Id")

    parts = dict(item.split("=", 1) for item in sig_header.split(",") if "=" in item)
    timestamp = parts.get("t")
    provided_signature = parts.get("v1")

    if not timestamp or not provided_signature or not delivery_id:
        raise HTTPException(status_code=401, detail="Missing timestamp, signature, or delivery id")

    signed_payload = f"{timestamp}.".encode() + raw_body
    expected_signature = hmac.new(WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected_signature, provided_signature):
        raise HTTPException(status_code=401, detail="Signature mismatch")

    age = abs(time.time() - int(timestamp))
    if age > TOLERANCE_SECONDS:
        raise HTTPException(status_code=401, detail=f"Timestamp outside {TOLERANCE_SECONDS}s tolerance (age={age:.0f}s)")

    if delivery_id in seen_delivery_ids:
        raise HTTPException(status_code=409, detail=f"Duplicate delivery id {delivery_id}, already processed")
    seen_delivery_ids.add(delivery_id)

    payload = json.loads(raw_body)
    print(f"Verified event (age={age:.1f}s, delivery_id={delivery_id}): {payload}")
    return {"status": "accepted"}

Run it and replay the same delivery twice, using the same timestamp_sender.py from Step 6:

WEBHOOK_SECRET=my-shared-webhook-secret uvicorn final_receiver:app --host 127.0.0.1 --port 8000
python3 timestamp_sender.py fresh
python3 timestamp_sender.py replay-same

Real captured output:

timestamp=1785740809 delivery_id=evt_001 -> HTTP 200: {"status":"accepted"}
timestamp=1785740809 delivery_id=evt_001 -> HTTP 409: {"detail":"Duplicate delivery id evt_001, already processed"}

The exact same replay that silently succeeded at the end of Step 6 is now rejected with a 409 Conflict. Combined with signature verification and timestamp tolerance, you now have all three pieces a production-grade webhook receiver actually needs: proof of authenticity, a bounded freshness window, and duplicate rejection inside that window.

Common mistakes and gotchas

  • Comparing signatures with == instead of hmac.compare_digest. Both GitHub’s documentation and Python’s own hmac documentation call this out explicitly, a plain string comparison can leak timing information about how much of a guessed signature was correct.
  • Verifying against re-serialized JSON instead of raw bytes. This is Step 5’s entire lesson: call request.body() before anything else touches the request, and verify against those bytes directly.
  • Setting the replay tolerance window to 0. Stripe’s own documentation warns against this specifically, a tolerance of 0 does not tighten security, it disables the recency check entirely because no clock is ever perfectly synchronized to the millisecond.
  • Forgetting HTTPS. HMAC verification proves authenticity and integrity, it does not provide confidentiality. Serve your webhook endpoint over HTTPS so the payload itself, and every header, is encrypted in transit, not just signed.
  • Storing the webhook secret in source control. Load it from an environment variable or a secrets manager, exactly as this tutorial’s receivers do with os.environ["WEBHOOK_SECRET"], never hardcode it in a file that gets committed.
  • Using a plain Python set for delivery-ID deduplication in production. It works for this tutorial because everything runs in one process, but a real deployment usually runs multiple worker processes behind a load balancer, none of which share memory. Use Redis with a TTL slightly longer than your timestamp tolerance, or a database table with a unique constraint on the delivery ID, instead.

How to verify everything works end to end

Run through this checklist against final_receiver.py, the complete version from Step 7, to confirm the whole system holds together:

  1. Start final_receiver.py with WEBHOOK_SECRET set, and confirm it logs Application startup complete.
  2. Run python3 timestamp_sender.py fresh and confirm you get HTTP 200.
  3. Immediately run python3 timestamp_sender.py replay-same and confirm you now get HTTP 409, proving duplicate detection works.
  4. Run python3 timestamp_sender.py old and confirm you get HTTP 401 for an expired timestamp.
  5. Reread Step 4’s tampered and wrong-secret results: final_receiver.py still runs the same hmac.compare_digest check, now against a signature computed over the timestamp-prefixed payload instead of the raw body alone, so a forged or altered request fails that check there too.

If those checks match, your receiver correctly rejects forged requests, tampered requests, expired requests, and replayed requests, while accepting genuine ones.

Next steps

The receivers in this tutorial simulate a provider with a hand-written sender script so you can see every moving part. When you integrate with a real service, read that specific provider’s webhook documentation for its exact header names and signing scheme, GitHub’s and Stripe’s docs (both linked above) are excellent references even if you are integrating with a different provider, since most webhook schemes are variations on the same two ideas covered here. From here, worthwhile next steps are swapping the in-memory seen_delivery_ids set for Redis so deduplication survives a restart and works across multiple worker processes, and looking into how your chosen provider recommends rotating a webhook secret without downtime, which usually involves accepting both the old and new secret for a short overlap window.

Tags:

API SecurityFastAPIHMACPythonWebhooks

Share

A composing stick holding set metal type spelling a pangram, resting on a wooden type case filled with metal sorts
Previous Post

GitHub’s Casefold Crate Turns Case-Insensitive Search Into a Byte-Arithmetic Problem

Token Ring and Ethernet interface ports on a physical IBM 2210 multiprotocol router
Next Post

Kubernetes Gateway API v1.6 Graduates TCPRoute and UDPRoute to General Availability

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Oct
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
08 Oct
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
Trending
October 8, 2026
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
October 8, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
October 8, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026