[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-74144":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":10,"language":10,"languages":10,"totalLinesOfCode":10,"stars":11,"forks":12,"watchers":13,"openIssues":14,"contributorsCount":15,"subscribersCount":15,"size":15,"stars1d":16,"stars7d":17,"stars30d":18,"stars90d":15,"forks30d":15,"starsTrendScore":19,"compositeScore":20,"rankGlobal":10,"rankLanguage":10,"license":21,"archived":22,"fork":22,"defaultBranch":23,"hasWiki":24,"hasPages":22,"topics":25,"createdAt":10,"pushedAt":10,"updatedAt":32,"readmeContent":33,"aiSummary":34,"trendingCount":15,"starSnapshotCount":15,"syncStatus":35,"lastSyncTime":36,"discoverSource":37},74144,"stripe-recommendations","t3dotgg\u002Fstripe-recommendations","t3dotgg","How to implement Stripe without going mad","",null,6333,300,65,23,0,6,8,29,18,82.34,"MIT License",false,"main",true,[26,27,28,29,30,31],"fullstack","nextjs","node","payments","stripe","typescript","2026-06-12 04:01:13","# How I Stay Sane Implementing Stripe\n\n> [!NOTE]  \n> **Update (2025-02-07)**  \n> 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.\n\nI 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.\n\nUntil 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.\n\n> If you want to stay sane implementing file uploads, check out my product [UploadThing](https:\u002F\u002Fuploadthing.com\u002F).\n\n### Pre-requirements\n\n- TypeScript\n- Some type of JS backend\n- Working auth (that is verified on your JS backend)\n- A KV store (I use Redis, usually [Upstash](https:\u002F\u002Fupstash.com\u002F?utm_source=theo), but any KV will work)\n\n### General philosophy\n\nIMO, 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.\n\nThere are [over 258 event types](https:\u002F\u002Fdocs.stripe.com\u002Fapi\u002Fevents\u002Ftypes). 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.\n\nThese 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_.\n\nThe following is how I (mostly) avoid getting Stripe into these awful split states.\n\n## The Flow\n\nThis 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_\n\n1. **FRONTEND:** \"Subscribe\" button should call a `\"generate-stripe-checkout\"` endpoint onClick\n1. **USER:** Clicks \"subscribe\" button on your app\n1. **BACKEND:** Create a Stripe customer\n1. **BACKEND:** Store binding between Stripe's `customerId` and your app's `userId`\n1. **BACKEND:** Create a \"checkout session\" for the user\n   - With the return URL set to a dedicated `\u002Fsuccess` route in your app\n1. **USER:** Makes payment, subscribes, redirects back to `\u002Fsuccess`\n1. **FRONTEND:** On load, triggers a `syncAfterSuccess` function on backend (hit an API, server action, rsc on load, whatever)\n1. **BACKEND:** Uses `userId` to get Stripe `customerId` from KV\n1. **BACKEND:** Calls `syncStripeDataToKV` with `customerId`\n1. **FRONTEND:** After sync succeeds, redirects user to wherever you want them to be :)\n1. **BACKEND:** On [_all relevant events_](#events-i-track), calls `syncStripeDataToKV` with `customerId`\n\nThis might seem like a lot. That's because it is. But it's also the simplest Stripe setup I've ever seen work.\n\nLet's go into the details on the important parts here.\n\n### Checkout flow\n\nThe 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.\n\nHere's an adapted example from how we're doing it in [T3 Chat](https:\u002F\u002Ft3.chat).\n\n```ts\nexport async function GET(req: Request) {\n  const user = auth(req);\n\n  \u002F\u002F Get the stripeCustomerId from your KV store\n  let stripeCustomerId = await kv.get(`stripe:user:${user.id}`);\n\n  \u002F\u002F Create a new Stripe customer if this user doesn't have one\n  if (!stripeCustomerId) {\n    const newCustomer = await stripe.customers.create({\n      email: user.email,\n      metadata: {\n        userId: user.id, \u002F\u002F DO NOT FORGET THIS\n      },\n    });\n\n    \u002F\u002F Store the relation between userId and stripeCustomerId in your KV\n    await kv.set(`stripe:user:${user.id}`, newCustomer.id);\n    stripeCustomerId = newCustomer.id;\n  }\n\n  \u002F\u002F ALWAYS create a checkout with a stripeCustomerId. They should enforce this.\n  const checkout = await stripe.checkout.sessions.create({\n    customer: stripeCustomerId,\n    success_url: \"https:\u002F\u002Ft3.chat\u002Fsuccess\",\n    ...\n  });\n```\n\n### syncStripeDataToKV\n\nThis is the function that syncs all of the data for a given Stripe customer to your KV. It will be used in both your `\u002Fsuccess` endpoint and in your `\u002Fapi\u002Fstripe` webhook handler.\n\nThe 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).\n\nYour 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:\u002F\u002Ft3.chat)).\n\n```ts\n\u002F\u002F The contents of this function should probably be wrapped in a try\u002Fcatch\nexport async function syncStripeDataToKV(customerId: string) {\n  \u002F\u002F Fetch latest subscription data from Stripe\n  const subscriptions = await stripe.subscriptions.list({\n    customer: customerId,\n    limit: 1,\n    status: \"all\",\n    expand: [\"data.default_payment_method\"],\n  });\n\n  if (subscriptions.data.length === 0) {\n    const subData = { status: \"none\" };\n    await kv.set(`stripe:customer:${customerId}`, subData);\n    return subData;\n  }\n\n  \u002F\u002F If a user can have multiple subscriptions, that's your problem\n  const subscription = subscriptions.data[0];\n\n  \u002F\u002F Store complete subscription state\n  const subData = {\n    subscriptionId: subscription.id,\n    status: subscription.status,\n    priceId: subscription.items.data[0].price.id,\n    currentPeriodEnd: subscription.current_period_end,\n    currentPeriodStart: subscription.current_period_start,\n    cancelAtPeriodEnd: subscription.cancel_at_period_end,\n    paymentMethod:\n      subscription.default_payment_method &&\n      typeof subscription.default_payment_method !== \"string\"\n        ? {\n            brand: subscription.default_payment_method.card?.brand ?? null,\n            last4: subscription.default_payment_method.card?.last4 ?? null,\n          }\n        : null,\n  };\n\n  \u002F\u002F Store the data in your KV\n  await kv.set(`stripe:customer:${customerId}`, subData);\n  return subData;\n}\n```\n\n### `\u002Fsuccess` endpoint\n\n> [!NOTE]\n> 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\n\nThis 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.\n\n```ts\nexport async function GET(req: Request) {\n  const user = auth(req);\n  const stripeCustomerId = await kv.get(`stripe:user:${user.id}`);\n  if (!stripeCustomerId) {\n    return redirect(\"\u002F\");\n  }\n\n  await syncStripeDataToKV(stripeCustomerId);\n  return redirect(\"\u002F\");\n}\n```\n\nNotice 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.\n\n### `\u002Fapi\u002Fstripe` (The Webhook)\n\nThis is the part everyone hates the most. I'm just gonna dump the code and justify myself later.\n\n```ts\nexport async function POST(req: Request) {\n  const body = await req.text();\n  const signature = (await headers()).get(\"Stripe-Signature\");\n\n  if (!signature) return NextResponse.json({}, { status: 400 });\n\n  async function doEventProcessing() {\n    if (typeof signature !== \"string\") {\n      throw new Error(\"[STRIPE HOOK] Header isn't a string???\");\n    }\n\n    const event = stripe.webhooks.constructEvent(\n      body,\n      signature,\n      process.env.STRIPE_WEBHOOK_SECRET!\n    );\n\n    waitUntil(processEvent(event));\n  }\n\n  const { error } = await tryCatch(doEventProcessing());\n\n  if (error) {\n    console.error(\"[STRIPE HOOK] Error processing event\", error);\n  }\n\n  return NextResponse.json({ received: true });\n}\n```\n\n> [!NOTE]\n> 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.\n>\n> ```ts\n> export const config = {\n>   api: {\n>     bodyParser: false,\n>   },\n> };\n> ```\n\n### `processEvent`\n\nThis is the function called in the endpoint that actually takes the Stripe event and updates the KV.\n\n```ts\nasync function processEvent(event: Stripe.Event) {\n  \u002F\u002F Skip processing if the event isn't one I'm tracking (list of all events below)\n  if (!allowedEvents.includes(event.type)) return;\n\n  \u002F\u002F All the events I track have a customerId\n  const { customer: customerId } = event?.data?.object as {\n    customer: string; \u002F\u002F Sadly TypeScript does not know this\n  };\n\n  \u002F\u002F This helps make it typesafe and also lets me know if my assumption is wrong\n  if (typeof customerId !== \"string\") {\n    throw new Error(\n      `[STRIPE HOOK][CANCER] ID isn't string.\\nEvent type: ${event.type}`\n    );\n  }\n\n  return await syncStripeDataToKV(customerId);\n}\n```\n\n### Events I Track\n\nIf there are more I should be tracking for updates, please file a PR. If they don't affect subscription state, I do not care.\n\n```ts\nconst allowedEvents: Stripe.Event.Type[] = [\n  \"checkout.session.completed\",\n  \"customer.subscription.created\",\n  \"customer.subscription.updated\",\n  \"customer.subscription.deleted\",\n  \"customer.subscription.paused\",\n  \"customer.subscription.resumed\",\n  \"customer.subscription.pending_update_applied\",\n  \"customer.subscription.pending_update_expired\",\n  \"customer.subscription.trial_will_end\",\n  \"invoice.paid\",\n  \"invoice.payment_failed\",\n  \"invoice.payment_action_required\",\n  \"invoice.upcoming\",\n  \"invoice.marked_uncollectible\",\n  \"invoice.payment_succeeded\",\n  \"payment_intent.succeeded\",\n  \"payment_intent.payment_failed\",\n  \"payment_intent.canceled\",\n];\n```\n\n### Custom Stripe subscription type\n\n```ts\nexport type STRIPE_SUB_CACHE =\n  | {\n      subscriptionId: string | null;\n      status: Stripe.Subscription.Status;\n      priceId: string | null;\n      currentPeriodStart: number | null;\n      currentPeriodEnd: number | null;\n      cancelAtPeriodEnd: boolean;\n      paymentMethod: {\n        brand: string | null; \u002F\u002F e.g., \"visa\", \"mastercard\"\n        last4: string | null; \u002F\u002F e.g., \"4242\"\n      } | null;\n    }\n  | {\n      status: \"none\";\n    };\n```\n\n## More Pro Tips\n\nGonna slowly drop more things here as I remember them.\n\n### DISABLE \"CASH APP PAY\".\n\nI'm convinced this is literally just used by scammers. over 90% of my cancelled transactions are Cash App Pay.\n![image](https:\u002F\u002Fgithub.com\u002Fuser-attachments\u002Fassets\u002Fc7271fa6-493c-4b1c-96cd-18904c2376ee)\n\n### ENABLE \"Limit customers to one subscription\"\n\nThis 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:\u002F\u002Fdocs.stripe.com\u002Fpayments\u002Fcheckout\u002Flimit-subscriptions)\n\n## Things that are still your problem\n\nWhile 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...\n\n- Managing `STRIPE_SECRET_KEY` and `STRIPE_PUBLISHABLE_KEY` env vars for both testing and production\n- Managing `STRIPE_PRICE_ID`s for all subscription tiers for dev and prod (I can't believe this is still a thing)\n- Exposing sub data from your KV to your user (a dumb endpoint is probably fine)\n- Tracking \"usage\" (i.e. a user gets 100 messages per month)\n- Managing \"free trials\"\n  ...the list goes on\n\nRegardless, I hope you found some value in this doc.\n","该项目提供了一种简化Stripe支付集成的方法，旨在帮助开发者更高效地在SaaS应用中设置支付功能。其核心功能包括通过一个`syncStripeDataToKV(customerId: string)`函数将Stripe客户数据同步到键值存储（如Redis），从而避免因事件处理顺序不确定和部分更新导致的数据不一致问题。技术上，该方案推荐使用TypeScript、Node.js后端及Next.js前端框架，并要求有可靠的身份验证机制。适合场景为需要快速稳定地集成Stripe支付且希望减少维护复杂性的全栈Web应用开发。",2,"2026-06-11 03:49:01","high_star"]