How-to › Integrate beyond REST

How to verify a Standard Webhooks signature in PHP#

Check the signature, the timestamp and the raw body of an incoming webhook in PHP, and keep two secrets valid so a rotation costs nobody a delivery.

Audience
API consumer
Level
intermediate
Topic
Receive and send webhooks
Languages
PHP
Verified

Your endpoint accepts a webhook, decodes the JSON, re-encodes it to check the signature, and refuses every delivery. The sender signed the bytes it put on the wire, and your framework already parsed and rebuilt them with different key ordering and different whitespace. The payload is identical in meaning and different in bytes.

What you get

You will end up with a verifier that checks the exact bytes received, refuses a stale delivery on the timestamp alone, and accepts either of two secrets during a rotation. This is for you if you receive webhooks in PHP and want the checks written down.

Short answer

Sign and verify over the exact bytes that arrived, joined as id.timestamp.body, with HMAC SHA-256 and a constant-time comparison. Reject a timestamp outside a few minutes before hashing anything. Hold more than one secret so a rotation overlaps, and accept a header that offers several signatures at once.

You will need

PHP 8.1 or later, and a sender that signs to the Standard Webhooks scheme. The three headers are webhook-id, webhook-timestamp and webhook-signature, and the digest is HMAC over the joined string rather than over the body alone.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A gateway that verifies at the edgeMany services receiving from one sender, and a gateway you already runSecrets held at the edge, and services that trust a header they cannot checkOne service, or a sender whose scheme the gateway cannot express
A hand-written verifierYou want the checks visible and the dependency count at zeroForty lines to own, including the parts that are easy to get subtly wrongA maintained library covers the same scheme
A provider SDK verifierYou receive from one provider and use their client alreadyA verifier per provider, each with its own header names and tolerancesYou receive from several senders using one scheme
The standardwebhooks PHP libraryYou want the scheme implemented by the people who wrote itA dependency, and its choices about tolerance and error reportingYou need to accept two secrets, or to log which one matched

The scheme is small enough that the choice is not really about effort. What decides it is what you need to know afterwards. A library answers whether the delivery was valid. A verifier you own can also say which secret matched, which is how you learn that a rotation is complete. It can also report why a delivery failed in words your support team can read.

Verify in the service rather than at the edge unless you have a reason. A service that trusts a header saying the gateway checked has moved the security boundary to whatever else can set that header.

Sign the bytes, not the meaning

The signed content is three fields joined, and the body is the raw payload.

public function sign(string $id, int $timestamp, string $payload, string $keyId): string
{
    $secret = base64_decode($this->keys[$keyId], true);
    $digest = hash_hmac('sha256', "{$id}.{$timestamp}.{$payload}", $secret, true);
    return 'v1,' . base64_encode($digest);
}

Capture the body before anything parses it. In most PHP setups that means reading php://input yourself and keeping the string, because a framework that hands you an array has already thrown the bytes away.1 Some frameworks keep the raw body for you, and it is worth checking which yours does before writing any of this.

The message id is in the signed content for a reason. Without it, a signature captured from one delivery verifies against another delivery with the same body and timestamp, which is exactly a replay. The timestamp narrows the window and the id closes it. The timestamp narrows the window and the id closes it.

Check the clock before the hash

A stale delivery is refused on the timestamp, and that check is cheap.

$age = $now - (int) $timestamp;
if (abs($age) > $this->toleranceSeconds) {
    return ['ok' => false, 'reason' => "timestamp is {$age}s away, tolerance is {$this->toleranceSeconds}s"];
}

Use abs rather than a one-sided comparison. A sender whose clock runs fast produces timestamps in your future, and a one-sided check accepts them forever.

Compare with hash_equals, known signature first and the header’s value second, and never with ==. A byte-by-byte comparison that returns early leaks how much of a guess was right, and a signature is exactly the kind of secret that attack is aimed at.2

Check it worked

Seven deliveries, two of them valid for different reasons.

php demo.php
signed with the current secret   ok       accepted via whsec_2026
signed with the previous secret  ok       accepted via whsec_2025
both offered during rotation     ok       accepted via whsec_2026
body altered after signing       refused  no signature matched a known secret
message id swapped               refused  no signature matched a known secret
replayed an hour later           refused  timestamp is 3600s away, tolerance is 300s
unsigned request                 refused  no signature matched a known secret

The third line is the rotation working. The sender offers both signatures in one header while it migrates, and the receiver accepts on the newer secret and says so. Reading which key matched is how you know when the old secret can be removed, rather than guessing from a date.

The last four lines are four different refusals and only one of them is a clock problem. Keeping them apart matters during an incident. A body that was altered and a body that your own framework re-encoded produce the same digest mismatch. Only the second one is your bug, and it is the likelier of the two.

php -d zend.assertions=1 test.php
ok a signature from the current secret is accepted
ok the previous secret still verifies during rotation
ok one changed byte in the body refuses the message
ok a re-encoded body with the same meaning is refused
ok a replay outside the tolerance is refused on the timestamp alone
ok a non-numeric timestamp is refused before any hashing
6 cases, 0 failures

When it goes wrong

Every delivery is refused and the secret is right. The body was parsed and rebuilt before verification. Read the raw input, verify, and parse afterwards.

Deliveries fail at certain times of day. Clock skew between the sender and your host. Run a time daemon on the receiving host, and widen the tolerance only after you have measured the skew rather than guessed at it.

A rotation drops deliveries. The receiver holds one secret. Accept a list, add the new secret before the sender switches, and remove the old one after the logs show nothing matching it.

Verification passes and the handler runs twice. A signature check is not a duplicate check. Store the message id and reject one you have already processed. Senders retry on any failure, including a timeout after your handler succeeded.

When not to do this

Do not write your own scheme when the sender implements a published one. A custom header order and a custom digest give you the same security and no library, no documentation, and no tests but your own. A published scheme also means a sender can integrate with you in an afternoon.

Do not log the payload on a verification failure. A failing delivery is exactly the one most likely to be hostile, and copying it into your logs puts unvalidated content where people read it.

Do not treat the tolerance as a tuning knob for an unreliable queue. Widening it to an hour to stop retries failing hands an attacker an hour of replay window, and the real fix is in whatever delayed the delivery. Five minutes is the usual figure, and moving it is a decision worth recording.3

Last verified

Verified 2026-09-14 against PHP 8.4.19. Both output blocks are what the preceding command printed.

Footnotes

  1. The stream has a manual entry of two sentences. php://input is a read-only stream that allows you to read raw data from the request body, and it is not available in a POST with enctype="multipart/form-data" when enable_post_data_reading is on. The second sentence describes the one case where PHP has already read the body for you, which is the case this page is about, seen from the other side. ↩︎ Back to text

  2. hash_equals arrived in PHP 5.6 under the manual heading timing attack safe string comparison, and its two parameters are named known_string and user_string in that order. The manual is firm that the user’s string goes second, and explains why further down. When the lengths differ the function returns at once, and the length of the known string may be leaked. A function whose purpose is not to leak keeps one leak, documents it, and asks the caller to arrange the arguments around it. ↩︎ Back to text

  3. The specification the page verifies against gives the tolerance no number. Its verification advice asks for a timestamp within some allowable tolerance of the current time, and stops there. Five minutes does appear twice in the same document. Once it is the suggested lifetime for stored message ids, and once a row in an example retry schedule, between five seconds and thirty minutes. Neither is the tolerance. Stripe’s webhook documentation gives its libraries a default tolerance of 5 minutes, and the specification gives none. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.