---
title: "WordPress Stripe Webhook: Verify Signatures Correctly"
description: "WordPress Stripe webhook guide: verify the Stripe-Signature header against the raw body, survive canonical redirects, and handle duplicate, out-of-order events."
url: "https://wpwebhooks.org/blog/stripe-webhook-wordpress/"
date: "2026-08-13"
---

# WordPress Stripe Webhook: Verify Signatures Correctly

**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](https://docs.stripe.com/api/events) 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.

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.

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

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](https://docs.stripe.com/ips) 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](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/) and [why WordPress webhooks fail silently in production](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-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](https://docs.stripe.com/webhooks).

² 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](https://docs.stripe.com/ips).

⁴ Signature failure triage, including the raw-body requirement, in Stripe's [signature verification guide](https://docs.stripe.com/webhooks/signature).

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"WordPress Stripe Webhook: Verify Signatures Correctly","description":"WordPress Stripe webhook guide: verify the Stripe-Signature header against the raw body, survive canonical redirects, and handle duplicate, out-of-order events.","datePublished":"2026-08-13","dateModified":"2026-08-13","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/stripe-webhook-wordpress/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/og_image.jpg","width":1200,"height":630,"caption":"WordPress Stripe Webhook: Verify Signatures Correctly"},"keywords":["wordpress stripe webhook","stripe webhook signature verification","stripe signature header wordpress","verify stripe webhook php","stripe webhook rest api wordpress","stripe webhook idempotency"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Stripe Webhook: Verify Signatures Correctly","item":"https://wpwebhooks.org/blog/stripe-webhook-wordpress/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why does my Stripe webhook signature verification fail in WordPress?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"What header does Stripe use to sign webhooks?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Why is Stripe marking my webhook deliveries as failed when the endpoint works?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"How long does Stripe retry a failed webhook?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Are Stripe webhook events delivered in order?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}
```
