WP Webhooks / Blog / WordPress integrations
Article · WordPress integrations

Receiving Stripe Webhooks in WordPress Without Getting Spoofed

WordPress Stripe webhook guide: verify the Stripe-Signature header against the raw body, survive canonical redirects, and handle duplicate, out-of-order events.

9 min 2026-08-13
#stripe#webhooks#security

TL;DR: Stripe signs every webhook, and WordPress has three separate ways of breaking that signature before your code ever runs.

  • Verify the Stripe-Signature header against the raw request body — $request->get_body(), never the parsed array.
  • signed_payload is t + "." + 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.

The path a Stripe webhook takes through a WordPress endpointStripe posts a signed JSON event to a registered REST route. Two WordPress behaviours can kill the delivery before any code runs: a canonical redirect answers with a 3xx, which Stripe counts as a failure, and any code that reads the parsed body instead of the raw body breaks the signature. A surviving request has its Stripe-Signature header split into a timestamp and a v1 signature, an HMAC-SHA256 of timestamp dot raw body is computed with the endpoint secret and compared in constant time, and the timestamp is checked against a five minute tolerance. Only then does the handler return 200 and hand the event to a queue, deduplicating on the event id.background queueWordPress REST routeStripebackground queueWordPress REST routeStripea 3xx canonical redirect hereis counted as a failed deliveryalt[signature mismatch or timestamp older than 5min][verified]POST signed JSON eventread RAW body, never the parsed arraysplit Stripe-Signature into t and v1HMAC-SHA256 of "t.rawbody" with whsec secret400, event rejectedenqueue by event id, skip if already seen200 returned before any workfulfil the order out of band
FIG 01 — Where a Stripe webhook survives or dies inside WordPress

/ 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.

BehaviourValueConsequence
Signature tolerance (libraries)300 secondsServer clock drift reads as an auth failure
Automatic retries, live modeUp to 3 days, exponential backoffA 4-hour outage self-heals; a 4-day one does not
Automatic retries, sandbox3 attempts over a few hoursTest behaviour is not production behaviour
Manual resend, Dashboard15 days after event creationYour window to replay by hand
Manual resend, CLI30 days after event creationstripe events resend
Endpoints per account16Not a per-plugin resource — budget it
Redirect responsesAny 3xx counts as failedRegister the resolved URL
TLSv1.2 or v1.3 onlyAn 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.

ConcernInline handlerVerify then queue
Response timeSum of every downstream callOne insert, milliseconds
Slow third partyStripe times out, marks it failedAlready answered 200
Retry arrivesRuns concurrently with attempt oneRejected by the event ID index
Traffic spikeEvery renewal hits at onceDrained at a rate you control
Failure visibilityOnly in Stripe’s delivery logPer-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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.php or 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.

/Footnotes
¹ Header format, signed_payload construction, the v0/v1 scheme rule and the 24-hour secret-roll overlap from Stripe's webhooks documentation.
² Retry windows, the 3xx-is-a-failure rule, the 16-endpoint limit, TLS requirements and the ordering and duplicate guarantees from the same page's event delivery behaviours and best practices sections.
³ Outbound IP ranges for firewall allowlisting are published at docs.stripe.com/ips.
Signature failure triage, including the raw-body requirement, in Stripe's signature verification guide.
FAQ

Things engineers always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

Why does my Stripe webhook signature verification fail in WordPress? +
Almost always because the code hashes a re-encoded body instead of the raw one. Stripe signs the exact bytes it sent, so use $request->get_body() inside a REST route and never wp_json_encode() of get_json_params(). The second most common cause is server clock drift pushing the timestamp outside the 5-minute tolerance.
What header does Stripe use to sign webhooks? +
Stripe-Signature. It contains a t= timestamp and one or more scheme-prefixed signatures. Only v1 is a live scheme; v0 appears on test events and should be ignored to prevent a downgrade attack. Inside WP_REST_Request::get_header() the name is normalised to stripe_signature.
Why is Stripe marking my webhook deliveries as failed when the endpoint works? +
Stripe counts any 3xx response as a failed delivery. WordPress canonical redirects between trailing-slash variants, and host-level http-to-https or www rules, are the usual cause. Register the fully resolved URL and confirm with curl that a POST returns a 2xx or 4xx, never a 301 or 302.
How long does Stripe retry a failed webhook? +
Up to three days with exponential backoff in live mode. Sandbox events are retried three times over a few hours. You can also resend manually for 15 days from the Dashboard or 30 days with the Stripe CLI.
Are Stripe webhook events delivered in order? +
No. Stripe explicitly does not guarantee ordering, and endpoints can receive the same event more than once. Deduplicate on the event ID with a unique database index, and re-read objects from the API rather than reconstructing state from the sequence of events you received.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate