Before you start
- Python 3.8 or newer; the example uses only the standard library
- A sender that signs requests with a shared secret (the scheme here is generic; yours may differ in header names)
- Understanding of HTTP POST requests
Permissions and scopes you need
- A shared secret known only to you and the sender, stored outside the code (an environment variable)
- A public HTTPS endpoint in production; the local example uses http on 127.0.0.1 for testing only
A webhook is an HTTP request that another system sends to your server when something happens: a payment succeeded, a form was submitted, a build finished. Your endpoint is reachable by anyone on the internet, so the first question is always the same: did this request really come from the sender?
This guide builds a receiver that does three things before it trusts a request:
- Authenticates it, by checking an HMAC signature.
- Rejects replays, by checking a timestamp.
- Ignores duplicates, because senders retry.
The signing scheme here is a common generic one: the sender computes HMAC-SHA256(secret, timestamp + "." + body) and sends the hex result in a header. Real providers differ in header names and exact format, so follow your provider's documentation for the details; the structure stays the same.
The receiver
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ.get("WEBHOOK_SECRET", "change-me").encode()
TOLERANCE_SECONDS = 300
seen_ids = set() # in real life: a database table with a unique constraint
def sign(timestamp: str, body: bytes) -> str:
message = timestamp.encode() + b"." + body
return hmac.new(SECRET, message, hashlib.sha256).hexdigest()
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) # the RAW bytes, exactly as sent
timestamp = self.headers.get("X-Timestamp", "")
received = self.headers.get("X-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return self.reply(400, "stale or missing timestamp")
if not hmac.compare_digest(sign(timestamp, body), received):
return self.reply(401, "bad signature")
event = json.loads(body)
if event["id"] in seen_ids: # the sender retries: do not process twice
return self.reply(200, "duplicate ignored")
seen_ids.add(event["id"])
print("processing", event["type"], event["id"])
return self.reply(200, "ok")
def reply(self, status, text):
self.send_response(status)
self.end_headers()
self.wfile.write(text.encode())
def log_message(self, *args):
pass
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8099), Handler).serve_forever()
Why each line is there
- The raw body. The signature covers exact bytes. If you parse the JSON and serialise it again, key order or whitespace can change and the signature will not match. Read the bytes, verify, and only then parse.
- The timestamp, inside the signature. Signing
timestamp + "." + bodymeans an attacker cannot reuse a valid request later with a fresh timestamp. Rejecting timestamps older than five minutes limits the time a captured request is useful. hmac.compare_digest. It compares in constant time, so response timing cannot be used to guess the signature byte by byte.- An event id check. Senders retry on timeouts and errors, so the same event can arrive more than once. Your handler must produce the same result however many times it runs. In production, store ids in a database with a unique constraint so the check survives restarts and several servers.
- Quick responses. Verify, record, return 2xx, and do heavy work afterwards. A slow answer looks like a failure and triggers more retries.
A test sender
This script signs requests the same way, and sends four: a valid one, the same one again, one with a tampered body and one with an old timestamp.
import hashlib
import hmac
import json
import time
import urllib.error
import urllib.request
SECRET = b"change-me"
def send(event, timestamp=None, tamper=False):
body = json.dumps(event).encode()
timestamp = str(timestamp or int(time.time()))
signature = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
if tamper:
body = body.replace(b"paid", b"free")
request = urllib.request.Request("http://127.0.0.1:8099/", data=body, method="POST",
headers={"X-Timestamp": timestamp, "X-Signature": signature, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(request) as response:
return response.status, response.read().decode()
except urllib.error.HTTPError as error:
return error.code, error.read().decode()
event = {"id": "evt_1", "type": "order.paid", "status": "paid"}
print("valid ", send(event))
print("replayed ", send(event))
print("tampered ", send({"id": "evt_2", "type": "order.paid", "status": "paid"}, tamper=True))
print("old ", send({"id": "evt_3", "type": "order.paid", "status": "paid"}, timestamp=int(time.time()) - 3600))
Start the receiver in one terminal and run the sender in another. The result:
valid (200, 'ok')
replayed (200, 'duplicate ignored')
tampered (401, 'bad signature')
old (400, 'stale or missing timestamp')
Read it as the four outcomes you designed for. The tampered body changed paid to free after signing, so the signature no longer matched. The old request had a correct signature but an hour-old timestamp.
Going to production
- Use HTTPS. The signature proves who sent the request, not that nobody read it. Webhook bodies often contain personal or financial data.
- Keep the secret out of code and rotate it. Store it in an environment variable or a secrets manager, and support two valid secrets during a rotation.
- Use a real web framework and make sure verification happens before any middleware that reads or rewrites the body.
- Return a 2xx status for events you accept, and a 4xx for ones you reject, so the sender's retry logic works properly. Do not return an error for a duplicate you have already handled.
- Log rejections (without logging the secret or the full body), and alert on a sudden rise: it can mean an attack or a broken sender.
- Allow only what you expect. Check the event type, and ignore types you do not handle without failing.
Expected result
A signed request returns 200 and is processed once. The same event sent again returns 200 and is ignored. A tampered body returns 401, and a request with an old timestamp returns 400.
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
| Every valid request is rejected with 'bad signature' | You are computing the signature over parsed or re-serialised JSON, or over the wrong bytes. Frameworks often parse the body before your code sees it. | Sign and verify the raw request body exactly as received. Read the bytes first, verify, then parse. |
| Verification works locally but fails in production | A proxy or framework changes the body (decompression, whitespace), or the secret differs between environments. | Log the body length and hash on both sides, check the secret has the same value, and verify before any middleware that modifies the body. |
| Legitimate events are rejected as stale | The sender and your server clocks differ by more than the tolerance, or the sender retries old events. | Synchronise your server's clock with NTP, and allow a tolerance that fits the sender's retry behaviour. |
| The same order is processed twice | The sender retried after a timeout, and your handler is not idempotent. | Store each event id on first sight with a unique constraint, and skip ids you have seen. |
Frequently asked questions
Why use compare_digest instead of ==?
A normal comparison stops at the first different character, so the time it takes can leak how much of a guess was right. hmac.compare_digest takes the same time whatever the input, which closes that side channel.
Should I respond before processing?
Usually yes. Verify, record the event, return 2xx quickly, and do slow work afterwards. Senders treat a slow response as a failure and retry.
References
From Grokem: https://www.grokem.com/programming/apis-integrations/webhooks/verify-webhook-signatures



