Skip to main content

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 productWe use it?Why
Checkout Session (hosted page)YesCard data never touches us (PCI SAQ-A), wallets and 3-D Secure built in, works in a popup
Payment Element / ElementsNoEmbedded card form; more work and more PCI scope for no gain at our size
Payment Intents directlyNoOnly needed under Elements
Customer PortalPlanned for "manage"Cancel runs on our server today
Stripe TaxNot yetNot 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​

EventWhat we do
checkout.session.completedActivates the purchase
invoice.paidBooks a renewal, with amount_paid. With billing reason subscription_update it is a plan change, not a renewal
customer.subscription.updatedState changes. The period moving on this event is not a payment. Stripe moves the period about an hour before it charges
customer.subscription.deletedEnds the subscription
charge.refundedRefund, full or partial. The subscription stays active on Stripe; we revoke from the event
charge.dispute.createdRevoke on a real dispute (not an inquiry). Restore if won
invoice.payment_failedGrace 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_days with 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​

Rule

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.

Rule

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.

Rule

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.updated before the charge. Book money only on invoice.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.completed and 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.