phpguzzle.org
enrudees
Stripe – Docs

Stripe Webhooks in PHP: Signature Verification and Reliable Handling

What is a Stripe webhook? Does Stripe support webhooks in PHP? Yes, and in any non-trivial integration they do the work your own code can’t. Stripe POSTs a JSON body to one of your URLs every time something notable happens on your account: a payment succeeded, a subscription renewed, a dispute opened, a customer’s card is about to expire. The payload is signed with a shared secret so your server can prove the request came from Stripe and not from someone who guessed the URL. In PHP that signature check is one method call; the surrounding plumbing is where the time goes.

A production endpoint has three moving parts: an HTTP route that reads the raw request body, a call to Stripe\Webhook::constructEvent() that rejects anything with a bad signature, and a dispatcher that returns 200 fast and does the actual work asynchronously. Get any of those wrong and the bugs are either silent (events accepted but not processed) or noisy at 3 a.m. (Stripe retries for three days, your logs fill up with signature failures you only see when someone complains).

This article covers the full working endpoint, the Laravel and Symfony body-parsing traps, retry and idempotency, and local testing with the Stripe CLI. It assumes you already have the SDK installed and API keys configured; if not, start at Stripe payment integration.

A minimal working endpoint

Start with a single file, no framework, no queue. This is the ceremony Stripe needs before any of your business logic runs:

<?php
require __DIR__ . '/vendor/autoload.php';

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = getenv('STRIPE_WEBHOOK_SECRET');

try {
    $event = \Stripe\Webhook::constructEvent($payload, $signature, $secret);
} catch (\Stripe\Exception\UnexpectedValueException $e) {
    http_response_code(400);
    exit('Invalid payload');
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    http_response_code(400);
    exit('Invalid signature');
}

// Signature verified, $event is a Stripe\Event object
error_log("Received {$event->type} ({$event->id})");

http_response_code(200);

Four things to notice.

php://input is the raw, unparsed request body. If you read the body any other way, the signature check fails. That sentence is the single biggest source of webhook bugs in PHP, and half of this article is about the ways frameworks make it hard to honour.

constructEvent throws two different exceptions, both under the \Stripe\Exception\ namespace. UnexpectedValueException (which extends the core PHP class, so catching either works) means the body isn’t valid JSON at all – probably a health check or someone poking at your URL. SignatureVerificationException means the body parsed but the signature is wrong – the secret is wrong, the endpoint is wrong, or something is modifying the body in transit. Log them differently.

The 200 response goes out before you do anything slow. Stripe expects a 2xx before any complex logic that could cause a timeout. If you take twelve seconds to write to three databases and send two emails, Stripe gives up, marks the delivery failed, and retries – now you have two events to deduplicate.

$event->type and $event->id are the two fields you’ll touch in every handler. Type routes to a handler, ID is your idempotency key (see below).

Where to get the webhook secret

From the Stripe Dashboard, under Developers > Webhooks. Create an endpoint, paste your public URL, pick the events you want to receive (start narrow – payment_intent.succeeded, charge.refunded, whatever matches the domain action you care about; you can always add more), and Stripe gives you a signing secret that starts with whsec_.

Test mode and live mode have separate Dashboards and separate secrets. When you flip your STRIPE_SECRET_KEY from test to live, you also flip STRIPE_WEBHOOK_SECRET, and every environment needs its own pair. Mixing them produces the same SignatureVerificationException as a tampered payload, and the error message doesn’t mention mode.

Multiple endpoints in one account have different secrets. If you split receipts and subscriptions into two URLs, each one has its own whsec_. Don’t try to share one env var across both; wire them separately.

Raw body: the Laravel and Symfony traps

Both frameworks have middleware that parses incoming JSON into an array before your controller sees it. That’s normally helpful. For a Stripe webhook it’s fatal: the signature is computed over the byte sequence Stripe sent, and json_decode($body) followed by json_encode($array) is not byte-identical to the original – whitespace, key order, numeric precision all drift.

Laravel. The request body is still available via $request->getContent() (string) even after the framework has populated $request->all(). That string is what you pass to constructEvent:

use Illuminate\Http\Request;

public function handle(Request $request)
{
    $payload = $request->getContent();
    $signature = $request->header('Stripe-Signature', '');
    $secret = config('services.stripe.webhook_secret');

    try {
        $event = \Stripe\Webhook::constructEvent($payload, $signature, $secret);
    } catch (\Stripe\Exception\SignatureVerificationException) {
        abort(400, 'Invalid signature');
    }

    // dispatch based on $event->type
    return response('', 200);
}

Exclude the route from CSRF protection. VerifyCsrfToken returns 419 long before constructEvent gets a chance. In Laravel 11+ this lives in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'stripe/webhook',   // exact path
        'stripe/*',         // or a wildcard for nested routes
    ]);
})

Keep the route out of any auth middleware. Stripe doesn’t send session cookies or bearer tokens, and auth redirects break the raw body as thoroughly as CSRF does.

Symfony. The raw body is $request->getContent() (same method name, different class – Symfony\Component\HttpFoundation\Request). getContent() is safe to call repeatedly. The trap is not consumption – it’s that $request->toArray() followed by json_encode() will not be byte-identical to what Stripe signed (whitespace and key order shift). Pass the raw string to constructEvent, not the re-encoded array.

Idempotency: Stripe retries for three days

Stripe tries to deliver each event for up to three days with exponential backoff in live mode, or three times over a few hours in a sandbox account. Retries happen on any non-2xx response, any timeout, and occasionally on a successful delivery that Stripe’s system couldn’t confirm. The consequence is unavoidable: your handler will see the same event.id more than once, and “process the event” has to mean “process it at most once regardless of how many times it arrives”. Any non-trivial handler has to treat event.id as a deduplication key:

function handleEvent(\Stripe\Event $event, \PDO $db): void
{
    // Insert-or-ignore on event.id. Primary key is the Stripe event ID.
    // PostgreSQL syntax below; on MySQL use INSERT IGNORE INTO ...,
    // on SQLite the ON CONFLICT form works as shown.
    $stmt = $db->prepare(
        'INSERT INTO processed_webhooks (event_id, type, processed_at)
         VALUES (:id, :type, NOW())
         ON CONFLICT (event_id) DO NOTHING'
    );
    $stmt->execute(['id' => $event->id, 'type' => $event->type]);

    if (0 === $stmt->rowCount()) {
        return; // seen before, nothing to do
    }

    match ($event->type) {
        'payment_intent.succeeded' => onPaymentSucceeded($event->data->object),
        'charge.refunded'          => onRefund($event->data->object),
        'customer.subscription.deleted' => onSubscriptionCancelled($event->data->object),
        default => null, // unrouted types are fine; log and move on
    };
}

The pattern: insert first, business logic only on an actual insert. That way the dedup is atomic with your database, not racy in a Redis check-then-act.

For webhooks arriving to a load-balanced fleet, the same table and unique constraint works across machines. For high-volume accounts, partition the table by month and purge old partitions – Stripe’s retries only go three days, so event_id uniqueness beyond a month is dead weight.

Don’t use the Stripe event ID as your own order ID or any other domain key. It’s yours for idempotency, not for routing.

Fast 2xx, real work in a queue

The two-phase pattern:

  1. In the HTTP handler: verify signature, persist event.id and full payload to your DB or queue, return 200 immediately.
  2. In a worker: pick up the row, dispatch on event.type, do the actual work with retries on your side.

The separation matters because your slow code is now your problem, not Stripe’s. Email gateway down? Your queue retries. Database migration locked a table for sixty seconds? Your worker waits, Stripe doesn’t. The webhook endpoint itself does nothing that can fail other than signature verification.

Laravel with the queue:

public function handle(Request $request)
{
    $payload = $request->getContent();
    $signature = $request->header('Stripe-Signature', '');

    try {
        $event = \Stripe\Webhook::constructEvent(
            $payload, $signature, config('services.stripe.webhook_secret')
        );
    } catch (\Stripe\Exception\SignatureVerificationException) {
        abort(400);
    }

    ProcessStripeEvent::dispatch($event->id, $event->type);

    return response('', 200);
}

A production gotcha with this pattern: don’t serialize the Stripe\Event object into the job payload. It drags the entire nested object graph – customer details, payment method, billing address – into Redis or your MySQL queue table. Ship the event ID and type only, then re-fetch from Stripe in the worker with \Stripe\Event::retrieve($id). PII stays on Stripe’s side until the worker actually needs it.

Working with the event payload

Every event has the same envelope:

{
  "id": "evt_1ABCDEf2ghIJK3LmNOPQr456",
  "type": "payment_intent.succeeded",
  "created": 1715775847,
  "data": {
    "object": { ... }           // the resource, varies by type
  },
  "livemode": false,
  "api_version": "2025-03-31.basil"
}

data.object is the thing the event is about – a PaymentIntent, a Charge, a Customer, a Subscription. In the SDK you reach it as $event->data->object, and it’s typed as the corresponding Stripe object so autocompletion works if your IDE indexes the SDK.

For one-time payments the events to route on are payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, and charge.dispute.created – that last one is the one you’ll wish you’d handled before the first chargeback arrives. Checkout integrations add checkout.session.completed and, for async payment methods like SEPA, checkout.session.async_payment_succeeded. Subscription billing is the wordiest surface: customer.subscription.created/updated/deleted, plus invoice.paid and invoice.payment_failed for the actual money movement. And there’s customer.source.expiring, which tells you a week ahead that a card on file is about to stop working – often the event that quietly saves a retention rate.

Subscribe in the Dashboard only to the events you actually handle in code; subscribing to everything makes the event log unreadable when you’re debugging a specific flow. The full, current list lives at docs.stripe.com/api/events/types.

Metadata is the bridge to your own domain. Whenever you create a PaymentIntent, Checkout Session, or Subscription, attach a metadata array with your internal IDs:

$intent = $stripe->paymentIntents->create([
    'amount' => 2500,
    'currency' => 'usd',
    'metadata' => ['order_id' => (string) $order->id],
]);

That metadata rides along on every event about that intent. In the webhook:

$orderId = $event->data->object->metadata['order_id'] ?? null;

No database lookup by Stripe ID, no fragile joins. Metadata values must be strings; cast integers explicitly.

A question that comes up often on Stack Overflow: “In a payment_intent.succeeded webhook, how do I get the session_id of the Checkout Session that created this PaymentIntent?” The answer isn’t expand – the PaymentIntent doesn’t carry a back-reference. Put the session’s own ID or your order ID in the session’s metadata when you create it, and it’ll propagate:

$session = $stripe->checkout->sessions->create([
    'mode' => 'payment',
    'metadata' => ['order_id' => (string) $order->id],
    // ...
]);

The payment_intent.succeeded handler reads the order ID straight off $event->data->object->metadata; no session lookup.

Event ordering is not guaranteed

Stripe’s best-practices page calls this out: delivery order is not guaranteed. In practice:

  • customer.subscription.updated can arrive before customer.subscription.created for the same subscription, on rare occasions.
  • charge.refunded can land before charge.succeeded if you look at the webhook log closely enough.

For most business flows it’s fine – your dedup on event.id plus a retrieve() call at handler time (to get the current state of the object) makes you ordering-independent. If you really need ordering, the pattern is: on each event, retrieve the resource from the API and act on its current state, not on the event’s snapshot:

case 'customer.subscription.updated':
    // Don't trust the event's snapshot; get the current truth.
    $subscription = $stripe->subscriptions->retrieve($event->data->object->id);
    updateLocalStatus($subscription);
    break;

One extra API call; worth it whenever status transitions actually matter.

Local testing with the Stripe CLI

You can’t register http://localhost:8000/stripe/webhook as a Stripe endpoint – Stripe needs a publicly reachable URL. The old answer was ngrok; the current answer is the Stripe CLI, which opens a tunnel specifically for webhooks without exposing your whole local server to the internet:

# One-time login (opens a browser to pair the CLI to your account)
stripe login

# Forward events to your local endpoint
stripe listen --forward-to localhost:8000/stripe/webhook

The CLI prints a webhook signing secret as soon as it starts – it’s different from your Dashboard secrets, specific to this forwarding session. Set it in your local env:

STRIPE_WEBHOOK_SECRET=whsec_xxx_from_cli

Now trigger synthetic events from another terminal:

stripe trigger payment_intent.succeeded
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted

Each one hits your endpoint with a fully-formed, correctly-signed payload. You can run your full handler logic, including database writes and queue dispatch, against it. No test card UI, no need to actually run a checkout.

For time-based events – subscription renewals, trial endings, cancellation at period end – stripe trigger isn’t enough, because those only fire when wall-clock time advances. Use test clocks to simulate time:

# Create a test clock starting at a given timestamp
stripe test_helpers test_clocks create --frozen-time 1735689600

# Advance it by, say, 35 days to cross a monthly billing cycle
stripe test_helpers test_clocks advance <clock_id> --frozen-time 1738713600

Attach the clock to a Customer on creation, and every billing event that resource produces follows the simulated time. Without this, testing a monthly-renewal handler means actually waiting a month, or lying about your system clock.

Reverse-proxy and edge-network pitfalls

The raw-body contract extends past your PHP code. Anything between Stripe and your handler that modifies the bytes breaks the signature. Two patterns to watch.

Cloudflare and similar CDNs can gzip-compress or minify responses by default – that’s fine, it’s outbound. The thing to check is whether your plan applies body transformations to inbound requests. For the webhook path, add a Cloudflare Configuration Rule (or a legacy Page Rule on older plans) that disables optimization features and leaves the body untouched.

nginx proxy_pass is usually transparent, but request-buffering settings combined with modules like sub_filter or a WAF layer occasionally alter the bytes on their way through. If signature checks pass locally but fail behind the proxy, review proxy_buffering, proxy_request_buffering, and any body-rewriting directives on the webhook path.

When diagnosing a production signature failure, the fastest triage is: log the first 50 bytes of $payload along with the signature header, then compare to the payload in the Dashboard webhook logs for that event. If the bytes differ, something in your infrastructure is rewriting the body.

API version skew

Here’s a subtle one that only bites on long-lived integrations. The shape of event payloads is tied to the API version pinned to the endpoint in the Dashboard, not the version you set in your SDK code. If your Dashboard endpoint is pinned to 2020-08-27 and your SDK calls set stripe_version to 2025-03-31.basil, the events Stripe sends you are shaped like 2020, even though your outbound API calls use 2025.

The practical consequence: field names and nesting you see in the current Stripe docs may not match what your endpoint receives. To check what version your endpoint is on, go to Developers > Webhooks > [your endpoint] > API version. To upgrade, change it there; Stripe will replay no events, and from then on new events come in the new shape.

For a fresh endpoint, pin to the same version as the one you use for outbound API calls and keep them in sync. The brittle scenario is inheriting an endpoint from 2019 and discovering three years later that half the fields in your handler code don’t exist in the actual payloads.

Common pitfalls

VerifyCsrfToken returns 419. Laravel-specific. Exclude the webhook route from CSRF; see the Laravel section above.

SignatureVerificationException on every request. Three usual causes, in descending frequency: the endpoint is using the wrong whsec_ (test/live mix or wrong endpoint copy-paste), a middleware is re-encoding the body, or a proxy is modifying it. Log the first bytes of $payload and compare to the Dashboard’s event log.

200 response but events still marked “Failed” in Dashboard. Your endpoint is taking too long. Return 200 before your handler does real work.

Events arrive out of order. Already covered: don’t trust the event’s snapshot, fetch the current state from the API.

Ngrok tunnel keeps breaking. Use the Stripe CLI instead; it’s purpose-built for this and doesn’t expire mid-session.

Next steps

The endpoint is the only way to learn about outcomes that happen outside your request-response cycle: when a SEPA debit actually settles four days after checkout, when a subscription renews next month, when a customer disputes a charge six weeks later. Once it’s in place, adding a new event type is a one-line change in your dispatch map.

If you’d rather not maintain a dispatcher, spatie/laravel-stripe-webhooks wraps this whole pattern – signature verification, Job dispatch per event type – in a Laravel package. The trade-off is waiting for Spatie releases after stripe-php majors; worth it for most projects, not for the ones that live on the edge of the SDK.

Related in this series: Stripe payment integration sets up the PaymentIntents that these webhooks report on. Stripe subscriptions covers the event types specific to recurring billing.

For troubleshooting signature failures, cURL errors, and decline codes, see the Stripe PHP errors reference. Either way, wire up the SDK logger in staging: \Stripe\Stripe::setLogger($psr3Logger). Webhook debugging is faster when you can see the full raw request next to the exception.

Spotted something inaccurate on this page?

Report an error