Skip to content

Grokem · https://www.grokem.com/programming/apis-integrations/webhooks/verify-webhook-signatures

WebhooksIntegration guide

Receive a webhook safely: verify the signature, check the age, ignore repeats

A webhook endpoint is a public door. Build one that checks who sent the request, rejects old replays and survives the same event arriving twice.

Grokem Editorial

Editorial desk

Published
  • Intermediate
  • 25 min to complete
Tested withPython3.11

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:

  1. Authenticates it, by checking an HMAC signature.
  2. Rejects replays, by checking a timestamp.
  3. 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

python
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 + "." + body means 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.

python
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:

text
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

ProblemLikely causeFix
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 productionA 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 staleThe 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 twiceThe 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

How did this strike you?

Be the first to react – it is anonymous.

Community

Questions about this?

Ask in Questions and help and get help from other readers.

Ask a questionBrowse Questions and help

Comments (2)

  • Omar H.

    Why compare with hmac.compare_digest instead of ==?

    • Grokem Editorial

      A plain `==` can return as soon as it finds the first differing character, so the time it takes leaks a little about how much of a guess was right. `compare_digest` takes the same time either way. It costs nothing to use, so use it for every secret comparison.

Choose what we may use. You can change this at any time with “Privacy choices” at the foot of every page. Details are in the Cookie policy and the Privacy policy.

Strictly necessary

Remembers your theme, the lessons you marked as done and this choice. It stays on your device, tracks nothing and cannot be switched off.

Always on
What exactly is collected, and why

Grokem’s own counts

Collects
The pages you open and how long you stay, how far you scroll, where you came from, your device type, browser, screen size, language and time zone. Your IP address is turned into a code that changes every day; it is never stored as it is.
Why
To see how long lessons are read and where readers leave. Not shared with anyone.
Stored
A random visitor ID, a visit ID and the time of your last page view in your browser’s storage (gr_vid, gr_sid, gr_last). No name or email is attached.