I was asked to build the Stripe side of a billing module for a multi-tenant SaaS app: plans with a trial, a per-seat upgrade, and one-time credit packs. Someone else was designing the screens and pricing. My job was the integration. I had never used Stripe.
The obvious move is to open the quickstart, paste the sample code and start wiring endpoints. I did the opposite: I spent the first day not writing any app code. This post is the workflow I followed, with the commands, the surprises and the diagrams I ended up with. It should work for any engineer starting a Stripe integration.
Everything in this post lives in a public repo: eshan-one/stripe-billing-101. It has the Day 1 guide with every command, the raw sample responses and event logs, the concepts and the architecture diagrams. Each step below links to the exact files, and there is a full index at the end.
TL;DR
- Learn the object model first (Product, Price, Customer, Subscription, Invoice, Event).
- Do everything in a sandbox with the Stripe CLI before writing code. The CLI makes the same API calls your backend will make.
- Keep
stripe listenopen and read the events. They are the real interface of your integration. - Break it on purpose with test cards. Failure behaviour is where integrations go wrong.
- Turn what you saw into a one-page architecture and a list of questions for whoever owns the product.
Step 1: Read fewer pages, but the right ones
Stripe's docs have a tab for everything. The first page I opened was Payments, Build a payments page. It is a fine page, but it teaches one-time payments. Subscriptions live under Revenue, then Billing.
That cost me ten minutes and was worth it, because it forced me to sort out three words that sound alike:
| What it is | Think of it as | |
|---|---|---|
| Payments | Moving money once | "Take $100 now" |
| Billing | Recurring charges and invoicing | "Charge $100 every month, send invoices, retry failures" |
| Subscription | One record inside Billing | "Acme is on the Pro plan, monthly" |
Billing sits on top of Payments: a subscription creates an invoice each period, and Stripe collects that invoice through the Payments machinery. For my task, plans were Billing and credit packs were Payments.
Step 2: Sandbox and CLI first
Everything below happens in a Stripe sandbox, so no real money moves. I used two terminal tabs from here on.
brew install stripe/stripe-cli/stripe
stripe login
stripe products list --limit 3
Then I created a product in the Dashboard: name Plus, price 8 USD, recurring monthly. The CLI shows what the Dashboard made:
stripe prices list --limit 3
The trimmed responses are in the repo: product.json and price.json. The fields worth understanding:
| Field | Value | Meaning |
|---|---|---|
unit_amount |
800 |
$8.00. Money is in cents. |
recurring.interval |
month |
Charged monthly |
billing_scheme |
per_unit |
Amount = unit price x quantity. This is how per-seat pricing works later. |
lookup_key |
null |
An optional stable name, handy because IDs differ between sandbox and live |
The mental model that clicked: a Product is what you sell, a Price is how much and how often. They are separate objects so you can change a price without touching the product.
Step 3: Keep the event stream open
In the second tab:
stripe listen
It prints Ready! and a webhook signing secret (whsec_..., never commit it). Nothing scrolls until something happens, but from now on every action I take shows up as events. This is the part I would tell every new Stripe engineer to do first: your backend is a consumer of this stream, so look at it before you build the consumer.
Step 4: A subscription with no code
Stripe's guide creates a Checkout Session in server code. The CLI does the same API call:
stripe checkout sessions create \
-d mode=subscription \
-d success_url=https://example.com/success \
-d "line_items[0][price]=<PRICE_ID>" \
-d "line_items[0][quantity]=1"
Two things bit me immediately.
First, zsh and brackets. Without quotes around line_items[0][price]=..., zsh tries to expand the brackets and fails with no matches found. Quote every -d argument that has brackets.
Second, an error I didn't expect (saved as error-managed-payments.json):
{
"error": {
"message": "Invalid line_items[0]: the product tax code is missing ... Product tax code is required for Managed Payments, which is enabled by default on your account. If you want to disable Managed Payments on this session, you can pass managed_payments[enabled]=false ...",
"type": "invalid_request_error"
}
}
The account had Managed Payments on by default, and my test product had no tax code. The error names the workaround, so I added one parameter and changed nothing account-wide:
-d "managed_payments[enabled]=false"
I deliberately did not flip the account setting. As I understand it, Managed Payments changes who the seller of record is, which is a business and tax decision, not something to change to make a command pass. I wrote it down as a question instead (more on that in Step 8).
The same request as curl, which is what your backend does through an SDK. Note it is form-encoded, and only the response is JSON:
curl https://api.stripe.com/v1/checkout/sessions \
-u "$STRIPE_SECRET_KEY:" \
-d mode=subscription \
-d success_url=https://example.com/success \
-d "line_items[0][price]=<PRICE_ID>" \
-d "line_items[0][quantity]=1" \
-d "managed_payments[enabled]=false"
The response is a Checkout Session, and it is worth reading closely because it is not the subscription. It is an open order form (the full response is in the repo):
| Field | Value | Why it matters |
|---|---|---|
url |
https://checkout.stripe.com/... |
The field your backend returns to the frontend, which redirects the browser |
status / payment_status
|
open / unpaid
|
Nothing has been paid yet |
subscription, customer
|
null |
Filled in only after payment |
expires_at |
24 hours after created
|
The link dies after that |
metadata, client_reference_id
|
empty | Your hook back into your own data. Put your user or org ID here. |
I opened the url, paid with the test card 4242 4242 4242 4242, and checked the result:
stripe subscriptions list --limit 1
"status": "active" (see subscription.json). One detail I would have missed in the docs: the billing period dates are on the subscription item, not on the subscription:
"items": { "data": [ {
"current_period_start": 1793904890,
"current_period_end": 1796496890,
"quantity": 1
} ] }
Step 5: Read the event firehose
One successful checkout produced 12 distinct events, and every one appeared twice, once as name and once as v1.name. The v1. copies look like the same events in Stripe's newer format. I haven't confirmed that, so I ignored them for now and learnt the 12 plain names. The raw log is events-first-checkout.log, and the event reference in the guide explains each event.
The lesson that matters most for design: arrival order is not logical order. In my run, invoice.paid arrived before customer.created, and checkout.session.completed arrived before invoice.created. Any code that assumes an order will break.
Of the 12, my backend needs about three:
| Event | Needed? |
|---|---|
customer.subscription.created |
Yes |
invoice.paid |
Yes. Stripe's guide says to wait for this before granting access. |
checkout.session.completed |
Yes, to link the purchase to your own user or org. Not enough on its own for access. |
the other nine (invoice.created, payment_intent.*, charge.succeeded, ...) |
No, the three above carry what you need |
Here is the whole flow as a sequence:
sequenceDiagram
autonumber
actor U as Customer
participant FE as Frontend
participant BE as Your backend
participant ST as Stripe
U->>FE: Click Subscribe
FE->>BE: Create checkout session for a plan
BE->>ST: POST /v1/checkout/sessions with price and metadata
ST-->>BE: session id and url
BE-->>FE: url
FE->>U: Redirect to the Stripe page
U->>ST: Enter card, confirm (3D Secure if asked)
ST-->>U: Redirect to success_url
ST->>BE: Webhooks: customer.subscription.created, invoice.paid, checkout.session.completed
BE->>BE: Verify signature, skip repeats, find the org, save state
FE->>BE: Billing page asks for status
BE-->>FE: plan, status, next billing date
Step 6: Break it on purpose
The happy path teaches you the shape. The failure paths teach you what your database must handle. Stripe has test cards for this. On one Checkout page I tried three in a row:
| Card | What happened |
|---|---|
4000 0000 0000 0002 |
"Your credit card was declined" |
4000 0000 0000 9995 |
Declined for insufficient funds |
4000 0025 0000 3155 |
A test 3D Secure window. I clicked Complete and it succeeded. |
The finding I most wanted was this: a failed first payment creates nothing. No customer, no subscription, no invoice. I checked the Dashboard's Customers list, and only the successful checkouts appeared. In the event stream there were only payment_intent.payment_failed and charge.failed until the payment finally succeeded, and then all the invoice.* and customer.subscription.* events appeared. The full log is events-failed-payments-and-3ds.log.
flowchart TD
A["Customer submits card on the Stripe page"] --> B{"Bank result"}
B -->|"Declined or insufficient funds"| C["payment_intent.payment_failed<br/>charge.failed"]
C --> D["Page shows an error and stays open"]
D --> A
C --> N["No customer, subscription or invoice exists"]
B -->|"Needs authentication"| E["payment_intent.requires_action"]
E --> F["3D Secure window: Complete"]
F --> G["charge.succeeded<br/>payment_intent.succeeded"]
B -->|"Approved"| G
G --> H["customer.subscription.created<br/>invoice.paid<br/>checkout.session.completed"]
H --> I["Backend webhook saves state"]
That tells me a failed sign-up needs no cleanup code, and that invoice.payment_failed, the event Stripe's guide lists, belongs to renewals, not to the first payment. I haven't tested renewals yet, so I mark that as expected, not verified.
Step 7: Turn it into an architecture
After a few hours the picture was clear enough to draw. Purple boxes are the backend code I'd need to write. Everything else is frontend work or hosted by Stripe.
flowchart LR
subgraph FE["Your frontend"]
A1["1. Click Subscribe<br/>plan or credit pack"]
A4["4. Redirect browser<br/>to the Stripe URL"]
A10["10. Billing page<br/>asks backend for status"]
end
subgraph BE["Your backend (you write this)"]
B2["2. Create session endpoint<br/>org ID in metadata"]
B8["8. Webhook endpoint<br/>verify, dedupe, route"]
B9["9. Update your DB<br/>plan, status, credits"]
end
subgraph ST["Stripe (hosted, no code)"]
S3["3. Checkout Session<br/>returns a payment URL"]
S5["5. Customer pays<br/>card, 3D Secure"]
S6["6. Creates customer,<br/>subscription, invoice"]
S7["7. Sends webhook events"]
end
A1 --> B2
B2 --> S3
B2 -->|"returns url"| A4
A4 --> S5
S5 --> S6 --> S7 --> B8 --> B9
A10 -->|"reads"| B9
classDef code fill:#EEEDFE,stroke:#534AB7,color:#26215C
class B2,B8,B9 code
This diagram and four more (including the Stripe object model and the expected renewal flow) are in architecture.md. Only three boxes are mine. And the experiments turned into design rules before I'd written a line:
-
Webhooks are the source of truth, not the redirect to
success_url. The browser can close, lie or be slow. - Don't rely on event order. Read the payload.
-
Deduplicate on the
evt_ID. Events can repeat. -
Verify the signature with the
whsec_secret. -
A webhook has no login and no user. So I must put my own user or org ID on the Stripe objects (
metadataorclient_reference_id) when I create the session. Otherwise the webhook cannot tell me whose payment it is. -
Wait for
invoice.paid, not justcheckout.session.completed, before granting access.
Step 8: Write down what you don't know
This is the step I think separates a tidy integration from a painful one. I did not guess; I kept a list of real decisions that belong to whoever owns the product, each with the assumption I'd work with meanwhile:
| Question | Working assumption |
|---|---|
| Is the "Team" plan flat or per seat? The mock-up said both. | Per seat (a quantity on a per-unit price) |
| Hosted Checkout, or an embedded payment form inside our app? | Hosted Checkout for v1 |
| Managed Payments on or off? | Off per session in the sandbox. Not my call for the account. |
| Which payment methods? Checkout offered six. | Cards only, to confirm |
Use lookup_keys so code doesn't hard-code IDs? |
Yes |
A question with a default is cheap to answer. "What should we do about billing?" is not.
What I haven't done yet
I'd rather say so than imply the job is finished. Not yet run: the trial period, test clocks (to simulate renewals and failed renewals, which is where invoice.payment_failed should show up), the customer portal, per-seat quantities with proration, one-time credit packs, and auto top-up with a saved card. The commands for these are written and marked "not yet run" in the repo, and a coverage table shows which sections of Stripe's own guide I have and haven't covered.
The workflow, in one page
- Sort out the vocabulary (Payments vs Billing vs Subscription, Product vs Price).
- Sandbox plus CLI. No app code.
- Create a product and price, and read the raw JSON.
- Keep
stripe listenopen all the time. - Make a subscription through the CLI, then read the Checkout Session and the Subscription.
- Learn which events matter and ignore the rest.
- Break it with test cards. Note what is not created.
- Draw who does what. Derive rules from what you observed.
- Write the open questions with defaults. Mark what you haven't verified.
Pitfalls I hit
| Symptom | Cause and fix |
|---|---|
zsh: no matches found: line_items[0]... |
Quote the whole -d "..." argument |
product tax code is missing ... Managed Payments |
Pass managed_payments[enabled]=false for the session, and raise the account setting as a question |
| Checkout link fails | The session expired after 24 hours. Create a new one. |
| Every event appears twice | The v1. copies (looks like a newer event format) |
stripe listen shows nothing |
It only prints when something happens in the sandbox |
Everything is in the repo
The full project is public: github.com/eshan-one/stripe-billing-101.
| What | Link |
|---|---|
| Repo home, progress and layout | README |
| Day 1 guide: every command, expected output, findings, test cards, troubleshooting | docs/day-01/README.md |
| Vocabulary, ID prefixes and webhook principles | docs/concepts.md |
| Five Mermaid diagrams: who does what, a successful subscription, a failed first payment, renewals, the object model | docs/architecture.md |
| Stripe's official guide, section by section | docs/day-01/stripe-guide-coverage.md |
| Samples index (what is trimmed and anonymised) | samples/day-01/README.md |
| Product response | product.json |
| Price response | price.json |
| Checkout Session response | checkout-session.json |
| Subscription response | subscription.json |
| The Managed Payments error | error-managed-payments.json |
| Event log: first successful checkout | events-first-checkout.log |
| Event log: failed payments and 3D Secure | events-failed-payments-and-3ds.log |
To follow along, clone it and run the guide in your own free sandbox:
git clone https://github.com/eshan-one/stripe-billing-101.git
Tell me what surprised you.
Top comments (0)