Blog

Integrations 9 min read

Stripe webhooks in PHP: a reliable handler you can trust

A Stripe webhook that works on your laptop is not yet a webhook you can trust with money. This guide shows how to verify signatures, acknowledge fast, deduplicate events and handle retries in plain PHP and Laravel, with code you can paste into a project.

Cover illustration: Stripe webhooks in PHP: a reliable handler you can trust

TL;DR

  • Treat the webhook, not the browser redirect, as the source of truth for payment status.
  • Read the raw request body and verify the Stripe-Signature header before touching the payload.
  • Return a 2xx response quickly and do the real work in a queue or after storing the event.
  • Store the event ID with a unique index: Stripe may deliver the same event more than once and out of order.
  • Test with the Stripe CLI (stripe listen and stripe trigger) before going live.

If you accept payments with Stripe, Stripe webhooks in PHP are the part that decides whether orders are marked as paid, subscriptions are activated and refunds are reflected in your database. The checkout page is only the front door: customers close tabs, banks confirm late and some payment methods settle minutes or days later. The webhook is the message from Stripe that says what really happened.

We build and maintain payment integrations for clients and for our own products (for example Bill, which takes Stripe and PayPal payments on a public pay page). This article is the checklist we follow when we write a Stripe webhook endpoint in PHP.

How Stripe webhooks work in one minute

You register an HTTPS URL in the Stripe Dashboard (or via the API) and choose which event types to receive. When something happens - a Checkout Session completes, an invoice is paid, a charge is refunded - Stripe sends a POST request with a JSON Event object to that URL. Your server must answer with a 2xx status code. If it does not, Stripe retries with exponential backoff for up to three days in live mode.

Three facts shape the whole design:

  • Events can arrive more than once. Retries and network issues make duplicates normal.
  • Events can arrive out of order. You may see invoice.paid before customer.subscription.created.
  • Anyone can POST to your URL. Without signature verification an attacker can mark their own order as paid.

Step 1. Install the SDK and create the endpoint

Use the official library, stripe/stripe-php, and keep the endpoint secret (it starts with whsec_) in an environment variable, never in the repository.

composer require stripe/stripe-php

# .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

Each webhook endpoint in the Dashboard has its own signing secret, and the secret from stripe listen in development is different again. A very common "signature verification failed" bug is simply using the wrong one.

Step 2. Verify the signature on the raw body

Stripe signs every request. The Stripe-Signature header contains a timestamp and one or more signatures computed as an HMAC-SHA256 of timestamp.payload with your endpoint secret. The library checks the signature and rejects events older than five minutes by default.

The important detail: verification needs the exact raw bytes Stripe sent. If a framework parses the JSON and you re-encode it, whitespace changes and the signature no longer matches.

<?php
// public/webhooks/stripe.php (plain PHP)
require __DIR__ . '/../../vendor/autoload.php';

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

try {
    $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\UnexpectedValueException $e) {
    http_response_code(400);   // invalid JSON
    exit;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    http_response_code(400);   // bad or missing signature
    exit;
}

In Laravel, read $request->getContent() instead of $request->all(), and exclude the route from CSRF protection (webhooks do not carry a CSRF token; the signature is the protection). Put the route outside the web session middleware group if you can.

Step 3. Make the handler idempotent

Idempotent means: processing the same event twice gives the same result as processing it once. The simplest reliable approach is a table of processed event IDs with a unique index. Insert first; if the insert fails because the ID exists, you have already seen the event.

CREATE TABLE stripe_events (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  event_id      VARCHAR(64) NOT NULL,
  type          VARCHAR(100) NOT NULL,
  payload       JSON NOT NULL,
  status        ENUM('received','processed','failed') NOT NULL DEFAULT 'received',
  received_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  processed_at  TIMESTAMP NULL,
  UNIQUE KEY uniq_event (event_id)
);
try {
    $pdo->prepare('INSERT INTO stripe_events (event_id, type, payload) VALUES (?, ?, ?)')
        ->execute([$event->id, $event->type, $payload]);
} catch (PDOException $e) {
    if ($e->getCode() === '23000') {   // duplicate key: already received
        http_response_code(200);
        exit;
    }
    throw $e;
}

Storing the raw payload gives you an audit trail: when a customer asks "why was I charged twice?", you can replay exactly what Stripe told you. It also makes it possible to reprocess failed events after you fix a bug.

Step 4. Acknowledge fast, process separately

Stripe expects a response within a few seconds. If your handler sends emails, calls other APIs or generates PDFs inline, it will time out, Stripe will retry, and you will do the same work again. Do the minimum in the request - verify, store, respond 200 - and run the real logic in a queue worker (Laravel queues, Symfony Messenger, a cron job reading status = 'received', or a supervisor-managed worker).

If the event cannot be stored at all (database down), return a 5xx status. That is the one case where you want Stripe to retry later.

Step 5. Handle the events that matter

You do not need every event type. Subscribe only to what your business logic uses; fewer subscriptions means less noise and a smaller attack surface. A typical set:

  • checkout.session.completed - the customer finished Checkout. Look up your order using the client_reference_id or metadata you set when creating the session.
  • payment_intent.succeeded and payment_intent.payment_failed - for custom payment flows.
  • invoice.paid and invoice.payment_failed - subscription billing.
  • customer.subscription.updated and customer.subscription.deleted - plan changes and cancellations.
  • charge.refunded and charge.dispute.created - money going back out.
switch ($event->type) {
    case 'checkout.session.completed':
        $session = $event->data->object;
        $orderId = $session->client_reference_id;
        if ($session->payment_status === 'paid') {
            markOrderPaid($orderId, $session->payment_intent);
        }
        break;

    case 'invoice.payment_failed':
        notifyBillingProblem($event->data->object->customer);
        break;

    case 'charge.refunded':
        markOrderRefunded($event->data->object->payment_intent);
        break;

    default:
        // unknown or unsubscribed type: acknowledge and ignore
        break;
}

Two habits prevent most bugs. First, never trust amounts from the browser: compare the paid amount and currency in the event with the order in your database. Second, because events can be out of order, make state changes monotonic: do not move an order from "paid" back to "pending" because an older event arrived late. When in doubt, fetch the current object from the API instead of relying on the event snapshot.

Step 6. Test locally with the Stripe CLI

The Stripe CLI forwards real test-mode events to your machine, so you do not need a public tunnel:

stripe login
stripe listen --forward-to http://localhost:8000/webhooks/stripe
# in another terminal:
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

stripe listen prints a temporary whsec_ secret. Put that into your local .env. Also test the unhappy paths on purpose: send a request with a wrong signature (expect 400), send the same event twice (expect one database effect) and stop your queue worker for a minute to see that events pile up instead of disappearing.

Common mistakes we see in audits

  • Using the redirect as proof of payment. The success_url page can be opened by anyone; show "confirming your payment" until the webhook arrives.
  • Parsing JSON before verification. Re-encoded bodies break the signature; framework body parsers are a frequent culprit.
  • One secret for test and live. Test and live mode have separate keys and separate webhook endpoints.
  • No logging. Without stored events and a visible failure channel, payment bugs are found by customers.
  • Slow handlers. Anything over a few seconds triggers retries and duplicate work.
  • Blocking Stripe at the firewall or WAF. If you use Cloudflare or another WAF, allow the webhook path and check that bot rules are not returning 403 to Stripe's servers.

Monitoring: how you know it keeps working

A webhook can break months after launch: a deployed change renames a column, a secret is rotated, a certificate expires. The Stripe Dashboard shows delivery attempts and failures per endpoint, and Stripe emails you when an endpoint keeps failing. On your side, alert on three things: a growing count of events in received or failed, a sudden drop in events per hour, and any 4xx or 5xx rate on the webhook route. A small daily reconciliation job that compares paid orders in your database with Stripe's list of successful payments catches the rest.

How we can help

We design and build production payment and webhook integrations with signature checks, idempotency, retries and logs your team can read. If you already have a Stripe flow and want a second pair of eyes, an architecture review or consultation is the fastest way to find the weak spots. For the broader picture of moving critical events out of Zapier or Make, read when no-code breaks, and for another provider see our guide to WayForPay integration in PHP.

Frequently asked questions

Why does Stripe webhook signature verification fail?

The usual causes are using the wrong signing secret (test vs live, CLI vs Dashboard endpoint), verifying a parsed or re-encoded body instead of the raw request body, or a proxy that modifies the payload. Verify against the exact raw bytes with the endpoint's own whsec_ secret.

How do I prevent processing a Stripe event twice?

Store each event ID in a table with a unique index and insert it before doing any work. If the insert fails with a duplicate key, return 200 and skip processing. Also make the business action itself safe to repeat.

What response code should a Stripe webhook return?

Return a 2xx code (200 or 204) as soon as the event is verified and stored. Return 400 for an invalid signature and 5xx only when you could not store the event and want Stripe to retry.

How long does Stripe retry failed webhooks?

In live mode Stripe retries with exponential backoff for up to three days. In test mode the retry window is much shorter, so do not rely on test-mode retries to judge production behaviour.

Can I test Stripe webhooks without a public URL?

Yes. Run stripe listen --forward-to with the Stripe CLI to forward test events to localhost, and use stripe trigger to generate sample events.

Free discovery call

Bring a messy stack or stalled integration - Integrono will map the smartest first move.

You get a senior take on budget traps, dependencies and whether to buy, glue or build - before paid work starts. The CEO stays in the loop.