The checkout success page creates a strong temptation. The customer returns with a session identifier, the screen says “payment successful,” and the application immediately unlocks the service.
That design feels complete because it works in a normal demonstration. The problem appears when somebody constructs the return URL themselves, replays an old identifier or reaches the page before the payment provider has finished processing.
Keep customer experience separate from financial authority. The success page communicates progress; the verified server event changes the order.
Let the browser show pending, not decide paid
The browser can safely display “we are confirming your payment.” It can poll a public endpoint that returns a coarse state such as processing, paid, failed or refunded.
It should not create the paid order, calculate the amount or unlock fulfilment based on a query parameter. A checkout session ID is a reference, not proof.
The server should return only enough information for the status screen. Do not expose a customer's email, payment intent, internal notes or provider payload through an unauthenticated status route.
Verify the signed webhook first
Stripe signs webhook deliveries. Verification must use the exact raw request body and the endpoint's signing secret. Parsing and re-serialising the JSON before signature verification can change the bytes and invalidate the check.
After verification, confirm that the event belongs to an order created by your system. A Stripe account can contain unrelated Payment Links and products. An internal order reference in metadata or client_reference_id helps connect the provider event to the intended record.
Check what was actually charged
Do not trust a price or amount sent by the browser. The checkout endpoint should accept a small offer key, then select the permitted Stripe Price ID on the server.
When the paid event arrives, compare the actual currency and Price ID with the order. This catches configuration drift and prevents a cheaper product from fulfilling a more expensive service.
For variable-scope work, avoid forcing a “from £2,500” service into a one-click fixed charge. Confirm scope first or create a specific quote and invoice.
Design for duplicate delivery
Webhook providers retry. Networks time out after your server has already written the order. Two copies of the same event can arrive close together. This is expected behaviour.
Store the provider event ID and make fulfilment atomic. The first valid paid event should transition the order and create the downstream engagement once. A retry should observe the existing result and return success without creating another record.
Use a stable transaction ID for analytics and a stable idempotency key for email. That protects the customer from duplicate invitations and keeps revenue reporting from counting the same purchase twice.
Handle uncomfortable states honestly
Payments can remain asynchronous, fail, expire, refund partially or enter dispute. The customer-facing language should reflect what the system actually knows.
“Payment received, confirming” is truthful before the signed paid event. “Payment confirmed” is appropriate afterward. If confirmation takes longer than expected, promise an email only if the email delivery path genuinely exists and is monitored.
The smallest reliable flow
- The browser requests checkout using an offer key.
- The server selects the approved Stripe Price and creates a pending order.
- Stripe hosts payment and sends its own financial receipt.
- A signed webhook verifies payment, price and currency.
- One transaction marks the order paid and creates fulfilment work.
- The success page reads the server's coarse order state.
This design is slightly less impressive in a five-minute demo because the screen may briefly say “confirming.” It is much more useful in production because every paid state has evidence behind it.