FDE PulseFDE jobs open 434New in the last 7 days 27
VI

The newspaper of the Forward Deployed Engineer

Guides

Build a webhook receiver that withstands forged signatures, replays and duplicate events

Stripe can resend the same event for up to three days, with a new signature each time. A time window cannot stop those duplicates, so your receiver needs a separate line of defence.

In brief

  • Compute the signature over the raw bytes of the body. If your framework parses and re-serialises the JSON, the signature will never match.
  • The timestamp is part of what is signed, so it blocks replays. Legitimate retries carry a new signature, so deduplication has to rely on the event ID.
  • Return 2xx before any heavy logic, push processing onto a queue, and never set the tolerance to 0.
ShareLinkedInFacebookX

In live mode, Stripe attempts to deliver a webhook event for up to three days, with exponential backoff between attempts. Each retry carries a new timestamp and a new signature.

That means a five-minute anti-replay window will not stop a legitimate retry. If that is your receiver’s only defence, a customer’s stock can be decremented twice, or they can receive two confirmation emails for the same order.

Webhooks show up in many of the integrations a Forward Deployed Engineer may have to build: payments with Stripe, CI/CD with GitHub, internal bots with Slack. This guide walks through building a receiver with three layers of defence: signature verification, replay protection and duplicate filtering.

What you will build, and what you need

The end result is a handful of Python functions that use only the standard library, with no framework, and mimic how Stripe signs webhooks. You need Python 3 and the hmac, hashlib, sqlite3 and json modules. The HTTP layer is deliberately left out. Any framework can handle it, provided you can get at the raw bytes of the body.

One caveat before starting: the code below is simplified to teach the mechanism. In a real project, use the provider’s official library, which parses the headers for you and handles details this example skips.

Step 1: why keep the raw body?

Stripe’s documentation is explicit: signature verification needs the raw request body, and the framework must not alter it. This is a common mistake when writing a receiver. Middleware parses the JSON, you serialise it again, and a single extra space or a different key order is enough to produce a completely different HMAC.

The rule is simple: capture the raw bytes first, verify the signature, and only then parse the JSON. You will see this for yourself in step 5.

Step 2: compute the signature and compare it properly

Stripe describes how to compute the signature manually. Concatenate the timestamp, a dot and the body into signed_payload, then compute an HMAC with the SHA256 hash function, using the endpoint’s signing secret as the key. For the comparison, Stripe requires a constant-time string comparison between the expected signature and each received signature, to defend against timing attacks.

import hmac, hashlib, time

TOLERANCE_SECONDS = 300  # 5 minutes

def expected_signature(secret: bytes, timestamp: str, raw_body: bytes) -> str:
    signed_payload = timestamp.encode() + b"." + raw_body
    return hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()

def verify(secret, timestamp, raw_body, received_sigs, now=None):
    now = now if now is not None else time.time()
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    exp = expected_signature(secret, timestamp, raw_body)
    return any(hmac.compare_digest(exp, s) for s in received_sigs)

This example is simplified: extracting the timestamp and the list of signatures from the header is skipped. Note the any(...) loop: several signatures may be sent, and only one needs to match. If you write exp == s, the code still runs and the tests still pass, but you have opened exactly the timing-attack hole Stripe warns about.

Check: call expected_signature(b"whsec_test", "1700000000", b'{"id":"evt_1"}') twice. Both results must be identical.

Step 3: block replays, and never set the tolerance to 0

The timestamp is part of what is signed, so an attacker cannot change it without breaking the signature. Stripe’s library allows a default drift of 5 minutes between the timestamp and the current time. Slack does something similar: its signature depends on the timestamp to prevent replays, and Slack’s official example also checks that the drift is no more than 5 minutes.

The trap is in the configuration. Stripe warns plainly that a tolerance of 0 disables the freshness check entirely. Someone hits a clock-skew error at 2am, sets the tolerance to 0 “just to get it through”, and the receiver loses its replay protection without any monitoring system raising an alarm.

Check: call verify with now 301 seconds later than the timestamp. The result must be False.

Step 4: deduplicate by event ID, not by time

As noted at the start, a legitimate retry has a new signature, so it gets through step 3. Stripe recommends logging the event IDs you have processed and skipping any event already in the log.

Stripe also stresses that delivery order is not guaranteed. Do not use the created field to infer order or to guess whether an event has already been handled.

import sqlite3
db = sqlite3.connect("events.db")
db.execute("CREATE TABLE IF NOT EXISTS processed (event_id TEXT PRIMARY KEY)")

def first_time(event_id: str) -> bool:
    # No commit here: the caller decides when to commit
    cur = db.execute(
        "INSERT OR IGNORE INTO processed(event_id) VALUES (?)", (event_id,))
    return cur.rowcount == 1

There are two subtleties. Stripe notes that two different Events sometimes describe the same thing. In that case, the deduplication key should be the ID of data.object combined with event.type.

The other is the order of writes. Record the event ID in the same transaction as the business change. If you mark the event “processed” and commit first, then the worker dies midway, that event is lost for good. That is why the function above does not commit on its own.

Check: first_time("evt_1") returns True on the first call and False on the second.

Step 5: return 2xx first, process later

Stripe requires endpoints to return a 2xx status quickly, before any complex logic that could cause a timeout. It recommends asynchronous processing through a queue to absorb sudden spikes in events. The condensed code below wires the previous steps into a complete receiver:

import json, queue

# Dummy secret for local testing; in a real project, read the signing secret from an environment variable
SECRET = b"whsec_test"
jobs = queue.Queue()

def handle(timestamp, sigs, raw_body):
    if not verify(SECRET, timestamp, raw_body, sigs):
        return 400
    jobs.put(json.loads(raw_body))
    return 200

def worker():
    while True:
        event = jobs.get()
        with db:  # one transaction: commit on success, roll back on error
            if first_time(event["id"]):
                apply_business_change(db, event)  # your business logic

Thanks to with db, recording the event ID and the business change either both succeed or are both rolled back. This is still a simplified version: an in-memory queue loses jobs if the process dies, and apply_business_change is yours to fill in.

Now run five tests:

  1. A valid signature must pass.
  2. Changing one byte of the body must fail.
  3. A timestamp older than 5 minutes must fail.
  4. The same event ID sent twice must be processed only once.
  5. Sign a compact JSON body with no spaces, such as b'{"id":"evt_1"}', then verify against json.dumps(json.loads(raw_body)).encode(). It must fail.

The fifth test needs a compact body because json.dumps does not always change the bytes. With the default separators, it inserts a space after each colon and comma, so {"id":"evt_1"} becomes {"id": "evt_1"} and the signature no longer matches. This is the most valuable test, because it reproduces exactly the middleware bug from step 1.

Every provider signs differently

At a client, you will rarely work with Stripe alone. The mechanism is the same but the details differ, and those details are what cause bugs:

Source Signed string / header Notes
Stripe timestamp.body, HMAC-SHA256 Default tolerance 5 minutes, never set to 0
GitHub Payload with secret token, hex digest in X-Hub-Signature-256 Constant-time comparison, handle the payload as UTF-8
Slack v0:timestamp:body, compared with X-Slack-Signature Official example checks a maximum drift of 5 minutes
Standard Webhooks webhook-id, webhook-timestamp, webhook-signature Use webhook-id as the idempotency key

If a client is building its own webhook-sending system, suggest it follows the Standard Webhooks specification. Its three headers already separate the three jobs you have just done: an ID for deduplication, a timestamp for replay protection and a signature for verification.

Where this skill fits in FDE work

Picture your first week at a retail client. The operations team complains that “orders sometimes get recorded twice”.

A good FDE does not guess, but checks in the order set out here: does the handler verify against the raw body, is there an event ID table, and does it do heavy work before returning 2xx, leading to timeouts and retries?

When job hunting, if the description mentions “integrations” or “event-driven”, ask in the interview how the team handles duplicate events.

On your CV, be specific, for example: “built a webhook receiver with HMAC verification, replay protection and event-ID deduplication, with asynchronous processing via a queue”. That line tells a recruiter you understand distributed systems well enough not to corrupt a client’s data.

A webhook is a contract between two systems that do not trust each other, connected over an unreliable network. A good receiver assumes no packet is necessarily genuine, fresh or delivered only once.

4 sources
Read next on the roadmap · Stage 5: DeploymentEncryption, tokenization or masking: choosing the right tool for each customer data fieldSecurity reviews rarely turn on algorithms. The client's security team will ask three things: who holds the keys, who can see what, and whether one person's data can be deleted when they ask.