Reliable Stripe webhook processing in PHP: state, retries, and idempotency
A production-focused design for verifying Stripe events, acknowledging quickly, deduplicating safely, and updating local payment or subscription state without trusting browser redirects.
A Stripe integration is not complete when the browser reaches a success page. Payment and subscription state can change asynchronously, events can be retried, delivery order is not a business guarantee, and the same event may reach the endpoint more than once. A reliable PHP application treats the webhook as an authenticated message about provider state and reconciles that message with its own business records.
This guide was reviewed on July 26, 2026. The current Stripe API version recorded for the review is 2026-02-25.clover. Stripe recommends Checkout Sessions for most on-session web payments, Billing APIs with Checkout for subscriptions, Setup Intents for saving a payment method, and PaymentIntents when the application genuinely needs to model a lower-level or off-session payment flow. The webhook design below applies across those integration surfaces.
Choose the payment API before designing events
Webhook complexity often begins with the wrong API. For a normal one-time web checkout, start with a Stripe-hosted or embedded Checkout Session. For recurring products, use Stripe Billing and a Checkout Session in subscription mode; do not build a manual renewal loop from raw PaymentIntents. For a custom embedded payment interface, prefer the Payment Element backed by Checkout Sessions where the product requirements allow it. Saving a payment method for later belongs in a Setup Intent.
Avoid new work on the Charges API, Sources API, Tokens API, or legacy Card Element. These older surfaces force the application to own more payment behavior and are not the recommended starting point. Selecting the current higher-level API reduces the number of states the PHP application has to invent.
Separate checkout intent from business truth
The application should create a local order, subscription request, or billing operation before redirecting the customer. Give that record an internal identifier and include a safe reference in Stripe metadata or the client reference field supported by the chosen API. Do not put confidential data in metadata; store only the identifier needed to reconnect provider state to the local record.
The return URL improves the customer experience, but it does not grant an entitlement. A user can close the tab, a redirect can fail, or a malicious client can request the URL directly. The server should display a pending or current status from its own database and let verified provider events advance that status.
Model states in business language. “Awaiting payment,” “active,” “past due,” “canceled,” and “refunded” are clearer than copying every provider object status into one column. Retain the relevant Stripe object and customer identifiers separately so the application can retrieve authoritative objects when reconciliation is required.
Verify the exact raw request
Stripe signs the webhook payload. Signature verification must use the unmodified raw request body, the signature header, and the endpoint secret for the current environment. If a framework parses JSON and then re-encodes it, the bytes differ and verification fails. Read or preserve the raw body before normal request parsing.
Use the maintained Stripe PHP SDK and its webhook verification helper. Reject a missing signature, invalid signature, malformed payload, or unacceptable timestamp before changing local state. Test and live endpoint secrets are different; keep them in private environment configuration and make it difficult for an application instance to combine the wrong endpoint with the wrong database.
A verified signature authenticates delivery from Stripe. It does not mean every event type should be processed or that every object belongs to the expected account or mode. Maintain an explicit allowlist of event types, confirm live or test mode, and validate the relevant object identifiers against the local record.
Acknowledge delivery quickly
The webhook endpoint should perform the minimum synchronous work required to authenticate, record, and accept the event. Slow email delivery, document generation, media work, or calls to other providers should run after acceptance through a durable job mechanism where the application has one. Returning a successful response quickly reduces avoidable retries.
Do not return success before the event is durably recorded if losing that event could lose business state. A practical pattern is one short database transaction that inserts the provider event ID into a table with a unique constraint, records its type and safe object references, and creates or marks work for processing. The endpoint can then acknowledge the event.
Make duplicate delivery harmless
Stripe can deliver an event more than once. Use the event ID as an idempotency boundary and enforce uniqueness in the database, not only in an in-memory check. If inserting the event record conflicts with an existing ID, return success after confirming it was already accepted. This prevents two PHP workers from passing a check at the same time.
Event deduplication is necessary but not always sufficient. Two different events can describe transitions affecting the same subscription or payment. The state update itself should be conditional and repeatable. For example, creating an entitlement should use a unique business key, and moving an order into a terminal state should not recreate side effects every time the handler runs.
When the application initiates a Stripe write, use an idempotency key based on the local operation. That protects the outbound request from network retries. The outbound idempotency key and inbound event ID solve different problems and should both be visible in protected operational records.
Do not assume event order
Network delivery order should not define business logic. An invoice event and subscription event may arrive in an order different from the interface journey. A robust handler uses the event as a trigger, then applies an update only when it is valid for the current local record. When a decision requires the latest provider truth, retrieve the current Stripe object with the server-side SDK instead of constructing a story from arrival order.
Keep this reconciliation targeted. Fetching multiple provider objects for every event makes the handler slow and vulnerable to a provider incident. Know which event payloads contain enough information, which transitions need retrieval, and which can wait for an asynchronous reconciliation job.
Use transactions for local consistency
Group the local event record, state transition, and durable side-effect request in one database transaction where possible. Do not send email or call another provider inside that transaction; long network work holds locks and creates ambiguous failures. Instead, write an outbox or job row that a worker can process after commit.
If processing fails after acceptance, record an attempt count and bounded error summary, then retry through the job system. Distinguish a temporary failure from a permanent data problem. An unknown local reference, wrong mode, or invalid transition should move to an operator-visible state rather than retry forever.
Protect the endpoint and its records
- Use HTTPS and a dedicated, unguessable secret value supplied by Stripe.
- Rate limits can reduce abuse, but they must not block legitimate retry bursts.
- Do not require a browser session or CSRF token; signature verification is the authentication boundary.
- Store only the payload fields needed for audit and recovery, under an explicit retention policy.
- Redact secrets and sensitive customer data from logs and public error responses.
- Keep test and live event records distinguishable.
- Restrict any replay or manual-reconciliation control to authorized operators and audit its use.
Test the lifecycle, not one event
Use the Stripe CLI or the supported test tooling to forward events to the local endpoint and verify signature handling with the correct secret. Exercise duplicate delivery, invalid signatures, missing local records, delayed processing, database failure, and worker retry. For subscriptions, test the initial checkout, trial behavior if used, renewal, failed renewal, payment-method update, cancellation, and reactivation rules defined by the product.
Confirm that the browser sees pending state before the webhook and current state after processing. Verify that refreshing the success URL cannot create a second order or entitlement. Review protected records to ensure an operator can connect the local business record, outbound Stripe request, provider objects, and inbound event without viewing full confidential payloads.
Production checklist
- Use the current recommended Stripe API and maintained PHP SDK for the product model.
- Create a local business record before sending the customer to checkout.
- Verify the raw payload and signature before parsing business fields.
- Allowlist event types and confirm environment and object ownership.
- Enforce a unique provider event ID in the database.
- Make state transitions and side effects repeatable.
- Acknowledge after durable recording, then process slow work asynchronously.
- Handle out-of-order events through conditional updates or targeted retrieval.
- Expose failed processing to authorized operators with a safe replay path.
- Test the full payment or subscription lifecycle before enabling live traffic.
Reliable payment processing is a state-management problem with an authenticated transport. When the PHP application owns its business states, records every accepted event once, and treats provider delivery as retryable and unordered, a webhook becomes an auditable integration boundary instead of a fragile callback.
