Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 11 min read

Stripe Subscriptions, CLI First: How I Learned Billing Before Writing a Line of Code

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

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 listen open 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:

  1. Webhooks are the source of truth, not the redirect to success_url. The browser can close, lie or be slow.
  2. Don't rely on event order. Read the payload.
  3. Deduplicate on the evt_ ID. Events can repeat.
  4. Verify the signature with the whsec_ secret.
  5. A webhook has no login and no user. So I must put my own user or org ID on the Stripe objects (metadata or client_reference_id) when I create the session. Otherwise the webhook cannot tell me whose payment it is.
  6. Wait for invoice.paid, not just checkout.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

  1. Sort out the vocabulary (Payments vs Billing vs Subscription, Product vs Price).
  2. Sandbox plus CLI. No app code.
  3. Create a product and price, and read the raw JSON.
  4. Keep stripe listen open all the time.
  5. Make a subscription through the CLI, then read the Checkout Session and the Subscription.
  6. Learn which events matter and ignore the rest.
  7. Break it with test cards. Note what is not created.
  8. Draw who does what. Derive rules from what you observed.
  9. 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.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.