Webhooks — Signatures, Replay Protection, Idempotency, and Retries

Build a complete webhook receiver in PHP: verify an HMAC signature to confirm the payload came from the real sender, reject replayed deliveries using a timestamp plus an event ID, process each event idempotently by recording processed IDs, and understand the sender-side retry/backoff behavior and delivery-log conventions that make webhooks reliable in practice.

A webhook flips the usual client-server relationship: instead of your server polling another service for changes, that service calls YOUR server the moment something happens -- a payment succeeded, a shipment status changed, a form was submitted. That convenience creates a specific set of trust problems a normal inbound API request doesn't have to think about: anyone who discovers your webhook URL can POST fake payloads to it, a network retry (yours or theirs) can deliver the exact same event twice, and delivery can fail entirely and need to be retried later. A production webhook receiver has to defend against all three at once.

Verifying the Signature

Every reputable webhook sender (Stripe, GitHub, Shopify) signs each payload with a shared secret using HMAC-SHA256 and sends the signature in a header (Stripe-Signature, X-Hub-Signature-256), typically alongside a timestamp folded into the signed content. The receiver recomputes the same HMAC over the raw request body using its own copy of the shared secret and compares it to the header value -- if they match, the payload genuinely came from the sender and was not altered in transit; if they don't, the request is rejected outright before any business logic runs.

The comparison MUST use a constant-time function (hash_equals() in PHP), never === or ==, because a naive string comparison exits early on the first mismatched byte, and the tiny timing difference between 'byte 1 wrong' and 'byte 30 wrong' is enough for a patient attacker to reconstruct a valid signature byte-by-byte over many requests.

• Sender computes HMAC-SHA256(secret, payload) and sends it in a header alongside the raw payload. • Receiver recomputes the same HMAC independently and compares -- never trusts a signature value the payload itself claims to have. • Always compare with hash_equals(), never == or === -- naive comparison leaks timing information an attacker can exploit. • Verify against the RAW request body bytes, not a re-serialized/re-parsed version, since re-serialization can change byte-for-byte content (key order, whitespace) and break the signature match.

Replay Protection, Idempotency, and Sender-Side Retries

A valid signature alone doesn't stop REPLAY: an attacker who captured one legitimate request (even over TLS, e.g. via a compromised proxy or log leak) can resend the exact same bytes later and it will still pass signature verification, since nothing about the signature changes over time. The fix is to fold a timestamp into the signed content and reject any request whose timestamp is older than a short tolerance window (typically 5 minutes) -- a captured request becomes worthless to replay once that window passes.

Separately, even WITHOUT malice, the sender's own retry behavior guarantees your receiver will sometimes see the same legitimate event delivered more than once -- a request can succeed on your end while the response gets lost on the way back, causing the sender to retry an event you already processed. The receiver must be idempotent: every event carries a unique ID, and the receiver records which IDs it has already processed (in a database table or fast key-value store) and short-circuits on a duplicate before repeating any side effect (like charging a card or shipping an order a second time).

On the sending side, a well-behaved webhook sender retries failed deliveries (no 2xx response, or a timeout) using exponential backoff with jitter -- e.g. retry after 1 minute, then 5, then 30, then a few hours, over a bounded total window (commonly up to 24-72 hours) before giving up and marking the delivery permanently failed. This is exactly why the receiver-side idempotency check above is not optional: retries are a NORMAL, expected part of webhook delivery, not an edge case.

• Replay protection: sign a timestamp too, and reject requests outside a short tolerance window (e.g. 5 minutes). • Idempotency: every event has a unique ID; store processed IDs and skip reprocessing a duplicate before it repeats a side effect. • Sender-side retries with exponential backoff + jitter are normal and expected -- design the receiver assuming duplicates WILL happen. • Idempotency storage needs its own retention/expiry policy -- keep processed IDs at least as long as the sender's retry window.

Delivery Logs

Both sides benefit from a delivery log: the sender's dashboard (Stripe's 'Webhooks' page is the canonical example) records every attempt -- timestamp, target URL, response status code, response body/latency, and which retry number it was -- so a developer can see exactly why a delivery failed without needing server access to the receiver. The receiver should log its own side too: which event ID arrived, whether the signature verified, whether it was a duplicate, and what processing outcome resulted, which is often the only way to debug 'why didn't this webhook do anything' days after the fact.

• Sender-side delivery log: every attempt with status code, latency, retry number -- lets developers self-diagnose failed deliveries. • Receiver-side log: event ID, signature result, duplicate/new, processing outcome -- essential for debugging after the fact.

Signature Verification with hash_equals()

file_get_contents('php://input') reads the exact raw body bytes the signature was computed over -- $_POST may have already normalized/re-encoded it. hash_equals() runs in constant time regardless of where the strings first differ, closing the timing side-channel. Reject with 401 immediately on mismatch, before touching the payload's contents at all.

$payload = file_get_contents('php://input'); // RAW bytes, not $_POST
$signatureHeader = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');

$expected = hash_hmac('sha256', $payload, $secret);

if (!hash_equals($expected, $signatureHeader)) {
    http_response_code(401);
    exit;
}

Code Examples

The receiver checks the timestamp window first (cheap, rejects replays before any HMAC work), verifies the signature over timestamp+body, then checks the event ID against a processed-events store before running any side effect, and logs every outcome for later debugging. Expected result: 200 OK on success or duplicate; 401 on bad signature/stale timestamp; 500 on genuine processing failure (triggers sender retry) Returning 200 for an already-processed duplicate is deliberate -- it tells the sender the delivery succeeded so it stops retrying, even though no new work was done this time.

if (ProcessedEvents::exists($eventId)) {

<?php
declare(strict_types=1);

const TOLERANCE_SECONDS = 300; // 5-minute replay window

function verifyAndParseWebhook(string $rawBody, string $sigHeader, string $secret): array
{
    // sigHeader format: "t=1699999999,v1=abcdef123..."
    parse_str(str_replace(',', '&', $sigHeader), $parts);
    $timestamp = (int) ($parts['t'] ?? 0);
    $signature = $parts['v1'] ?? '';

    // Reject stale or future-dated requests — closes the replay window
    if (abs(time() - $timestamp) > TOLERANCE_SECONDS) {
        throw new WebhookException('Timestamp outside tolerance window', 401);
    }

    // Sign timestamp + body together, exactly as the sender does
    $signedContent = $timestamp . '.' . $rawBody;
    $expected = hash_hmac('sha256', $signedContent, $secret);

    if (!hash_equals($expected, $signature)) {
        throw new WebhookException('Invalid signature', 401);
    }

    return json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
}

function handleWebhook(): void
{
    $rawBody = file_get_contents('php://input');
    $sigHeader = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

    try {
        $event = verifyAndParseWebhook($rawBody, $sigHeader, getenv('WEBHOOK_SECRET'));
 
…

Webhook receiver decision flow

Each gate rejects a different failure mode: replay, forgery, duplication, and transient processing failure.

Request in
   |
   v
[timestamp within tolerance?] --no--> 401 reject (stale/replayed)
   |yes
   v
[HMAC signature matches?] --no--> 401 reject (forged)
   |yes
   v
[event ID already processed?] --yes--> 200 OK (skip, log duplicate)
   |no
   v
[run side effect] --fails--> 500 (sender retries later)
   |succeeds
   v
mark event ID processed -> 200 OK

Important Rules

Always verify the signature over the RAW request body bytes, computed with hash_equals(), never a normal string comparison.

Sign and check a timestamp alongside the payload; reject requests outside a short tolerance window to defeat replay.

Treat every event as potentially duplicated -- store processed event IDs and skip reprocessing before any side effect runs.

Return 200 for a successfully-handled duplicate so the sender stops retrying, and a 5xx only for genuine processing failures you want retried.

Log both the verification outcome and the processing outcome for every delivery, not just failures -- you'll need the full picture when debugging weeks later.

Stripe's webhook signing scheme

Stripe's Stripe-Signature header is the direct model for the timestamp+HMAC pattern shown above: it carries a t= timestamp and one or more v1= signatures, computed over the string 't={timestamp}.{payload}', and Stripe's own client libraries provide a constructEvent() helper that does exactly the timestamp-tolerance check and hash_equals()-based comparison this lesson builds by hand, plus Stripe's dashboard shows a full delivery log with response codes and retry attempts for every webhook endpoint you register.

Comparing the computed and received signatures with === or == instead of hash_equals().

Always use hash_equals($expected, $received) for signature comparison.

A naive string comparison exits as soon as it finds a mismatched byte, and the resulting timing difference is a real, exploitable side channel that lets an attacker reconstruct a valid signature over repeated attempts.

Processing the event's side effect (e.g. marking an order paid) before checking whether that event ID was already processed.

Check ProcessedEvents::exists($eventId) first and skip processing entirely on a hit, before any side effect runs.

Sender retries are normal and expected; without an idempotency check up front, a retried delivery of a legitimate event re-runs the side effect a second time (double-charging, double-shipping) exactly as if it were an attack.

Why must webhook signature comparison use hash_equals() instead of ==? A. hash_equals() is faster for large payloads B. == does not work with strings in PHP C. hash_equals() runs in constant time, preventing a timing side-channel that == 's early-exit comparison would expose D. hash_equals() automatically retries failed comparisons

C. hash_equals() runs in constant time, preventing a timing side-channel that == 's early-exit comparison would expose — A standard string comparison exits on the first mismatched byte, leaking timing information; hash_equals() takes the same amount of time regardless of where a mismatch occurs, closing that side channel.

A webhook receiver gets the exact same event ID delivered twice because the sender's retry fired after a response was lost in transit. What should happen? A. The second delivery should be rejected with a 401 as a forged request B. The receiver should reprocess the event fully both times to be safe C. The receiver should recognize the event ID as already processed, skip the side effect, and still return 200 D. The receiver should ignore all future webhooks from that sender

C. The receiver should recognize the event ID as already processed, skip the side effect, and still return 200 — Idempotent processing means recognizing a duplicate by its event ID, skipping the side effect a second time, and still returning success so the sender knows delivery is complete and stops retrying.

Extend the ProcessedEvents idempotency store concept to also store the HTTP response status your receiver returned for each event ID, and change handleWebhook() so a duplicate delivery replays the SAME status code it returned the first time (not always 200) — useful when the first attempt legitimately failed with a 500 and you want the sender's retry to still trigger reprocessing rather than being treated as a satisfied duplicate.

The current design already achieves this: ProcessedEvents::markProcessed($eventId) is only called after processOrderEvent() succeeds. A failed attempt never marks the ID, so ProcessedEvents::exists() returns false on retry and processing genuinely re-runs -- no change needed beyond confirming markProcessed() is called only on the success path, never in a finally block.

Only mark an event ID as processed in ProcessedEvents on a genuine SUCCESS; on failure, leave it unmarked entirely so the existence check in the current code already lets a retry through naturally.