TL;DR: Stripe signs every webhook, and WordPress has three separate ways of breaking that signature before your code ever runs.
- Verify the
Stripe-Signatureheader against the raw request body —$request->get_body(), never the parsed array. signed_payloadist + "." + raw_body, HMAC-SHA256 with the endpoint secret, compared in constant time.- Stripe counts any 3xx as a failed delivery. A trailing-slash redirect in front of your route silently kills every event.
- The official libraries allow a 5-minute timestamp tolerance. Never set it to
0— that disables the recency check entirely. - Delivery is at-least-once and unordered. Deduplicate on the event ID and never assume the sequence.
/ Overview
What does Stripe actually send to your endpoint?
A single POST with a JSON Event object as the body, and a Stripe-Signature header carrying a timestamp and one or more signatures. That header is the entire security model. Without checking it, your endpoint is a public URL that anyone who guesses it can use to mark orders as paid.
The header is a comma-separated list of prefix/value pairs on a single line:
HTTP — the signature header, split for readability
Stripe-Signature: t=1492774577, v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd, v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
t is the Unix timestamp of the delivery attempt. v1 is the live signature scheme. v0 appears only on test events and exists purely to help you test — Stripe's own guidance is to ignore every scheme that is not v1, because accepting v0 is a downgrade attack waiting to happen. You can also see more than one v1 value at once: when you roll an endpoint secret you can keep the old one alive for up to 24 hours, and Stripe signs with both during that window.
/ Raw body
Why does WordPress break Stripe signature verification?
Because the signature is computed over the exact bytes Stripe sent, and almost every convenient way of reading a request body in WordPress hands you something else. Stripe's documentation is blunt about it: any manipulation of the raw body causes verification to fail.
Inside a REST route, $request->get_json_params() gives you a decoded array. Re-encoding that array produces different bytes — different key order, different whitespace, different unicode escaping — and the HMAC will not match. The same goes for $request->get_params(). The only correct source is the raw body:
PHP — reading the body correctly
$payload = $request->get_body(); // correct: untouched bytes $sig = $request->get_header( 'stripe_signature' ); // WRONG — all of these change the bytes: // $payload = wp_json_encode( $request->get_json_params() ); // $payload = wp_json_encode( $request->get_params() );
Note the header name. WP_REST_Request::get_header() normalises header names by lowercasing them and converting dashes to underscores, so Stripe-Signature is looked up as stripe_signature. Passing the literal header name returns null, and a null signature is indistinguishable from an attack in most people's error handling.
/ Routing
How do you register a route WordPress will not redirect?
This is the failure nobody debugs, because nothing appears in your logs at all. Stripe's own delivery status table lists 302 — and any other 3xx — as an error, with the fix being to point the endpoint at the URL the redirect resolves to. WordPress produces those redirects enthusiastically: redirect_canonical() will bounce a request between trailing-slash variants, and a host-level rule will bounce http to https or www to bare.
So the URL you paste into Stripe has to be the final one, byte for byte. Register the route, then confirm what a raw POST to it actually returns:
PHP — the route registration
add_action( 'rest_api_init', function () { register_rest_route( 'your-plugin/v1', '/stripe', [ 'methods' => 'POST', 'callback' => 'your_plugin_stripe_webhook', // The signature IS the authentication. Anything else would // reject Stripe, which sends no cookies and no nonce. 'permission_callback' => '__return_true', ] ); } );
Returning __return_true from a permission_callback is normally a red flag, and on a route that does anything before verifying a signature it still is. It is acceptable here only because the very first statement in the callback is the HMAC check and the route does nothing else. Verify the deployed URL before you trust it:
Shell — confirm no redirect sits in front of the route
curl -si -X POST https://example.com/wp-json/your-plugin/v1/stripe -H 'Content-Type: application/json' -d '{}' | head -n 1 # want: HTTP/2 400 (route reached, signature rejected) # bad: HTTP/2 301 (Stripe will mark every delivery failed)
/ Verification
How do you verify the Stripe-Signature header?
Four steps, and each one has a way to get it subtly wrong. Split the header into its t and v1 parts, build signed_payload by concatenating the timestamp, a literal ., and the raw body, compute an HMAC-SHA256 over it keyed with the endpoint secret, then compare in constant time.
PHP — manual verification, no SDK required
function your_plugin_verify_stripe( $payload, $header, $secret, $tolerance = 300 ) { $timestamp = null; $signatures = []; foreach ( explode( ',', (string) $header ) as $part ) { $pair = explode( '=', trim( $part ), 2 ); if ( 2 !== count( $pair ) ) { continue; } if ( 't' === $pair[0] ) { $timestamp = (int) $pair[1]; } elseif ( 'v1' === $pair[0] ) { // v1 only. Ignoring v0 prevents a downgrade attack. $signatures[] = $pair[1]; } } if ( ! $timestamp || ! $signatures ) { return false; } if ( abs( time() - $timestamp ) > $tolerance ) { return false; // replayed, or the server clock has drifted } $expected = hash_hmac( 'sha256', $timestamp . '.' . $payload, $secret ); foreach ( $signatures as $candidate ) { // hash_equals, never ===, to defeat timing analysis if ( hash_equals( $expected, $candidate ) ) { return true; } } return false; }
The loop over $signatures is not decoration — it is what keeps deliveries flowing through a secret roll, when Stripe sends one v1 per active secret. Code that reads only the first v1 works fine until the day you rotate, then drops half your events for 24 hours.
Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records. — Stripe webhook documentation
/ Tolerance
What does the 5-minute tolerance actually protect?
Replay. The signature proves the payload came from Stripe; it does not prove it came from Stripe just now. Anyone who captures one valid request — from a proxy log, an error report, a misconfigured APM tool — holds a payload and a signature that stay mathematically valid forever. The timestamp is inside the signed string, so it cannot be edited, but it can be re-sent.
Stripe's official libraries default to a 300-second window, and the docs carry an explicit warning against setting it to 0, because zero does not mean "strictest" — it disables the recency check completely. The real operational cost of the check is clock drift: your PHP server compares time() against Stripe's timestamp, so a host whose clock is four minutes slow will reject roughly half of all live traffic with a signature error that looks like a secret mismatch. Run NTP.
| Behaviour | Value | Consequence |
|---|---|---|
| Signature tolerance (libraries) | 300 seconds | Server clock drift reads as an auth failure |
| Automatic retries, live mode | Up to 3 days, exponential backoff | A 4-hour outage self-heals; a 4-day one does not |
| Automatic retries, sandbox | 3 attempts over a few hours | Test behaviour is not production behaviour |
| Manual resend, Dashboard | 15 days after event creation | Your window to replay by hand |
| Manual resend, CLI | 30 days after event creation | stripe events resend |
| Endpoints per account | 16 | Not a per-plugin resource — budget it |
| Redirect responses | Any 3xx counts as failed | Register the resolved URL |
| TLS | v1.2 or v1.3 only | An old terminator drops every delivery |
/ Response
Why must you return 2xx before doing the work?
Because Stripe's timeout is not your business logic's timeout, and the two are in direct conflict. Stripe's instruction is to return a 2xx prior to any complex logic that might cause a timeout — its own example being that you must return 200 before marking an invoice paid in an accounting system.
The reason is compounding. If your handler synchronously calls a CRM, a fulfilment API and an email service, its latency is the sum of three third parties. Any of them being slow turns into a timed-out delivery, which turns into a retry, which arrives while the first attempt is still running — and now you are executing the same fulfilment twice, concurrently. The retry system designed to protect you becomes the thing that double-ships the order.
| Concern | Inline handler | Verify then queue |
|---|---|---|
| Response time | Sum of every downstream call | One insert, milliseconds |
| Slow third party | Stripe times out, marks it failed | Already answered 200 |
| Retry arrives | Runs concurrently with attempt one | Rejected by the event ID index |
| Traffic spike | Every renewal hits at once | Drained at a rate you control |
| Failure visibility | Only in Stripe’s delivery log | Per-attempt record on your side |
Stripe makes the same recommendation directly — configure the handler to process incoming events with an asynchronous queue, because a spike such as the start of the month when every subscription renews will otherwise overwhelm the endpoint host.
/ Duplicates
How do you handle duplicate and out-of-order events?
By assuming both, always. Stripe states plainly that it does not guarantee delivery in the order events were generated, and that endpoints might occasionally receive the same event more than once. Creating a subscription can emit customer.subscription.created, invoice.created, invoice.paid and charge.created — and they can land in any sequence.
The deduplication key is the event ID, and the storage needs to be something with a real uniqueness constraint. A WordPress option or transient is not that:
PHP — idempotent intake
global $wpdb; // Unique index on event_id makes the race impossible, not unlikely. $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->prefix}your_plugin_stripe_events (event_id, type, received_at) VALUES (%s, %s, %d)", $event['id'], $event['type'], time() ) ); if ( 0 === $inserted ) { // Already seen. Acknowledge and stop. return new WP_REST_Response( [ 'duplicate' => true ], 200 ); }
Ordering needs a different answer, because no key can fix it: never derive state from the sequence of arrivals. When the current shape of an object matters, re-read it from the API instead of reconstructing it from the events you happen to have received. Stripe suggests exactly that — retrieve the invoice, charge or subscription with the information from the event you did get.
There is a second, subtler duplicate case worth knowing about. Stripe notes that in some cases two separate Event objects are generated for the same underlying change, with different IDs. An event-ID index will not catch those; the documented identifier is the ID of the object in data.object combined with event.type.
/ Limits
What does Stripe not protect you from?
Four things, and all four are yours to build.
- An unbounded queue. Stripe retries for up to three days with backoff. If your worker is broken for two of those days, the events all arrive when it recovers — a burst, not a trickle. Bound your queue and shed or defer rather than fall over.
- Business-level replay. The 5-minute tolerance stops network replay. It does nothing about a legitimate, correctly signed retry arriving after your handler already granted access. Only idempotency at the point of effect fixes that.
- Endpoint discovery. Signature verification is authentication, not rate limiting. An attacker who finds the URL can still post garbage at it as fast as your host will accept it, and every request costs you a PHP worker and an HMAC. Stripe publishes its outbound IP ranges and recommends allowlisting them at the firewall in addition to checking the signature.
- Your own secret handling. The endpoint secret is a shared key with the same power as the signature it validates. It belongs in
wp-config.phpor the environment — never in the database where an SQL injection or a careless export reaches it, and never in a file under the webroot.
Do not skip verification in a sandbox. Test-mode endpoints get their own secret, and Stripe sends a fake v0 signature alongside the real v1. Code that "works in test" because it accepted v0, or skipped the check when WP_DEBUG was on, ships that behaviour to production unnoticed.
If you need the same discipline for events leaving WordPress rather than arriving, the failure modes are close cousins — see retry policy and exponential backoff and why WordPress webhooks fail silently in production.
signed_payload construction, the v0/v1 scheme rule and the 24-hour secret-roll overlap from Stripe's webhooks documentation.