├── LICENSE └── README.md /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Theo Browne 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # How I Stay Sane Implementing Stripe 2 | 3 | > [!NOTE] 4 | > **Update (2025-02-07)** 5 | > Stripe invited me to speak with the CEO at their company-wide all hands meeting. They were super receptive to my feedback, and I see a bright future where none of this is necessary. Until then, I still think this is the best way to set up payments in your SaaS apps. 6 | 7 | I have set up Stripe far too many times. I've never enjoyed it. I've talked to the Stripe team about the shortcomings and they say they'll fix them...eventually. 8 | 9 | Until then, this is how I recommend setting up Stripe. I don't cover everything - check out [things that are still your problem](#things-that-are-still-your-problem) for clarity on what I'm NOT helping with. 10 | 11 | > If you want to stay sane implementing file uploads, check out my product [UploadThing](https://uploadthing.com/). 12 | 13 | ### Pre-requirements 14 | 15 | - TypeScript 16 | - Some type of JS backend 17 | - Working auth (that is verified on your JS backend) 18 | - A KV store (I use Redis, usually [Upstash](https://upstash.com/?utm_source=theo), but any KV will work) 19 | 20 | ### General philosophy 21 | 22 | IMO, the biggest issue with Stripe is the "split brain" it inherently introduces to your code base. When a customer checks out, the "state of the purchase" is in Stripe. You're then expected to track the purchase in your own database via webhooks. 23 | 24 | There are [over 258 event types](https://docs.stripe.com/api/events/types). They all have different amounts of data. The order you get them is not guaranteed. None of them should be trusted. It's far too easy to have a payment be failed in stripe and "subscribed" in your app. 25 | 26 | These partial updates and race conditions are obnoxious. I recommend avoiding them entirely. My solution is simple: _a single `syncStripeDataToKV(customerId: string)` function that syncs all of the data for a given Stripe customer to your KV_. 27 | 28 | The following is how I (mostly) avoid getting Stripe into these awful split states. 29 | 30 | ## The Flow 31 | 32 | This is a quick overview of the "flow" I recommend. More detail below. Even if you don't copy my specific implementation, you should read this. _I promise all of these steps are necessary. Skipping any of them will make life unnecessarily hard_ 33 | 34 | 1. **FRONTEND:** "Subscribe" button should call a `"generate-stripe-checkout"` endpoint onClick 35 | 1. **USER:** Clicks "subscribe" button on your app 36 | 1. **BACKEND:** Create a Stripe customer 37 | 1. **BACKEND:** Store binding between Stripe's `customerId` and your app's `userId` 38 | 1. **BACKEND:** Create a "checkout session" for the user 39 | - With the return URL set to a dedicated `/success` route in your app 40 | 1. **USER:** Makes payment, subscribes, redirects back to `/success` 41 | 1. **FRONTEND:** On load, triggers a `syncAfterSuccess` function on backend (hit an API, server action, rsc on load, whatever) 42 | 1. **BACKEND:** Uses `userId` to get Stripe `customerId` from KV 43 | 1. **BACKEND:** Calls `syncStripeDataToKV` with `customerId` 44 | 1. **FRONTEND:** After sync succeeds, redirects user to wherever you want them to be :) 45 | 1. **BACKEND:** On [_all relevant events_](#events-i-track), calls `syncStripeDataToKV` with `customerId` 46 | 47 | This might seem like a lot. That's because it is. But it's also the simplest Stripe setup I've ever seen work. 48 | 49 | Let's go into the details on the important parts here. 50 | 51 | ### Checkout flow 52 | 53 | The key is to make sure **you always have the customer defined BEFORE YOU START CHECKOUT**. The ephemerality of "customer" is a straight up design flaw and I have no idea why they built Stripe like this. 54 | 55 | Here's an adapted example from how we're doing it in [T3 Chat](https://t3.chat). 56 | 57 | ```ts 58 | export async function GET(req: Request) { 59 | const user = auth(req); 60 | 61 | // Get the stripeCustomerId from your KV store 62 | let stripeCustomerId = await kv.get(`stripe:user:${user.id}`); 63 | 64 | // Create a new Stripe customer if this user doesn't have one 65 | if (!stripeCustomerId) { 66 | const newCustomer = await stripe.customers.create({ 67 | email: user.email, 68 | metadata: { 69 | userId: user.id, // DO NOT FORGET THIS 70 | }, 71 | }); 72 | 73 | // Store the relation between userId and stripeCustomerId in your KV 74 | await kv.set(`stripe:user:${user.id}`, newCustomer.id); 75 | stripeCustomerId = newCustomer.id; 76 | } 77 | 78 | // ALWAYS create a checkout with a stripeCustomerId. They should enforce this. 79 | const checkout = await stripe.checkout.sessions.create({ 80 | customer: stripeCustomerId, 81 | success_url: "https://t3.chat/success", 82 | ... 83 | }); 84 | ``` 85 | 86 | ### syncStripeDataToKV 87 | 88 | This is the function that syncs all of the data for a given Stripe customer to your KV. It will be used in both your `/success` endpoint and in your `/api/stripe` webhook handler. 89 | 90 | The Stripe api returns a ton of data, much of which can not be serialized to JSON. I've selected the "most likely to be needed" chunk here for you to use, and there's a [type definition later in the file](#custom-stripe-subscription-type). 91 | 92 | Your implementation will vary based on if you're doing subscriptions or one-time purchases. The example below is with subcriptions (again from [T3 Chat](https://t3.chat)). 93 | 94 | ```ts 95 | // The contents of this function should probably be wrapped in a try/catch 96 | export async function syncStripeDataToKV(customerId: string) { 97 | // Fetch latest subscription data from Stripe 98 | const subscriptions = await stripe.subscriptions.list({ 99 | customer: customerId, 100 | limit: 1, 101 | status: "all", 102 | expand: ["data.default_payment_method"], 103 | }); 104 | 105 | if (subscriptions.data.length === 0) { 106 | const subData = { status: "none" }; 107 | await kv.set(`stripe:customer:${customerId}`, subData); 108 | return subData; 109 | } 110 | 111 | // If a user can have multiple subscriptions, that's your problem 112 | const subscription = subscriptions.data[0]; 113 | 114 | // Store complete subscription state 115 | const subData = { 116 | subscriptionId: subscription.id, 117 | status: subscription.status, 118 | priceId: subscription.items.data[0].price.id, 119 | currentPeriodEnd: subscription.current_period_end, 120 | currentPeriodStart: subscription.current_period_start, 121 | cancelAtPeriodEnd: subscription.cancel_at_period_end, 122 | paymentMethod: 123 | subscription.default_payment_method && 124 | typeof subscription.default_payment_method !== "string" 125 | ? { 126 | brand: subscription.default_payment_method.card?.brand ?? null, 127 | last4: subscription.default_payment_method.card?.last4 ?? null, 128 | } 129 | : null, 130 | }; 131 | 132 | // Store the data in your KV 133 | await kv.set(`stripe:customer:${customerId}`, subData); 134 | return subData; 135 | } 136 | ``` 137 | 138 | ### `/success` endpoint 139 | 140 | > [!NOTE] 141 | > While this isn't 'necessary', there's a good chance your user will make it back to your site before the webhooks do. It's a nasty race condition to handle. Eagerly calling syncStripeDataToKV will prevent any weird states you might otherwise end up in 142 | 143 | This is the page that the user is redirected to after they complete their checkout. For the sake of simplicity, I'm going to implement it as a `get` route that redirects them. In my apps, I do this with a server component and Suspense, but I'm not going to spend the time explaining all that here. 144 | 145 | ```ts 146 | export async function GET(req: Request) { 147 | const user = auth(req); 148 | const stripeCustomerId = await kv.get(`stripe:user:${user.id}`); 149 | if (!stripeCustomerId) { 150 | return redirect("/"); 151 | } 152 | 153 | await syncStripeDataToKV(stripeCustomerId); 154 | return redirect("/"); 155 | } 156 | ``` 157 | 158 | Notice how I'm not using any of the `CHECKOUT_SESSION_ID` stuff? That's because it sucks and it encourages you to implement 12 different ways to get the Stripe state. Ignore the siren calls. Have a SINGLE `syncStripeDataToKV` function. It will make your life easier. 159 | 160 | ### `/api/stripe` (The Webhook) 161 | 162 | This is the part everyone hates the most. I'm just gonna dump the code and justify myself later. 163 | 164 | ```ts 165 | export async function POST(req: Request) { 166 | const body = await req.text(); 167 | const signature = (await headers()).get("Stripe-Signature"); 168 | 169 | if (!signature) return NextResponse.json({}, { status: 400 }); 170 | 171 | async function doEventProcessing() { 172 | if (typeof signature !== "string") { 173 | throw new Error("[STRIPE HOOK] Header isn't a string???"); 174 | } 175 | 176 | const event = stripe.webhooks.constructEvent( 177 | body, 178 | signature, 179 | process.env.STRIPE_WEBHOOK_SECRET! 180 | ); 181 | 182 | waitUntil(processEvent(event)); 183 | } 184 | 185 | const { error } = await tryCatch(doEventProcessing()); 186 | 187 | if (error) { 188 | console.error("[STRIPE HOOK] Error processing event", error); 189 | } 190 | 191 | return NextResponse.json({ received: true }); 192 | } 193 | ``` 194 | 195 | > [!NOTE] 196 | > If you are using Next.js Pages Router, make sure you turn this on. Stripe expects the body to be "untouched" so it can verify the signature. 197 | > 198 | > ```ts 199 | > export const config = { 200 | > api: { 201 | > bodyParser: false, 202 | > }, 203 | > }; 204 | > ``` 205 | 206 | ### `processEvent` 207 | 208 | This is the function called in the endpoint that actually takes the Stripe event and updates the KV. 209 | 210 | ```ts 211 | async function processEvent(event: Stripe.Event) { 212 | // Skip processing if the event isn't one I'm tracking (list of all events below) 213 | if (!allowedEvents.includes(event.type)) return; 214 | 215 | // All the events I track have a customerId 216 | const { customer: customerId } = event?.data?.object as { 217 | customer: string; // Sadly TypeScript does not know this 218 | }; 219 | 220 | // This helps make it typesafe and also lets me know if my assumption is wrong 221 | if (typeof customerId !== "string") { 222 | throw new Error( 223 | `[STRIPE HOOK][CANCER] ID isn't string.\nEvent type: ${event.type}` 224 | ); 225 | } 226 | 227 | return await syncStripeDataToKV(customerId); 228 | } 229 | ``` 230 | 231 | ### Events I Track 232 | 233 | If there are more I should be tracking for updates, please file a PR. If they don't affect subscription state, I do not care. 234 | 235 | ```ts 236 | const allowedEvents: Stripe.Event.Type[] = [ 237 | "checkout.session.completed", 238 | "customer.subscription.created", 239 | "customer.subscription.updated", 240 | "customer.subscription.deleted", 241 | "customer.subscription.paused", 242 | "customer.subscription.resumed", 243 | "customer.subscription.pending_update_applied", 244 | "customer.subscription.pending_update_expired", 245 | "customer.subscription.trial_will_end", 246 | "invoice.paid", 247 | "invoice.payment_failed", 248 | "invoice.payment_action_required", 249 | "invoice.upcoming", 250 | "invoice.marked_uncollectible", 251 | "invoice.payment_succeeded", 252 | "payment_intent.succeeded", 253 | "payment_intent.payment_failed", 254 | "payment_intent.canceled", 255 | ]; 256 | ``` 257 | 258 | ### Custom Stripe subscription type 259 | 260 | ```ts 261 | export type STRIPE_SUB_CACHE = 262 | | { 263 | subscriptionId: string | null; 264 | status: Stripe.Subscription.Status; 265 | priceId: string | null; 266 | currentPeriodStart: number | null; 267 | currentPeriodEnd: number | null; 268 | cancelAtPeriodEnd: boolean; 269 | paymentMethod: { 270 | brand: string | null; // e.g., "visa", "mastercard" 271 | last4: string | null; // e.g., "4242" 272 | } | null; 273 | } 274 | | { 275 | status: "none"; 276 | }; 277 | ``` 278 | 279 | ## More Pro Tips 280 | 281 | Gonna slowly drop more things here as I remember them. 282 | 283 | ### DISABLE "CASH APP PAY". 284 | 285 | I'm convinced this is literally just used by scammers. over 90% of my cancelled transactions are Cash App Pay. 286 | ![image](https://github.com/user-attachments/assets/c7271fa6-493c-4b1c-96cd-18904c2376ee) 287 | 288 | ### ENABLE "Limit customers to one subscription" 289 | 290 | This is a really useful hidden setting that has saved me a lot of headaches and race conditions. Fun fact: this is the ONLY way to prevent someone from being able to check out twice if they open up two checkout sessions 🙃 More info [in Stripe's docs here](https://docs.stripe.com/payments/checkout/limit-subscriptions) 291 | 292 | ## Things that are still your problem 293 | 294 | While I have solved a lot of stuff here, in particular the "subscription" flows, there are a few things that are still your problem. Those include... 295 | 296 | - Managing `STRIPE_SECRET_KEY` and `STRIPE_PUBLISHABLE_KEY` env vars for both testing and production 297 | - Managing `STRIPE_PRICE_ID`s for all subscription tiers for dev and prod (I can't believe this is still a thing) 298 | - Exposing sub data from your KV to your user (a dumb endpoint is probably fine) 299 | - Tracking "usage" (i.e. a user gets 100 messages per month) 300 | - Managing "free trials" 301 | ...the list goes on 302 | 303 | Regardless, I hope you found some value in this doc. 304 | --------------------------------------------------------------------------------