Stripe
How the website takes subscriptions and one-time payments with Stripe's hosted Checkout, and which Stripe events actually move money. Read it before you add a web plan, change a price, or touch a Stripe webhook.
How it works
- Step 4. UPG sends the amount from our price list (
price_data), not a Stripe Price id. A price change is a change in our panel, not in two places. - Step 8 and 9. Coming back from Stripe proves nothing. The return page polls until the server has the result. The webhook can land before or after the browser returns.
- The browser never talks to UPG and never holds a key.
Which Stripe product
| Stripe product | We use it? | Why |
|---|---|---|
| Checkout Session (hosted page) | Yes | Card data never touches us (PCI SAQ-A), wallets and 3-D Secure built in, works in a popup |
| Payment Element / Elements | No | Embedded card form; more work and more PCI scope for no gain at our size |
| Payment Intents directly | No | Only needed under Elements |
| Customer Portal | Planned for "manage" | Cancel runs on our server today |
| Stripe Tax | Not yet | Not verified for every selling entity. Decide per entity before launch |
Checkout modes: subscription for renewing plans, payment for one-time and lifetime
plans.
How to build it
Keys and accounts
- One restricted key per environment, with only the permissions UPG needs. Never the secret key.
- Test mode and live mode are separate connections with separate keys and separate webhook endpoint secrets. Each environment has its own endpoint secret; one environment's secret does not work for another.
- Pin the Stripe API version in config and check it is a version Stripe accepts.
Events: webhook or EventBridge
Stripe can deliver events by webhook or into Amazon EventBridge. We support both, but only one per Stripe account. EventBridge into SQS means no public endpoint and free retries through the dead-letter queue.
Webhook rules:
- Verify the signature on the raw request bytes, before any JSON parsing.
- Answer 200 fast, then process. Store first, apply later.
- Dedupe on the event id. Stripe retries for up to 3 days.
The events that matter
| Event | What we do |
|---|---|
checkout.session.completed | Activates the purchase |
invoice.paid | Books a renewal, with amount_paid. With billing reason subscription_update it is a plan change, not a renewal |
customer.subscription.updated | State changes. The period moving on this event is not a payment. Stripe moves the period about an hour before it charges |
customer.subscription.deleted | Ends the subscription |
charge.refunded | Refund, full or partial. The subscription stays active on Stripe; we revoke from the event |
charge.dispute.created | Revoke on a real dispute (not an inquiry). Restore if won |
invoice.payment_failed | Grace starts |
invoice.*, charge.* and dispute events name the subscription only by id. Read the
subscription back with the account's key, or the event maps to nothing and is dropped.
Use previous_attributes on updated events to tell "auto-renew switched back on" from
a new activation.
Trials and upgrades
- A trial is
trial_period_dayswith a card on file. A trial starts free, so do not quote a registration charge. - Upgrading a trialing subscription: Stripe refuses
billing_cycle_anchor=now. End the trial (trial_end=now) with no proration instead. - Every plan change gets an idempotency key made from the client's key, the subscription and the target plan. A retry after Stripe already moved must apply our state, not charge again.
Naming
Checkout shows a display name like "Pro Plan". Derive it when building the session. Never rename the product id: webhooks map back to the plan through it, and a rename orphans every existing subscription.
Rules
Never put a Stripe Price id in a plan mapping. Stripe would charge Stripe's amount, not the plan's price, and the two drift apart.
Map Stripe errors to a 502 rail_error and log them at warn with Stripe's message. We
once returned Stripe's 400 to the caller as their own invalid_request, with no log
line, and lost a day.
3-decimal currencies (KWD, BHD, OMR) must be sent to Stripe as amounts in tens. Read Stripe's currency notes before adding a currency.
Mistakes we made
- The first real sandbox purchase failed twice. Config named an API version Stripe rejects, and verify expanded five levels deep when Stripe allows four.
- Refunds, disputes and renewals were silently dropped. Those events carry only the subscription id, and the adapter did not read it back. Nothing errored.
- A renewal was booked an hour early, then again. The period moved on
customer.subscription.updatedbefore the charge. Book money only oninvoice.paid. - One-time purchases were booked twice. Verify and the webhook used different ids (the payment intent vs the charge). Both now key on the charge.
- Upgrading during a trial failed three times on dev. See "Trials and upgrades".
Checklist
- Restricted key per environment; test and live are separate connections.
- Each environment has its own webhook secret, or EventBridge, not both.
- Signature checked on raw bytes; events deduped on id.
- Amounts come from our price list as
price_data. - Money is booked only from
invoice.paid,checkout.session.completedand refund or dispute events. - Refunds and disputes revoke from the event, not from the subscription status.
- Plan changes carry an idempotency key.
- The return page polls the server; the browser never calls UPG.