<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Eshan Shikarkhane</title>
    <description>The latest articles on DEV Community by Eshan Shikarkhane (@eshan_one).</description>
    <link>https://dev.to/eshan_one</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4229262%2F2b2f5a40-88c2-49ca-af0c-fcf71ec8fd3f.png</url>
      <title>DEV Community: Eshan Shikarkhane</title>
      <link>https://dev.to/eshan_one</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eshan_one"/>
    <language>en</language>
    <item>
      <title>Stripe Subscriptions, CLI First: How I Learned Billing Before Writing a Line of Code</title>
      <dc:creator>Eshan Shikarkhane</dc:creator>
      <pubDate>Thu, 05 Nov 2026 20:58:39 +0000</pubDate>
      <link>https://dev.to/eshan_one/stripe-subscriptions-cli-first-how-i-learned-billing-before-writing-a-line-of-code-1gkj</link>
      <guid>https://dev.to/eshan_one/stripe-subscriptions-cli-first-how-i-learned-billing-before-writing-a-line-of-code-1gkj</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;not writing any app code&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;Everything in this post lives in a public repo: &lt;a href="https://github.com/eshan-one/stripe-billing-101" rel="noopener noreferrer"&gt;&lt;strong&gt;eshan-one/stripe-billing-101&lt;/strong&gt;&lt;/a&gt;. It has the &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/day-01/README.md" rel="noopener noreferrer"&gt;Day 1 guide&lt;/a&gt; with every command, the &lt;a href="https://github.com/eshan-one/stripe-billing-101/tree/main/samples/day-01" rel="noopener noreferrer"&gt;raw sample responses and event logs&lt;/a&gt;, the &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/concepts.md" rel="noopener noreferrer"&gt;concepts&lt;/a&gt; and the &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/architecture.md" rel="noopener noreferrer"&gt;architecture diagrams&lt;/a&gt;. Each step below links to the exact files, and there is a full index at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Learn the object model first (Product, Price, Customer, Subscription, Invoice, Event).&lt;/li&gt;
&lt;li&gt;Do everything in a &lt;strong&gt;sandbox with the Stripe CLI&lt;/strong&gt; before writing code. The CLI makes the same API calls your backend will make.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;stripe listen&lt;/code&gt; open and read the events. They are the real interface of your integration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Break it on purpose&lt;/strong&gt; with test cards. Failure behaviour is where integrations go wrong.&lt;/li&gt;
&lt;li&gt;Turn what you saw into a one-page architecture and a list of questions for whoever owns the product.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Read fewer pages, but the right ones
&lt;/h2&gt;

&lt;p&gt;Stripe's docs have a tab for everything. The first page I opened was &lt;em&gt;Payments, Build a payments page&lt;/em&gt;. It is a fine page, but it teaches &lt;strong&gt;one-time payments&lt;/strong&gt;. Subscriptions live under &lt;strong&gt;Revenue, then Billing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That cost me ten minutes and was worth it, because it forced me to sort out three words that sound alike:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Think of it as&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Payments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Moving money once&lt;/td&gt;
&lt;td&gt;"Take $100 now"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Billing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Recurring charges and invoicing&lt;/td&gt;
&lt;td&gt;"Charge $100 every month, send invoices, retry failures"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Subscription&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One record inside Billing&lt;/td&gt;
&lt;td&gt;"Acme is on the Pro plan, monthly"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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, &lt;strong&gt;plans were Billing&lt;/strong&gt; and &lt;strong&gt;credit packs were Payments&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Sandbox and CLI first
&lt;/h2&gt;

&lt;p&gt;Everything below happens in a Stripe &lt;strong&gt;sandbox&lt;/strong&gt;, so no real money moves. I used two terminal tabs from here on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;stripe/stripe-cli/stripe
stripe login
stripe products list &lt;span class="nt"&gt;--limit&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I created a product in the Dashboard: name &lt;code&gt;Plus&lt;/code&gt;, price &lt;code&gt;8&lt;/code&gt; USD, recurring monthly. The CLI shows what the Dashboard made:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe prices list &lt;span class="nt"&gt;--limit&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trimmed responses are in the repo: &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/product.json" rel="noopener noreferrer"&gt;product.json&lt;/a&gt; and &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/price.json" rel="noopener noreferrer"&gt;price.json&lt;/a&gt;. The fields worth understanding:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unit_amount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;800&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$8.00. Money is in &lt;strong&gt;cents&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;recurring.interval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;month&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Charged monthly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;billing_scheme&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;per_unit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Amount = unit price x quantity. This is how per-seat pricing works later.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lookup_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An optional stable name, handy because IDs differ between sandbox and live&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mental model that clicked: a &lt;strong&gt;Product&lt;/strong&gt; is what you sell, a &lt;strong&gt;Price&lt;/strong&gt; is how much and how often. They are separate objects so you can change a price without touching the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Keep the event stream open
&lt;/h2&gt;

&lt;p&gt;In the second tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe listen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints &lt;code&gt;Ready!&lt;/code&gt; and a webhook signing secret (&lt;code&gt;whsec_...&lt;/code&gt;, 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: &lt;strong&gt;your backend is a consumer of this stream&lt;/strong&gt;, so look at it before you build the consumer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: A subscription with no code
&lt;/h2&gt;

&lt;p&gt;Stripe's guide creates a Checkout Session in server code. The CLI does the same API call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe checkout sessions create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;subscription &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;success_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://example.com/success &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"line_items[0][price]=&amp;lt;PRICE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"line_items[0][quantity]=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things bit me immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First,&lt;/strong&gt; &lt;code&gt;zsh&lt;/code&gt; &lt;strong&gt;and brackets.&lt;/strong&gt; Without quotes around &lt;code&gt;line_items[0][price]=...&lt;/code&gt;, zsh tries to expand the brackets and fails with &lt;code&gt;no matches found&lt;/code&gt;. Quote every &lt;code&gt;-d&lt;/code&gt; argument that has brackets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, an error I didn't expect&lt;/strong&gt; (saved as &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/error-managed-payments.json" rel="noopener noreferrer"&gt;error-managed-payments.json&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"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 ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid_request_error"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The account had &lt;strong&gt;Managed Payments&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"managed_payments[enabled]=false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;The same request as &lt;code&gt;curl&lt;/code&gt;, which is what your backend does through an SDK. Note it is &lt;strong&gt;form-encoded&lt;/strong&gt;, and only the response is JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.stripe.com/v1/checkout/sessions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;subscription &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;success_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://example.com/success &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"line_items[0][price]=&amp;lt;PRICE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"line_items[0][quantity]=1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"managed_payments[enabled]=false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is a &lt;strong&gt;Checkout Session&lt;/strong&gt;, and it is worth reading closely because it is &lt;em&gt;not&lt;/em&gt; the subscription. It is an open order form (the &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/checkout-session.json" rel="noopener noreferrer"&gt;full response&lt;/a&gt; is in the repo):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://checkout.stripe.com/...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The field your backend returns to the frontend, which redirects the browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;status&lt;/code&gt; / &lt;code&gt;payment_status&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;open&lt;/code&gt; / &lt;code&gt;unpaid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Nothing has been paid yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;subscription&lt;/code&gt;, &lt;code&gt;customer&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filled in only after payment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expires_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;24 hours after &lt;code&gt;created&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The link dies after that&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;metadata&lt;/code&gt;, &lt;code&gt;client_reference_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;empty&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Your hook back into your own data.&lt;/strong&gt; Put your user or org ID here.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I opened the &lt;code&gt;url&lt;/code&gt;, paid with the test card &lt;code&gt;4242 4242 4242 4242&lt;/code&gt;, and checked the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe subscriptions list &lt;span class="nt"&gt;--limit&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"status": "active"&lt;/code&gt; (see &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/subscription.json" rel="noopener noreferrer"&gt;subscription.json&lt;/a&gt;). One detail I would have missed in the docs: the billing period dates are on the &lt;strong&gt;subscription item&lt;/strong&gt;, not on the subscription:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_period_start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1793904890&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_period_end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;1796496890&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Read the event firehose
&lt;/h2&gt;

&lt;p&gt;One successful checkout produced &lt;strong&gt;12 distinct events&lt;/strong&gt;, and every one appeared &lt;strong&gt;twice&lt;/strong&gt;, once as &lt;code&gt;name&lt;/code&gt; and once as &lt;code&gt;v1.name&lt;/code&gt;. The &lt;code&gt;v1.&lt;/code&gt; 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 &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/events-first-checkout.log" rel="noopener noreferrer"&gt;events-first-checkout.log&lt;/a&gt;, and the &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/day-01/README.md#reference-events" rel="noopener noreferrer"&gt;event reference&lt;/a&gt; in the guide explains each event.&lt;/p&gt;

&lt;p&gt;The lesson that matters most for design: &lt;strong&gt;arrival order is not logical order.&lt;/strong&gt; In my run, &lt;code&gt;invoice.paid&lt;/code&gt; arrived before &lt;code&gt;customer.created&lt;/code&gt;, and &lt;code&gt;checkout.session.completed&lt;/code&gt; arrived before &lt;code&gt;invoice.created&lt;/code&gt;. Any code that assumes an order will break.&lt;/p&gt;

&lt;p&gt;Of the 12, my backend needs about three:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Needed?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;customer.subscription.created&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invoice.paid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes. Stripe's guide says to wait for this before granting access.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checkout.session.completed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes, to link the purchase to your own user or org. Not enough on its own for access.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the other nine (&lt;code&gt;invoice.created&lt;/code&gt;, &lt;code&gt;payment_intent.*&lt;/code&gt;, &lt;code&gt;charge.succeeded&lt;/code&gt;, ...)&lt;/td&gt;
&lt;td&gt;No, the three above carry what you need&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is the whole flow as a sequence:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
  autonumber
  actor U as Customer
  participant FE as Frontend
  participant BE as Your backend
  participant ST as Stripe
  U-&amp;gt;&amp;gt;FE: Click Subscribe
  FE-&amp;gt;&amp;gt;BE: Create checkout session for a plan
  BE-&amp;gt;&amp;gt;ST: POST /v1/checkout/sessions with price and metadata
  ST--&amp;gt;&amp;gt;BE: session id and url
  BE--&amp;gt;&amp;gt;FE: url
  FE-&amp;gt;&amp;gt;U: Redirect to the Stripe page
  U-&amp;gt;&amp;gt;ST: Enter card, confirm (3D Secure if asked)
  ST--&amp;gt;&amp;gt;U: Redirect to success_url
  ST-&amp;gt;&amp;gt;BE: Webhooks: customer.subscription.created, invoice.paid, checkout.session.completed
  BE-&amp;gt;&amp;gt;BE: Verify signature, skip repeats, find the org, save state
  FE-&amp;gt;&amp;gt;BE: Billing page asks for status
  BE--&amp;gt;&amp;gt;FE: plan, status, next billing date&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Step 6: Break it on purpose
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Card&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;4000 0000 0000 0002&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Your credit card was declined"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;4000 0000 0000 9995&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Declined for insufficient funds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;4000 0025 0000 3155&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A test 3D Secure window. I clicked &lt;strong&gt;Complete&lt;/strong&gt; and it succeeded.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The finding I most wanted was this: &lt;strong&gt;a failed first payment creates nothing.&lt;/strong&gt; 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 &lt;code&gt;payment_intent.payment_failed&lt;/code&gt; and &lt;code&gt;charge.failed&lt;/code&gt; until the payment finally succeeded, and &lt;em&gt;then&lt;/em&gt; all the &lt;code&gt;invoice.*&lt;/code&gt; and &lt;code&gt;customer.subscription.*&lt;/code&gt; events appeared. The full log is &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/events-failed-payments-and-3ds.log" rel="noopener noreferrer"&gt;events-failed-payments-and-3ds.log&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  A["Customer submits card on the Stripe page"] --&amp;gt; B{"Bank result"}
  B --&amp;gt;|"Declined or insufficient funds"| C["payment_intent.payment_failed&amp;lt;br/&amp;gt;charge.failed"]
  C --&amp;gt; D["Page shows an error and stays open"]
  D --&amp;gt; A
  C --&amp;gt; N["No customer, subscription or invoice exists"]
  B --&amp;gt;|"Needs authentication"| E["payment_intent.requires_action"]
  E --&amp;gt; F["3D Secure window: Complete"]
  F --&amp;gt; G["charge.succeeded&amp;lt;br/&amp;gt;payment_intent.succeeded"]
  B --&amp;gt;|"Approved"| G
  G --&amp;gt; H["customer.subscription.created&amp;lt;br/&amp;gt;invoice.paid&amp;lt;br/&amp;gt;checkout.session.completed"]
  H --&amp;gt; I["Backend webhook saves state"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;That tells me a failed sign-up needs &lt;strong&gt;no cleanup code&lt;/strong&gt;, and that &lt;code&gt;invoice.payment_failed&lt;/code&gt;, the event Stripe's guide lists, belongs to &lt;strong&gt;renewals&lt;/strong&gt;, not to the first payment. I haven't tested renewals yet, so I mark that as expected, not verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Turn it into an architecture
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  subgraph FE["Your frontend"]
    A1["1. Click Subscribe&amp;lt;br/&amp;gt;plan or credit pack"]
    A4["4. Redirect browser&amp;lt;br/&amp;gt;to the Stripe URL"]
    A10["10. Billing page&amp;lt;br/&amp;gt;asks backend for status"]
  end
  subgraph BE["Your backend (you write this)"]
    B2["2. Create session endpoint&amp;lt;br/&amp;gt;org ID in metadata"]
    B8["8. Webhook endpoint&amp;lt;br/&amp;gt;verify, dedupe, route"]
    B9["9. Update your DB&amp;lt;br/&amp;gt;plan, status, credits"]
  end
  subgraph ST["Stripe (hosted, no code)"]
    S3["3. Checkout Session&amp;lt;br/&amp;gt;returns a payment URL"]
    S5["5. Customer pays&amp;lt;br/&amp;gt;card, 3D Secure"]
    S6["6. Creates customer,&amp;lt;br/&amp;gt;subscription, invoice"]
    S7["7. Sends webhook events"]
  end
  A1 --&amp;gt; B2
  B2 --&amp;gt; S3
  B2 --&amp;gt;|"returns url"| A4
  A4 --&amp;gt; S5
  S5 --&amp;gt; S6 --&amp;gt; S7 --&amp;gt; B8 --&amp;gt; B9
  A10 --&amp;gt;|"reads"| B9
  classDef code fill:#EEEDFE,stroke:#534AB7,color:#26215C
  class B2,B8,B9 code&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;This diagram and four more (including the Stripe object model and the expected renewal flow) are in &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/architecture.md" rel="noopener noreferrer"&gt;architecture.md&lt;/a&gt;. Only three boxes are mine. And the experiments turned into design rules before I'd written a line:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks are the source of truth&lt;/strong&gt;, not the redirect to &lt;code&gt;success_url&lt;/code&gt;. The browser can close, lie or be slow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't rely on event order.&lt;/strong&gt; Read the payload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate on the&lt;/strong&gt; &lt;code&gt;evt_&lt;/code&gt; &lt;strong&gt;ID.&lt;/strong&gt; Events can repeat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the signature&lt;/strong&gt; with the &lt;code&gt;whsec_&lt;/code&gt; secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A webhook has no login and no user.&lt;/strong&gt; So I must put my own user or org ID on the Stripe objects (&lt;code&gt;metadata&lt;/code&gt; or &lt;code&gt;client_reference_id&lt;/code&gt;) when I create the session. Otherwise the webhook cannot tell me whose payment it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait for&lt;/strong&gt; &lt;code&gt;invoice.paid&lt;/code&gt;, not just &lt;code&gt;checkout.session.completed&lt;/code&gt;, before granting access.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 8: Write down what you don't know
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Working assumption&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is the "Team" plan flat or per seat? The mock-up said both.&lt;/td&gt;
&lt;td&gt;Per seat (a quantity on a per-unit price)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosted Checkout, or an embedded payment form inside our app?&lt;/td&gt;
&lt;td&gt;Hosted Checkout for v1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed Payments on or off?&lt;/td&gt;
&lt;td&gt;Off per session in the sandbox. Not my call for the account.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Which payment methods? Checkout offered six.&lt;/td&gt;
&lt;td&gt;Cards only, to confirm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use &lt;code&gt;lookup_key&lt;/code&gt;s so code doesn't hard-code IDs?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A question with a default is cheap to answer. "What should we do about billing?" is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't done yet
&lt;/h2&gt;

&lt;p&gt;I'd rather say so than imply the job is finished. Not yet run: the trial period, &lt;strong&gt;test clocks&lt;/strong&gt; (to simulate renewals and failed renewals, which is where &lt;code&gt;invoice.payment_failed&lt;/code&gt; 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 &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/day-01/README.md#day-2-not-yet-run" rel="noopener noreferrer"&gt;written and marked "not yet run"&lt;/a&gt; in the repo, and &lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/day-01/stripe-guide-coverage.md" rel="noopener noreferrer"&gt;a coverage table&lt;/a&gt; shows which sections of Stripe's own guide I have and haven't covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow, in one page
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Sort out the vocabulary (Payments vs Billing vs Subscription, Product vs Price).&lt;/li&gt;
&lt;li&gt;Sandbox plus CLI. No app code.&lt;/li&gt;
&lt;li&gt;Create a product and price, and read the raw JSON.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;stripe listen&lt;/code&gt; open all the time.&lt;/li&gt;
&lt;li&gt;Make a subscription through the CLI, then read the Checkout Session and the Subscription.&lt;/li&gt;
&lt;li&gt;Learn which events matter and ignore the rest.&lt;/li&gt;
&lt;li&gt;Break it with test cards. Note what is &lt;strong&gt;not&lt;/strong&gt; created.&lt;/li&gt;
&lt;li&gt;Draw who does what. Derive rules from what you observed.&lt;/li&gt;
&lt;li&gt;Write the open questions with defaults. Mark what you haven't verified.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pitfalls I hit
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause and fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zsh: no matches found: line_items[0]...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Quote the whole &lt;code&gt;-d "..."&lt;/code&gt; argument&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;product tax code is missing ... Managed Payments&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pass &lt;code&gt;managed_payments[enabled]=false&lt;/code&gt; for the session, and raise the account setting as a question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkout link fails&lt;/td&gt;
&lt;td&gt;The session expired after 24 hours. Create a new one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every event appears twice&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;v1.&lt;/code&gt; copies (looks like a newer event format)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;stripe listen&lt;/code&gt; shows nothing&lt;/td&gt;
&lt;td&gt;It only prints when something happens in the sandbox&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Everything is in the repo
&lt;/h2&gt;

&lt;p&gt;The full project is public: &lt;a href="https://github.com/eshan-one/stripe-billing-101" rel="noopener noreferrer"&gt;&lt;strong&gt;github.com/eshan-one/stripe-billing-101&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Link&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Repo home, progress and layout&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101" rel="noopener noreferrer"&gt;README&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Day 1 guide&lt;/strong&gt;: every command, expected output, findings, test cards, troubleshooting&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/day-01/README.md" rel="noopener noreferrer"&gt;docs/day-01/README.md&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vocabulary, ID prefixes and webhook principles&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/concepts.md" rel="noopener noreferrer"&gt;docs/concepts.md&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Five Mermaid diagrams: who does what, a successful subscription, a failed first payment, renewals, the object model&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/architecture.md" rel="noopener noreferrer"&gt;docs/architecture.md&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe's official guide, section by section&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/docs/day-01/stripe-guide-coverage.md" rel="noopener noreferrer"&gt;docs/day-01/stripe-guide-coverage.md&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Samples index (what is trimmed and anonymised)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/README.md" rel="noopener noreferrer"&gt;samples/day-01/README.md&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product response&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/product.json" rel="noopener noreferrer"&gt;product.json&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price response&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/price.json" rel="noopener noreferrer"&gt;price.json&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkout Session response&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/checkout-session.json" rel="noopener noreferrer"&gt;checkout-session.json&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subscription response&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/subscription.json" rel="noopener noreferrer"&gt;subscription.json&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Managed Payments error&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/error-managed-payments.json" rel="noopener noreferrer"&gt;error-managed-payments.json&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event log: first successful checkout&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/events-first-checkout.log" rel="noopener noreferrer"&gt;events-first-checkout.log&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event log: failed payments and 3D Secure&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/eshan-one/stripe-billing-101/blob/main/samples/day-01/events-failed-payments-and-3ds.log" rel="noopener noreferrer"&gt;events-failed-payments-and-3ds.log&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To follow along, clone it and run the guide in your own free sandbox:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/eshan-one/stripe-billing-101.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tell me what surprised you.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
