How to Handle Stripe Webhooks in PHP: Signatures and Retries
Read the raw request body, verify the Stripe-Signature header with your endpoint's whsec_ secret and a five-minute timestamp tolerance, and record the event ID so duplicates are skipped. Apply the change in a transaction and return 2xx only once it is saved; return an error on failure so Stripe retries.
Why webhooks, not the success page
After Checkout, Stripe redirects the customer to your success URL. It is tempting to grant access there, but the redirect is not reliable: the customer can close the tab, lose signal or never return. Some payment methods settle hours or days later. Renewals, failed payments, cancellations, refunds and disputes happen with no browser involved at all.
Webhooks are Stripe telling your server what happened. Treat them as the source of truth for entitlements. The success page can still look up the Checkout Session by its ID and show the right message straight away, but both paths should call the same idempotent function, so whichever arrives first does the work and the other changes nothing.
Verify the signature on the raw body
Anyone can POST JSON to your endpoint. The Stripe-Signature header proves Stripe sent it. Stripe computes an HMAC-SHA256 of timestamp.body with your endpoint's signing secret. The body must be byte-for-byte what Stripe sent, so read php://input and never re-encode decoded JSON before checking.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = getenv('STRIPE_WEBHOOK_SECRET'); // whsec_..., from .env
$timestamp = 0;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't') { $timestamp = (int) $v; }
if ($k === 'v1') { $signatures[] = $v; } // ignore v0 and other schemes
}
if ($timestamp === 0 || abs(time() - $timestamp) > 300) {
http_response_code(400); exit('Stale or missing timestamp');
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
$valid = false;
foreach ($signatures as $sig) {
if (hash_equals($expected, $sig)) { $valid = true; break; }
}
if (!$valid) { http_response_code(400); exit('Bad signature'); }
$event = json_decode($payload, true);
The five-minute tolerance matches Stripe's libraries and limits replays. Keep your server clock in sync with NTP. If you use the official stripe-php library, \Stripe\Webhook::constructEvent($payload, $header, $secret) performs the same checks. Either way, log failures without logging the secret or full card-related payloads.
Make processing idempotent
Stripe can deliver the same event more than once. Record every event ID with a unique constraint and atomically claim it before doing any work:
CREATE TABLE webhook_events (
id INTEGER PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'received', -- received | processing | processed | failed
payload TEXT NOT NULL,
received_at TEXT NOT NULL
);
-- insert if new, then claim it; only one request gets rowCount() = 1
INSERT OR IGNORE INTO webhook_events (event_id, type, payload, received_at) VALUES (?, ?, ?, ?);
UPDATE webhook_events SET status = 'processing'
WHERE event_id = ? AND status IN ('received', 'failed');
If the claim succeeds, run the handler and mark the row processed. If the handler throws, mark it failed and return a non-2xx status so Stripe tries again. Also make the effects idempotent. Stripe notes that two separate events can describe the same object, so a credit grant should carry its own key, such as the invoice or billing period, with a unique index. Then a duplicate cannot grant twice even if it arrives under a different event ID.
Retries, timeouts and ordering
- Retries. In live mode Stripe retries failed deliveries for up to three days with exponential back-off; sandbox events are retried three times over a few hours. Redirects (3xx) count as failures, so register the final URL.
- Respond quickly. Stripe expects a fast 2xx. Short database writes are fine inline. If a handler must call slow services, store the event, return 2xx and finish the work in a background job that reads the stored row.
- Ordering is not guaranteed.
invoice.paidcan arrive beforecustomer.subscription.created. Stripe advises against using the event'screatedtime to decide order. When current state matters, fetch the subscription or invoice from the API and act on that, rather than trusting a possibly older snapshot. - Acknowledge what you ignore. Return 2xx for event types you do not handle, and subscribe only to the types you need.
Which events to subscribe to
For subscriptions, a typical set is checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid (renewals) and invoice.payment_failed (dunning). For one-time digital products add checkout.session.async_payment_succeeded for delayed methods, plus charge.refunded and charge.dispute.created if refunds and disputes should remove access. The guides on Stripe subscriptions in PHP and selling digital downloads show how these map to entitlements.
When creating the endpoint in Stripe Workbench you choose a payload format. A handler that reads $event['data']['object'], like the one above, needs snapshot events; thin events carry a reference that you fetch separately.
Testing locally and going live
stripe login
stripe listen --forward-to http://localhost:8000/webhooks.php
stripe trigger checkout.session.completed
stripe listen prints its own whsec_ secret for local use. Your sandbox endpoint and live endpoint each have different secrets, so keep three values apart and never copy the CLI secret to production. Before launch, replay a delivered event (Dashboard "Resend" or stripe events resend) and confirm nothing is granted twice.
Logging, replays and monitoring
Storing each event's payload, type, status and timestamps pays for itself the first time a customer says "I paid but I have no access". You can see whether the event arrived, whether the signature passed and whether the handler failed, without guessing.
- Keep an admin view of recent events, filterable by status, with a detail page showing the stored payload.
- Watch for failures. Stripe's Event deliveries tab in Workbench shows each attempt and its HTTP status. A run of failures usually means an expired secret, a deploy that broke the endpoint, or a redirect added in front of it.
- Replay safely. Because processing is idempotent, resending an event after you fix a bug should repair the customer's state without side effects. If a replay would double-grant anything, fix that before you need it.
- Protect the data. Webhook payloads contain customer emails and billing details. Treat the table, and backups of it, as personal data, and prune old rows on a schedule that suits your accounting needs.
Sources: Stripe: receive events in your webhook endpoint · Stripe: using webhooks with subscriptions · Stripe: fulfil orders
How Simple Stack handles it
Simple Stack's receiver is public/webhooks.php?provider=stripe, backed by verify_stripe_webhook(), record_webhook_event() and handle_stripe_event() in app/bootstrap.php. It processes events inline with short database writes and no queue, and uses no Stripe SDK.
- Verifies Stripe-Signature with HMAC-SHA256 and hash_equals; timestamps over five minutes old fail
- Stores every event in webhook_events and claims it atomically, so a duplicate runs once
- A failed handler is marked failed and answered with an error, so Stripe retries it
- A delivery that is still processing gets a 409, keeping Stripe's retry cycle alive
- Handles checkout, subscription, invoice paid/failed, refund and dispute events
- Credit grants carry dedupe keys, so a replayed renewal cannot grant credits twice
Questions people ask
Skip the plumbing. Build the product.
Sign-in, Stripe, emails, an admin and a CMS — already built and tested in plain PHP. £49, once, for unlimited projects.