[{"data":1,"prerenderedAt":4482},["ShallowReactive",2],{"\u002Fblog\u002Fstripe-subscriptions-nuxt-4":3,"\u002Fblog\u002Fstripe-subscriptions-nuxt-4-surround":4472},{"id":4,"title":5,"authors":6,"badge":12,"body":14,"date":4460,"description":4461,"extension":4462,"image":4463,"meta":4465,"navigation":162,"path":4466,"seo":4467,"stem":4470,"__hash__":4471},"posts\u002Fblog\u002Fstripe-subscriptions-nuxt-4.md","Stripe Subscriptions in Nuxt 4: Checkout, Webhooks, and the Customer Portal",[7],{"name":8,"to":9,"avatar":10},"Rares","https:\u002F\u002Fx.com\u002FRaress96_",{"src":11},"\u002Fimages\u002Favatar.png",{"label":13},"Tutorial",{"type":15,"value":16,"toc":4445},"minimark",[17,21,24,33,38,41,63,67,70,96,103,302,309,315,321,367,371,378,381,707,722,728,735,739,746,753,986,989,996,1062,1066,1073,1485,1500,1503,1944,1958,1967,1973,1976,2134,2144,2148,2151,2158,2651,2654,2667,2677,2681,2684,2692,2992,3004,3412,3428,3439,3447,3700,3706,3714,3720,3724,3727,3835,3841,3844,3848,3855,3858,4031,4034,4230,4238,4242,4245,4263,4272,4275,4308,4315,4325,4329,4342,4355,4361,4374,4380,4384,4391,4399,4409,4413,4416,4419,4425,4441],[18,19,20],"p",{},"Adding Stripe to a Nuxt app looks like a two hour job. Install the module, create a checkout session, redirect the user, done.",[18,22,23],{},"Then you discover the actual work: your database has to stay in sync with Stripe's idea of the subscription, forever, through upgrades, downgrades, failed payments, cancellations that take effect next month, and reactivations. That sync is the whole feature, and checkout is maybe 10% of it.",[18,25,26,27,32],{},"This guide covers the full path in Nuxt 4: checkout sessions, webhook signature verification (including the part that breaks on Cloudflare Workers), the customer portal, and enforcing plan limits server-side. The code is close to what ships in ",[28,29,31],"a",{"href":30},"\u002F#pricing","NuxtBeyond",", so it's been through production rather than just the docs.",[34,35,37],"h2",{"id":36},"what-youre-actually-building","What you're actually building",[18,39,40],{},"Six pieces, in order of how likely they are to bite you:",[42,43,44,48,51,54,57,60],"ol",{},[45,46,47],"li",{},"A checkout session endpoint that maps a plan to a Stripe price",[45,49,50],{},"A webhook handler that verifies signatures and updates your database",[45,52,53],{},"A customer portal session so users manage their own billing",[45,55,56],{},"Plan limits enforced on the server, not just hidden in the UI",[45,58,59],{},"A way to test all of it locally",[45,61,62],{},"Correct environment variables in production (this one causes more outages than the rest combined)",[34,64,66],{"id":65},"setup","Setup",[18,68,69],{},"Install the Stripe module for Nuxt:",[71,72,77],"pre",{"className":73,"code":74,"language":75,"meta":76,"style":76},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","npm install @unlok-co\u002Fnuxt-stripe\n","bash","",[78,79,80],"code",{"__ignoreMap":76},[81,82,85,89,93],"span",{"class":83,"line":84},"line",1,[81,86,88],{"class":87},"sBMFI","npm",[81,90,92],{"class":91},"sfazB"," install",[81,94,95],{"class":91}," @unlok-co\u002Fnuxt-stripe\n",[18,97,98,99,102],{},"Then wire it into ",[78,100,101],{},"nuxt.config.ts",":",[71,104,108],{"className":105,"code":106,"language":107,"meta":76,"style":76},"language-typescript shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","export default defineNuxtConfig({\n  modules: ['@unlok-co\u002Fnuxt-stripe'],\n\n  runtimeConfig: {\n    stripe: {\n      key: '', \u002F\u002F overridden by NUXT_STRIPE_KEY\n    },\n    public: {\n      siteUrl: '', \u002F\u002F overridden by NUXT_PUBLIC_SITE_URL\n      stripe: {\n        key: '', \u002F\u002F overridden by NUXT_PUBLIC_STRIPE_KEY\n        manualClientLoad: true,\n      },\n    },\n  },\n});\n","typescript",[78,109,110,131,157,164,175,185,203,209,219,234,244,259,273,279,284,290],{"__ignoreMap":76},[81,111,112,116,119,123,127],{"class":83,"line":84},[81,113,115],{"class":114},"s7zQu","export",[81,117,118],{"class":114}," default",[81,120,122],{"class":121},"s2Zo4"," defineNuxtConfig",[81,124,126],{"class":125},"sTEyZ","(",[81,128,130],{"class":129},"sMK4o","{\n",[81,132,134,138,140,143,146,149,151,154],{"class":83,"line":133},2,[81,135,137],{"class":136},"swJcz","  modules",[81,139,102],{"class":129},[81,141,142],{"class":125}," [",[81,144,145],{"class":129},"'",[81,147,148],{"class":91},"@unlok-co\u002Fnuxt-stripe",[81,150,145],{"class":129},[81,152,153],{"class":125},"]",[81,155,156],{"class":129},",\n",[81,158,160],{"class":83,"line":159},3,[81,161,163],{"emptyLinePlaceholder":162},true,"\n",[81,165,167,170,172],{"class":83,"line":166},4,[81,168,169],{"class":136},"  runtimeConfig",[81,171,102],{"class":129},[81,173,174],{"class":129}," {\n",[81,176,178,181,183],{"class":83,"line":177},5,[81,179,180],{"class":136},"    stripe",[81,182,102],{"class":129},[81,184,174],{"class":129},[81,186,188,191,193,196,199],{"class":83,"line":187},6,[81,189,190],{"class":136},"      key",[81,192,102],{"class":129},[81,194,195],{"class":129}," ''",[81,197,198],{"class":129},",",[81,200,202],{"class":201},"sHwdD"," \u002F\u002F overridden by NUXT_STRIPE_KEY\n",[81,204,206],{"class":83,"line":205},7,[81,207,208],{"class":129},"    },\n",[81,210,212,215,217],{"class":83,"line":211},8,[81,213,214],{"class":136},"    public",[81,216,102],{"class":129},[81,218,174],{"class":129},[81,220,222,225,227,229,231],{"class":83,"line":221},9,[81,223,224],{"class":136},"      siteUrl",[81,226,102],{"class":129},[81,228,195],{"class":129},[81,230,198],{"class":129},[81,232,233],{"class":201}," \u002F\u002F overridden by NUXT_PUBLIC_SITE_URL\n",[81,235,237,240,242],{"class":83,"line":236},10,[81,238,239],{"class":136},"      stripe",[81,241,102],{"class":129},[81,243,174],{"class":129},[81,245,247,250,252,254,256],{"class":83,"line":246},11,[81,248,249],{"class":136},"        key",[81,251,102],{"class":129},[81,253,195],{"class":129},[81,255,198],{"class":129},[81,257,258],{"class":201}," \u002F\u002F overridden by NUXT_PUBLIC_STRIPE_KEY\n",[81,260,262,265,267,271],{"class":83,"line":261},12,[81,263,264],{"class":136},"        manualClientLoad",[81,266,102],{"class":129},[81,268,270],{"class":269},"sfNiH"," true",[81,272,156],{"class":129},[81,274,276],{"class":83,"line":275},13,[81,277,278],{"class":129},"      },\n",[81,280,282],{"class":83,"line":281},14,[81,283,208],{"class":129},[81,285,287],{"class":83,"line":286},15,[81,288,289],{"class":129},"  },\n",[81,291,293,296,299],{"class":83,"line":292},16,[81,294,295],{"class":129},"}",[81,297,298],{"class":125},")",[81,300,301],{"class":129},";\n",[18,303,304,305,308],{},"Empty strings with a ",[78,306,307],{},"NUXT_"," prefixed env var is the Nuxt pattern for runtime config. The value gets injected at runtime instead of baked into the build, which matters when you deploy the same bundle to staging and production.",[18,310,311,314],{},[78,312,313],{},"manualClientLoad: true"," stops Stripe.js from loading on every page. If you're using hosted Checkout (you should, for subscriptions), the browser barely needs Stripe.js at all. That's one less third-party script on your landing page.",[18,316,317,318,102],{},"Your ",[78,319,320],{},".env",[71,322,324],{"className":73,"code":323,"language":75,"meta":76,"style":76},"NUXT_STRIPE_KEY=sk_test_...\nNUXT_PUBLIC_STRIPE_KEY=pk_test_...\nSTRIPE_WEBHOOK_SECRET=whsec_...\nNUXT_PUBLIC_SITE_URL=http:\u002F\u002Flocalhost:3000\n",[78,325,326,337,347,357],{"__ignoreMap":76},[81,327,328,331,334],{"class":83,"line":84},[81,329,330],{"class":125},"NUXT_STRIPE_KEY",[81,332,333],{"class":129},"=",[81,335,336],{"class":91},"sk_test_...\n",[81,338,339,342,344],{"class":83,"line":133},[81,340,341],{"class":125},"NUXT_PUBLIC_STRIPE_KEY",[81,343,333],{"class":129},[81,345,346],{"class":91},"pk_test_...\n",[81,348,349,352,354],{"class":83,"line":159},[81,350,351],{"class":125},"STRIPE_WEBHOOK_SECRET",[81,353,333],{"class":129},[81,355,356],{"class":91},"whsec_...\n",[81,358,359,362,364],{"class":83,"line":166},[81,360,361],{"class":125},"NUXT_PUBLIC_SITE_URL",[81,363,333],{"class":129},[81,365,366],{"class":91},"http:\u002F\u002Flocalhost:3000\n",[34,368,370],{"id":369},"put-the-subscription-on-the-tenant-not-the-user","Put the subscription on the tenant, not the user",[18,372,373,374,377],{},"Before any Stripe code, decide what owns the subscription. In B2B the thing being sold is the workspace, not the person. If you attach ",[78,375,376],{},"stripeCustomerId"," to the user row, adding teams later means migrating every billing record you have.",[18,379,380],{},"Here's the shape in Drizzle, targeting Cloudflare D1:",[71,382,384],{"className":105,"code":383,"language":107,"meta":76,"style":76},"export const projects = sqliteTable(\n  'projects',\n  {\n    id: text().primaryKey().$defaultFn(() => crypto.randomUUID()),\n    name: text().notNull(),\n    planName: text({ enum: planNames }).notNull(),\n    subscriptionStatus: text().notNull().default('active'),\n    stripeCustomerId: text(),\n    stripeSubscriptionId: text(),\n    stripeCurrentPeriodEnd: integer({ mode: 'timestamp' }),\n  },\n  (t) => [\n    index('projectPlanIdx').on(t.planName),\n    index('projectStripeSubscriptionIdx').on(t.stripeSubscriptionId),\n  ]\n);\n",[78,385,386,405,417,422,468,488,522,557,570,583,617,621,637,668,696,701],{"__ignoreMap":76},[81,387,388,390,394,397,399,402],{"class":83,"line":84},[81,389,115],{"class":114},[81,391,393],{"class":392},"spNyl"," const",[81,395,396],{"class":125}," projects ",[81,398,333],{"class":129},[81,400,401],{"class":121}," sqliteTable",[81,403,404],{"class":125},"(\n",[81,406,407,410,413,415],{"class":83,"line":133},[81,408,409],{"class":129},"  '",[81,411,412],{"class":91},"projects",[81,414,145],{"class":129},[81,416,156],{"class":129},[81,418,419],{"class":83,"line":159},[81,420,421],{"class":129},"  {\n",[81,423,424,427,429,432,435,438,441,443,445,448,450,452,455,458,460,463,466],{"class":83,"line":166},[81,425,426],{"class":136},"    id",[81,428,102],{"class":129},[81,430,431],{"class":121}," text",[81,433,434],{"class":125},"()",[81,436,437],{"class":129},".",[81,439,440],{"class":121},"primaryKey",[81,442,434],{"class":125},[81,444,437],{"class":129},[81,446,447],{"class":121},"$defaultFn",[81,449,126],{"class":125},[81,451,434],{"class":129},[81,453,454],{"class":392}," =>",[81,456,457],{"class":125}," crypto",[81,459,437],{"class":129},[81,461,462],{"class":121},"randomUUID",[81,464,465],{"class":125},"())",[81,467,156],{"class":129},[81,469,470,473,475,477,479,481,484,486],{"class":83,"line":177},[81,471,472],{"class":136},"    name",[81,474,102],{"class":129},[81,476,431],{"class":121},[81,478,434],{"class":125},[81,480,437],{"class":129},[81,482,483],{"class":121},"notNull",[81,485,434],{"class":125},[81,487,156],{"class":129},[81,489,490,493,495,497,499,502,505,507,510,512,514,516,518,520],{"class":83,"line":187},[81,491,492],{"class":136},"    planName",[81,494,102],{"class":129},[81,496,431],{"class":121},[81,498,126],{"class":125},[81,500,501],{"class":129},"{",[81,503,504],{"class":136}," enum",[81,506,102],{"class":129},[81,508,509],{"class":125}," planNames ",[81,511,295],{"class":129},[81,513,298],{"class":125},[81,515,437],{"class":129},[81,517,483],{"class":121},[81,519,434],{"class":125},[81,521,156],{"class":129},[81,523,524,527,529,531,533,535,537,539,541,544,546,548,551,553,555],{"class":83,"line":205},[81,525,526],{"class":136},"    subscriptionStatus",[81,528,102],{"class":129},[81,530,431],{"class":121},[81,532,434],{"class":125},[81,534,437],{"class":129},[81,536,483],{"class":121},[81,538,434],{"class":125},[81,540,437],{"class":129},[81,542,543],{"class":121},"default",[81,545,126],{"class":125},[81,547,145],{"class":129},[81,549,550],{"class":91},"active",[81,552,145],{"class":129},[81,554,298],{"class":125},[81,556,156],{"class":129},[81,558,559,562,564,566,568],{"class":83,"line":211},[81,560,561],{"class":136},"    stripeCustomerId",[81,563,102],{"class":129},[81,565,431],{"class":121},[81,567,434],{"class":125},[81,569,156],{"class":129},[81,571,572,575,577,579,581],{"class":83,"line":221},[81,573,574],{"class":136},"    stripeSubscriptionId",[81,576,102],{"class":129},[81,578,431],{"class":121},[81,580,434],{"class":125},[81,582,156],{"class":129},[81,584,585,588,590,593,595,597,600,602,605,608,610,613,615],{"class":83,"line":236},[81,586,587],{"class":136},"    stripeCurrentPeriodEnd",[81,589,102],{"class":129},[81,591,592],{"class":121}," integer",[81,594,126],{"class":125},[81,596,501],{"class":129},[81,598,599],{"class":136}," mode",[81,601,102],{"class":129},[81,603,604],{"class":129}," '",[81,606,607],{"class":91},"timestamp",[81,609,145],{"class":129},[81,611,612],{"class":129}," }",[81,614,298],{"class":125},[81,616,156],{"class":129},[81,618,619],{"class":83,"line":246},[81,620,289],{"class":129},[81,622,623,626,630,632,634],{"class":83,"line":261},[81,624,625],{"class":129},"  (",[81,627,629],{"class":628},"sHdIc","t",[81,631,298],{"class":129},[81,633,454],{"class":392},[81,635,636],{"class":125}," [\n",[81,638,639,642,644,646,649,651,653,655,658,661,663,666],{"class":83,"line":275},[81,640,641],{"class":121},"    index",[81,643,126],{"class":125},[81,645,145],{"class":129},[81,647,648],{"class":91},"projectPlanIdx",[81,650,145],{"class":129},[81,652,298],{"class":125},[81,654,437],{"class":129},[81,656,657],{"class":121},"on",[81,659,660],{"class":125},"(t",[81,662,437],{"class":129},[81,664,665],{"class":125},"planName)",[81,667,156],{"class":129},[81,669,670,672,674,676,679,681,683,685,687,689,691,694],{"class":83,"line":281},[81,671,641],{"class":121},[81,673,126],{"class":125},[81,675,145],{"class":129},[81,677,678],{"class":91},"projectStripeSubscriptionIdx",[81,680,145],{"class":129},[81,682,298],{"class":125},[81,684,437],{"class":129},[81,686,657],{"class":121},[81,688,660],{"class":125},[81,690,437],{"class":129},[81,692,693],{"class":125},"stripeSubscriptionId)",[81,695,156],{"class":129},[81,697,698],{"class":83,"line":286},[81,699,700],{"class":125},"  ]\n",[81,702,703,705],{"class":83,"line":292},[81,704,298],{"class":125},[81,706,301],{"class":129},[18,708,709,710,713,714,717,718,721],{},"Four columns carry the state. ",[78,711,712],{},"planName"," is what your app reads for feature gating. ",[78,715,716],{},"subscriptionStatus"," tracks whether the subscription is active, past due, or scheduled to cancel. ",[78,719,720],{},"stripeSubscriptionId"," is how webhooks find the right row, so it needs an index. Without it, every webhook does a full table scan, which is fine at 50 customers and not fine at 5,000.",[18,723,724,727],{},[78,725,726],{},"stripeCurrentPeriodEnd"," is only populated when a cancellation is scheduled, so the UI can say \"active until March 12\" instead of just \"canceling.\"",[18,729,730,731,437],{},"The multi-tenant modeling around this (members, roles, query scoping) is covered in the ",[28,732,734],{"href":733},"\u002Fblog\u002Fmulti-tenant-saas-cloudflare-workers-nuxt-4","multi-tenant SaaS guide",[34,736,738],{"id":737},"use-price-lookup-keys-not-hardcoded-price-ids","Use price lookup keys, not hardcoded price IDs",[18,740,741,742,745],{},"Most tutorials hardcode ",[78,743,744],{},"price_1Ab2Cd..."," in an env var. That works until you have three plans across test and live mode, and now you have six env vars that nobody can read at a glance.",[18,747,748,749,752],{},"Lookup keys fix this. In the Stripe dashboard, give each price a lookup key like ",[78,750,751],{},"pro_monthly",". Then resolve it at runtime:",[71,754,756],{"className":105,"code":755,"language":107,"meta":76,"style":76},"const stripe = await useServerStripe(event);\n\nconst prices = await stripe.prices.list({\n  lookup_keys: [`${planName}_monthly`],\n  expand: ['data.product'],\n});\n\nconst priceId = prices.data?.[0]?.id;\n\nif (!priceId) {\n  throw createError({\n    statusCode: 500,\n    statusMessage: `Price ID not found for plan: ${planName}`,\n  });\n}\n",[78,757,758,779,783,811,837,857,865,869,905,909,925,937,949,972,981],{"__ignoreMap":76},[81,759,760,763,766,768,771,774,777],{"class":83,"line":84},[81,761,762],{"class":392},"const",[81,764,765],{"class":125}," stripe ",[81,767,333],{"class":129},[81,769,770],{"class":114}," await",[81,772,773],{"class":121}," useServerStripe",[81,775,776],{"class":125},"(event)",[81,778,301],{"class":129},[81,780,781],{"class":83,"line":133},[81,782,163],{"emptyLinePlaceholder":162},[81,784,785,787,790,792,794,797,799,802,804,807,809],{"class":83,"line":159},[81,786,762],{"class":392},[81,788,789],{"class":125}," prices ",[81,791,333],{"class":129},[81,793,770],{"class":114},[81,795,796],{"class":125}," stripe",[81,798,437],{"class":129},[81,800,801],{"class":125},"prices",[81,803,437],{"class":129},[81,805,806],{"class":121},"list",[81,808,126],{"class":125},[81,810,130],{"class":129},[81,812,813,816,818,820,823,825,827,830,833,835],{"class":83,"line":166},[81,814,815],{"class":136},"  lookup_keys",[81,817,102],{"class":129},[81,819,142],{"class":125},[81,821,822],{"class":129},"`${",[81,824,712],{"class":125},[81,826,295],{"class":129},[81,828,829],{"class":91},"_monthly",[81,831,832],{"class":129},"`",[81,834,153],{"class":125},[81,836,156],{"class":129},[81,838,839,842,844,846,848,851,853,855],{"class":83,"line":177},[81,840,841],{"class":136},"  expand",[81,843,102],{"class":129},[81,845,142],{"class":125},[81,847,145],{"class":129},[81,849,850],{"class":91},"data.product",[81,852,145],{"class":129},[81,854,153],{"class":125},[81,856,156],{"class":129},[81,858,859,861,863],{"class":83,"line":187},[81,860,295],{"class":129},[81,862,298],{"class":125},[81,864,301],{"class":129},[81,866,867],{"class":83,"line":205},[81,868,163],{"emptyLinePlaceholder":162},[81,870,871,873,876,878,881,883,886,889,892,896,898,900,903],{"class":83,"line":211},[81,872,762],{"class":392},[81,874,875],{"class":125}," priceId ",[81,877,333],{"class":129},[81,879,880],{"class":125}," prices",[81,882,437],{"class":129},[81,884,885],{"class":125},"data",[81,887,888],{"class":129},"?.",[81,890,891],{"class":125},"[",[81,893,895],{"class":894},"sbssI","0",[81,897,153],{"class":125},[81,899,888],{"class":129},[81,901,902],{"class":125},"id",[81,904,301],{"class":129},[81,906,907],{"class":83,"line":221},[81,908,163],{"emptyLinePlaceholder":162},[81,910,911,914,917,920,923],{"class":83,"line":236},[81,912,913],{"class":114},"if",[81,915,916],{"class":125}," (",[81,918,919],{"class":129},"!",[81,921,922],{"class":125},"priceId) ",[81,924,130],{"class":129},[81,926,927,930,933,935],{"class":83,"line":246},[81,928,929],{"class":114},"  throw",[81,931,932],{"class":121}," createError",[81,934,126],{"class":136},[81,936,130],{"class":129},[81,938,939,942,944,947],{"class":83,"line":261},[81,940,941],{"class":136},"    statusCode",[81,943,102],{"class":129},[81,945,946],{"class":894}," 500",[81,948,156],{"class":129},[81,950,951,954,956,959,962,965,967,970],{"class":83,"line":275},[81,952,953],{"class":136},"    statusMessage",[81,955,102],{"class":129},[81,957,958],{"class":129}," `",[81,960,961],{"class":91},"Price ID not found for plan: ",[81,963,964],{"class":129},"${",[81,966,712],{"class":125},[81,968,969],{"class":129},"}`",[81,971,156],{"class":129},[81,973,974,977,979],{"class":83,"line":281},[81,975,976],{"class":129},"  }",[81,978,298],{"class":136},[81,980,301],{"class":129},[81,982,983],{"class":83,"line":286},[81,984,985],{"class":129},"}\n",[18,987,988],{},"The plan name comes from your own constants file, so the mapping between \"what my app calls this plan\" and \"what Stripe charges for it\" lives in the Stripe dashboard where your finance-brained self can change it without a deploy.",[18,990,991,992,995],{},"The bonus shows up in the webhook. Stripe sends back the price object with its ",[78,993,994],{},"lookup_key",", so you can reverse the mapping with one line instead of a lookup table:",[71,997,999],{"className":105,"code":998,"language":107,"meta":76,"style":76},"const newPlanName = subscription.items.data[0]?.price.lookup_key?.split('_')?.[0];\n",[78,1000,1001],{"__ignoreMap":76},[81,1002,1003,1005,1008,1010,1013,1015,1018,1020,1023,1025,1027,1029,1032,1034,1036,1038,1041,1043,1045,1048,1050,1052,1054,1056,1058,1060],{"class":83,"line":84},[81,1004,762],{"class":392},[81,1006,1007],{"class":125}," newPlanName ",[81,1009,333],{"class":129},[81,1011,1012],{"class":125}," subscription",[81,1014,437],{"class":129},[81,1016,1017],{"class":125},"items",[81,1019,437],{"class":129},[81,1021,1022],{"class":125},"data[",[81,1024,895],{"class":894},[81,1026,153],{"class":125},[81,1028,888],{"class":129},[81,1030,1031],{"class":125},"price",[81,1033,437],{"class":129},[81,1035,994],{"class":125},[81,1037,888],{"class":129},[81,1039,1040],{"class":121},"split",[81,1042,126],{"class":125},[81,1044,145],{"class":129},[81,1046,1047],{"class":91},"_",[81,1049,145],{"class":129},[81,1051,298],{"class":125},[81,1053,888],{"class":129},[81,1055,891],{"class":125},[81,1057,895],{"class":894},[81,1059,153],{"class":125},[81,1061,301],{"class":129},[34,1063,1065],{"id":1064},"creating-the-checkout-session","Creating the checkout session",[18,1067,1068,1069,1072],{},"The endpoint lives at ",[78,1070,1071],{},"server\u002Fapi\u002Fprojects\u002F[id]\u002Fbilling\u002Findex.post.ts",". Auth check, permission check, then Stripe:",[71,1074,1076],{"className":105,"code":1075,"language":107,"meta":76,"style":76},"export default defineEventHandler(async (event) => {\n  const session = await requireUserSession(event);\n  const db = useDrizzle();\n  const projectId = getRouterParam(event, 'id');\n\n  const userProject = await getUserProjectWithRole(db, session.user.id, projectId);\n  if (!userProject) {\n    throw createError({ statusCode: 404, statusMessage: 'Project not found' });\n  }\n\n  const { role, project } = userProject;\n  if (role !== 'admin') {\n    throw createError({ statusCode: 403, statusMessage: 'Only project admins can manage billing' });\n  }\n\n  const { planName } = await readValidatedBody(event, bodySchema.parse);\n\n  return createCheckout({ event, project, planName, userId: session.user.id, userEmail: session.user.email });\n});\n",[78,1077,1078,1103,1127,1143,1171,1175,1215,1232,1271,1276,1280,1303,1326,1362,1366,1370,1406,1411,1476],{"__ignoreMap":76},[81,1079,1080,1082,1084,1087,1089,1092,1094,1097,1099,1101],{"class":83,"line":84},[81,1081,115],{"class":114},[81,1083,118],{"class":114},[81,1085,1086],{"class":121}," defineEventHandler",[81,1088,126],{"class":125},[81,1090,1091],{"class":392},"async",[81,1093,916],{"class":129},[81,1095,1096],{"class":628},"event",[81,1098,298],{"class":129},[81,1100,454],{"class":392},[81,1102,174],{"class":129},[81,1104,1105,1108,1111,1114,1116,1119,1121,1123,1125],{"class":83,"line":133},[81,1106,1107],{"class":392},"  const",[81,1109,1110],{"class":125}," session",[81,1112,1113],{"class":129}," =",[81,1115,770],{"class":114},[81,1117,1118],{"class":121}," requireUserSession",[81,1120,126],{"class":136},[81,1122,1096],{"class":125},[81,1124,298],{"class":136},[81,1126,301],{"class":129},[81,1128,1129,1131,1134,1136,1139,1141],{"class":83,"line":159},[81,1130,1107],{"class":392},[81,1132,1133],{"class":125}," db",[81,1135,1113],{"class":129},[81,1137,1138],{"class":121}," useDrizzle",[81,1140,434],{"class":136},[81,1142,301],{"class":129},[81,1144,1145,1147,1150,1152,1155,1157,1159,1161,1163,1165,1167,1169],{"class":83,"line":166},[81,1146,1107],{"class":392},[81,1148,1149],{"class":125}," projectId",[81,1151,1113],{"class":129},[81,1153,1154],{"class":121}," getRouterParam",[81,1156,126],{"class":136},[81,1158,1096],{"class":125},[81,1160,198],{"class":129},[81,1162,604],{"class":129},[81,1164,902],{"class":91},[81,1166,145],{"class":129},[81,1168,298],{"class":136},[81,1170,301],{"class":129},[81,1172,1173],{"class":83,"line":177},[81,1174,163],{"emptyLinePlaceholder":162},[81,1176,1177,1179,1182,1184,1186,1189,1191,1194,1196,1198,1200,1203,1205,1207,1209,1211,1213],{"class":83,"line":187},[81,1178,1107],{"class":392},[81,1180,1181],{"class":125}," userProject",[81,1183,1113],{"class":129},[81,1185,770],{"class":114},[81,1187,1188],{"class":121}," getUserProjectWithRole",[81,1190,126],{"class":136},[81,1192,1193],{"class":125},"db",[81,1195,198],{"class":129},[81,1197,1110],{"class":125},[81,1199,437],{"class":129},[81,1201,1202],{"class":125},"user",[81,1204,437],{"class":129},[81,1206,902],{"class":125},[81,1208,198],{"class":129},[81,1210,1149],{"class":125},[81,1212,298],{"class":136},[81,1214,301],{"class":129},[81,1216,1217,1220,1222,1224,1227,1230],{"class":83,"line":205},[81,1218,1219],{"class":114},"  if",[81,1221,916],{"class":136},[81,1223,919],{"class":129},[81,1225,1226],{"class":125},"userProject",[81,1228,1229],{"class":136},") ",[81,1231,130],{"class":129},[81,1233,1234,1237,1239,1241,1243,1246,1248,1251,1253,1256,1258,1260,1263,1265,1267,1269],{"class":83,"line":211},[81,1235,1236],{"class":114},"    throw",[81,1238,932],{"class":121},[81,1240,126],{"class":136},[81,1242,501],{"class":129},[81,1244,1245],{"class":136}," statusCode",[81,1247,102],{"class":129},[81,1249,1250],{"class":894}," 404",[81,1252,198],{"class":129},[81,1254,1255],{"class":136}," statusMessage",[81,1257,102],{"class":129},[81,1259,604],{"class":129},[81,1261,1262],{"class":91},"Project not found",[81,1264,145],{"class":129},[81,1266,612],{"class":129},[81,1268,298],{"class":136},[81,1270,301],{"class":129},[81,1272,1273],{"class":83,"line":221},[81,1274,1275],{"class":129},"  }\n",[81,1277,1278],{"class":83,"line":236},[81,1279,163],{"emptyLinePlaceholder":162},[81,1281,1282,1284,1287,1290,1292,1295,1297,1299,1301],{"class":83,"line":246},[81,1283,1107],{"class":392},[81,1285,1286],{"class":129}," {",[81,1288,1289],{"class":125}," role",[81,1291,198],{"class":129},[81,1293,1294],{"class":125}," project",[81,1296,612],{"class":129},[81,1298,1113],{"class":129},[81,1300,1181],{"class":125},[81,1302,301],{"class":129},[81,1304,1305,1307,1309,1312,1315,1317,1320,1322,1324],{"class":83,"line":261},[81,1306,1219],{"class":114},[81,1308,916],{"class":136},[81,1310,1311],{"class":125},"role",[81,1313,1314],{"class":129}," !==",[81,1316,604],{"class":129},[81,1318,1319],{"class":91},"admin",[81,1321,145],{"class":129},[81,1323,1229],{"class":136},[81,1325,130],{"class":129},[81,1327,1328,1330,1332,1334,1336,1338,1340,1343,1345,1347,1349,1351,1354,1356,1358,1360],{"class":83,"line":275},[81,1329,1236],{"class":114},[81,1331,932],{"class":121},[81,1333,126],{"class":136},[81,1335,501],{"class":129},[81,1337,1245],{"class":136},[81,1339,102],{"class":129},[81,1341,1342],{"class":894}," 403",[81,1344,198],{"class":129},[81,1346,1255],{"class":136},[81,1348,102],{"class":129},[81,1350,604],{"class":129},[81,1352,1353],{"class":91},"Only project admins can manage billing",[81,1355,145],{"class":129},[81,1357,612],{"class":129},[81,1359,298],{"class":136},[81,1361,301],{"class":129},[81,1363,1364],{"class":83,"line":281},[81,1365,1275],{"class":129},[81,1367,1368],{"class":83,"line":286},[81,1369,163],{"emptyLinePlaceholder":162},[81,1371,1372,1374,1376,1379,1381,1383,1385,1388,1390,1392,1394,1397,1399,1402,1404],{"class":83,"line":292},[81,1373,1107],{"class":392},[81,1375,1286],{"class":129},[81,1377,1378],{"class":125}," planName",[81,1380,612],{"class":129},[81,1382,1113],{"class":129},[81,1384,770],{"class":114},[81,1386,1387],{"class":121}," readValidatedBody",[81,1389,126],{"class":136},[81,1391,1096],{"class":125},[81,1393,198],{"class":129},[81,1395,1396],{"class":125}," bodySchema",[81,1398,437],{"class":129},[81,1400,1401],{"class":125},"parse",[81,1403,298],{"class":136},[81,1405,301],{"class":129},[81,1407,1409],{"class":83,"line":1408},17,[81,1410,163],{"emptyLinePlaceholder":162},[81,1412,1414,1417,1420,1422,1424,1427,1429,1431,1433,1435,1437,1440,1442,1444,1446,1448,1450,1452,1454,1457,1459,1461,1463,1465,1467,1470,1472,1474],{"class":83,"line":1413},18,[81,1415,1416],{"class":114},"  return",[81,1418,1419],{"class":121}," createCheckout",[81,1421,126],{"class":136},[81,1423,501],{"class":129},[81,1425,1426],{"class":125}," event",[81,1428,198],{"class":129},[81,1430,1294],{"class":125},[81,1432,198],{"class":129},[81,1434,1378],{"class":125},[81,1436,198],{"class":129},[81,1438,1439],{"class":136}," userId",[81,1441,102],{"class":129},[81,1443,1110],{"class":125},[81,1445,437],{"class":129},[81,1447,1202],{"class":125},[81,1449,437],{"class":129},[81,1451,902],{"class":125},[81,1453,198],{"class":129},[81,1455,1456],{"class":136}," userEmail",[81,1458,102],{"class":129},[81,1460,1110],{"class":125},[81,1462,437],{"class":129},[81,1464,1202],{"class":125},[81,1466,437],{"class":129},[81,1468,1469],{"class":125},"email",[81,1471,612],{"class":129},[81,1473,298],{"class":136},[81,1475,301],{"class":129},[81,1477,1479,1481,1483],{"class":83,"line":1478},19,[81,1480,295],{"class":129},[81,1482,298],{"class":125},[81,1484,301],{"class":129},[18,1486,1487,1488,1491,1492,1495,1496,1499],{},"Two things worth calling out. The permission check is ",[78,1489,1490],{},"role !== 'admin'",", because a regular team member being able to change the workspace plan is a support ticket waiting to happen. And ",[78,1493,1494],{},"readValidatedBody"," with a Zod enum means a request body of ",[78,1497,1498],{},"{\"planName\": \"free_but_with_everything\"}"," gets rejected before it reaches Stripe.",[18,1501,1502],{},"The session itself:",[71,1504,1506],{"className":105,"code":1505,"language":107,"meta":76,"style":76},"const config = useRuntimeConfig(event);\n\nconst sessionConfig: Record\u003Cstring, any> = {\n  billing_address_collection: 'auto',\n  line_items: [{ price: priceId, quantity: 1 }],\n  mode: 'subscription',\n  success_url: `${config.public.siteUrl}\u002Fdashboard?billing_success=true&session_id={CHECKOUT_SESSION_ID}`,\n  cancel_url: `${config.public.siteUrl}\u002Fdashboard\u002Fbilling`,\n  metadata: {\n    userId,\n    projectId: project.id,\n    planName,\n  },\n  automatic_tax: { enabled: true },\n  tax_id_collection: { enabled: true },\n};\n\nif (project.stripeCustomerId) {\n  sessionConfig.customer = project.stripeCustomerId;\n  sessionConfig.customer_update = { address: 'auto', name: 'auto' };\n} else {\n  sessionConfig.customer_email = userEmail;\n}\n\nconst stripeSession = await stripe.checkout.sessions.create(sessionConfig);\n\nreturn { url: stripeSession.url };\n",[78,1507,1508,1524,1528,1558,1574,1609,1625,1657,1685,1694,1701,1716,1722,1726,1745,1762,1767,1771,1785,1805,1846,1856,1872,1877,1882,1916,1921],{"__ignoreMap":76},[81,1509,1510,1512,1515,1517,1520,1522],{"class":83,"line":84},[81,1511,762],{"class":392},[81,1513,1514],{"class":125}," config ",[81,1516,333],{"class":129},[81,1518,1519],{"class":121}," useRuntimeConfig",[81,1521,776],{"class":125},[81,1523,301],{"class":129},[81,1525,1526],{"class":83,"line":133},[81,1527,163],{"emptyLinePlaceholder":162},[81,1529,1530,1532,1535,1537,1540,1543,1546,1548,1551,1554,1556],{"class":83,"line":159},[81,1531,762],{"class":392},[81,1533,1534],{"class":125}," sessionConfig",[81,1536,102],{"class":129},[81,1538,1539],{"class":87}," Record",[81,1541,1542],{"class":129},"\u003C",[81,1544,1545],{"class":87},"string",[81,1547,198],{"class":129},[81,1549,1550],{"class":87}," any",[81,1552,1553],{"class":129},">",[81,1555,1113],{"class":129},[81,1557,174],{"class":129},[81,1559,1560,1563,1565,1567,1570,1572],{"class":83,"line":166},[81,1561,1562],{"class":136},"  billing_address_collection",[81,1564,102],{"class":129},[81,1566,604],{"class":129},[81,1568,1569],{"class":91},"auto",[81,1571,145],{"class":129},[81,1573,156],{"class":129},[81,1575,1576,1579,1581,1583,1585,1588,1590,1593,1595,1598,1600,1603,1605,1607],{"class":83,"line":177},[81,1577,1578],{"class":136},"  line_items",[81,1580,102],{"class":129},[81,1582,142],{"class":125},[81,1584,501],{"class":129},[81,1586,1587],{"class":136}," price",[81,1589,102],{"class":129},[81,1591,1592],{"class":125}," priceId",[81,1594,198],{"class":129},[81,1596,1597],{"class":136}," quantity",[81,1599,102],{"class":129},[81,1601,1602],{"class":894}," 1",[81,1604,612],{"class":129},[81,1606,153],{"class":125},[81,1608,156],{"class":129},[81,1610,1611,1614,1616,1618,1621,1623],{"class":83,"line":187},[81,1612,1613],{"class":136},"  mode",[81,1615,102],{"class":129},[81,1617,604],{"class":129},[81,1619,1620],{"class":91},"subscription",[81,1622,145],{"class":129},[81,1624,156],{"class":129},[81,1626,1627,1630,1632,1635,1638,1640,1643,1645,1648,1650,1653,1655],{"class":83,"line":205},[81,1628,1629],{"class":136},"  success_url",[81,1631,102],{"class":129},[81,1633,1634],{"class":129}," `${",[81,1636,1637],{"class":125},"config",[81,1639,437],{"class":129},[81,1641,1642],{"class":125},"public",[81,1644,437],{"class":129},[81,1646,1647],{"class":125},"siteUrl",[81,1649,295],{"class":129},[81,1651,1652],{"class":91},"\u002Fdashboard?billing_success=true&session_id={CHECKOUT_SESSION_ID}",[81,1654,832],{"class":129},[81,1656,156],{"class":129},[81,1658,1659,1662,1664,1666,1668,1670,1672,1674,1676,1678,1681,1683],{"class":83,"line":211},[81,1660,1661],{"class":136},"  cancel_url",[81,1663,102],{"class":129},[81,1665,1634],{"class":129},[81,1667,1637],{"class":125},[81,1669,437],{"class":129},[81,1671,1642],{"class":125},[81,1673,437],{"class":129},[81,1675,1647],{"class":125},[81,1677,295],{"class":129},[81,1679,1680],{"class":91},"\u002Fdashboard\u002Fbilling",[81,1682,832],{"class":129},[81,1684,156],{"class":129},[81,1686,1687,1690,1692],{"class":83,"line":221},[81,1688,1689],{"class":136},"  metadata",[81,1691,102],{"class":129},[81,1693,174],{"class":129},[81,1695,1696,1699],{"class":83,"line":236},[81,1697,1698],{"class":125},"    userId",[81,1700,156],{"class":129},[81,1702,1703,1706,1708,1710,1712,1714],{"class":83,"line":246},[81,1704,1705],{"class":136},"    projectId",[81,1707,102],{"class":129},[81,1709,1294],{"class":125},[81,1711,437],{"class":129},[81,1713,902],{"class":125},[81,1715,156],{"class":129},[81,1717,1718,1720],{"class":83,"line":261},[81,1719,492],{"class":125},[81,1721,156],{"class":129},[81,1723,1724],{"class":83,"line":275},[81,1725,289],{"class":129},[81,1727,1728,1731,1733,1735,1738,1740,1742],{"class":83,"line":281},[81,1729,1730],{"class":136},"  automatic_tax",[81,1732,102],{"class":129},[81,1734,1286],{"class":129},[81,1736,1737],{"class":136}," enabled",[81,1739,102],{"class":129},[81,1741,270],{"class":269},[81,1743,1744],{"class":129}," },\n",[81,1746,1747,1750,1752,1754,1756,1758,1760],{"class":83,"line":286},[81,1748,1749],{"class":136},"  tax_id_collection",[81,1751,102],{"class":129},[81,1753,1286],{"class":129},[81,1755,1737],{"class":136},[81,1757,102],{"class":129},[81,1759,270],{"class":269},[81,1761,1744],{"class":129},[81,1763,1764],{"class":83,"line":292},[81,1765,1766],{"class":129},"};\n",[81,1768,1769],{"class":83,"line":1408},[81,1770,163],{"emptyLinePlaceholder":162},[81,1772,1773,1775,1778,1780,1783],{"class":83,"line":1413},[81,1774,913],{"class":114},[81,1776,1777],{"class":125}," (project",[81,1779,437],{"class":129},[81,1781,1782],{"class":125},"stripeCustomerId) ",[81,1784,130],{"class":129},[81,1786,1787,1790,1792,1795,1797,1799,1801,1803],{"class":83,"line":1478},[81,1788,1789],{"class":125},"  sessionConfig",[81,1791,437],{"class":129},[81,1793,1794],{"class":125},"customer",[81,1796,1113],{"class":129},[81,1798,1294],{"class":125},[81,1800,437],{"class":129},[81,1802,376],{"class":125},[81,1804,301],{"class":129},[81,1806,1808,1810,1812,1815,1817,1819,1822,1824,1826,1828,1830,1832,1835,1837,1839,1841,1843],{"class":83,"line":1807},20,[81,1809,1789],{"class":125},[81,1811,437],{"class":129},[81,1813,1814],{"class":125},"customer_update",[81,1816,1113],{"class":129},[81,1818,1286],{"class":129},[81,1820,1821],{"class":136}," address",[81,1823,102],{"class":129},[81,1825,604],{"class":129},[81,1827,1569],{"class":91},[81,1829,145],{"class":129},[81,1831,198],{"class":129},[81,1833,1834],{"class":136}," name",[81,1836,102],{"class":129},[81,1838,604],{"class":129},[81,1840,1569],{"class":91},[81,1842,145],{"class":129},[81,1844,1845],{"class":129}," };\n",[81,1847,1849,1851,1854],{"class":83,"line":1848},21,[81,1850,295],{"class":129},[81,1852,1853],{"class":114}," else",[81,1855,174],{"class":129},[81,1857,1859,1861,1863,1866,1868,1870],{"class":83,"line":1858},22,[81,1860,1789],{"class":125},[81,1862,437],{"class":129},[81,1864,1865],{"class":125},"customer_email",[81,1867,1113],{"class":129},[81,1869,1456],{"class":125},[81,1871,301],{"class":129},[81,1873,1875],{"class":83,"line":1874},23,[81,1876,985],{"class":129},[81,1878,1880],{"class":83,"line":1879},24,[81,1881,163],{"emptyLinePlaceholder":162},[81,1883,1885,1887,1890,1892,1894,1896,1898,1901,1903,1906,1908,1911,1914],{"class":83,"line":1884},25,[81,1886,762],{"class":392},[81,1888,1889],{"class":125}," stripeSession ",[81,1891,333],{"class":129},[81,1893,770],{"class":114},[81,1895,796],{"class":125},[81,1897,437],{"class":129},[81,1899,1900],{"class":125},"checkout",[81,1902,437],{"class":129},[81,1904,1905],{"class":125},"sessions",[81,1907,437],{"class":129},[81,1909,1910],{"class":121},"create",[81,1912,1913],{"class":125},"(sessionConfig)",[81,1915,301],{"class":129},[81,1917,1919],{"class":83,"line":1918},26,[81,1920,163],{"emptyLinePlaceholder":162},[81,1922,1924,1927,1929,1932,1934,1937,1939,1942],{"class":83,"line":1923},27,[81,1925,1926],{"class":114},"return",[81,1928,1286],{"class":129},[81,1930,1931],{"class":136}," url",[81,1933,102],{"class":129},[81,1935,1936],{"class":125}," stripeSession",[81,1938,437],{"class":129},[81,1940,1941],{"class":125},"url ",[81,1943,1766],{"class":129},[18,1945,1946,1947,1950,1951,1954,1955,1957],{},"The ",[78,1948,1949],{},"metadata"," object is the important part. When the webhook fires, Stripe tells you a payment happened, but it has no idea which row in your database to update. The metadata is the only link back. Put ",[78,1952,1953],{},"projectId"," and ",[78,1956,712],{}," in there and the webhook becomes trivial.",[18,1959,1960,1961,1963,1964,1966],{},"Reusing ",[78,1962,1794],{}," when you already have a ",[78,1965,376],{}," matters too. Skip it and a customer upgrading for the second time gets a duplicate Stripe customer record, along with two payment methods, two invoice histories, and one confused founder in the dashboard.",[18,1968,1946,1969,1972],{},[78,1970,1971],{},"{CHECKOUT_SESSION_ID}"," in the success URL is a literal placeholder that Stripe substitutes. Don't template it yourself.",[18,1974,1975],{},"On the client, it's one call:",[71,1977,1979],{"className":105,"code":1978,"language":107,"meta":76,"style":76},"async function subscribe(planName: string) {\n  const { url } = await $fetch(`\u002Fapi\u002Fprojects\u002F${projectId}\u002Fbilling`, {\n    method: 'POST',\n    body: { planName },\n  });\n\n  if (url) {\n    await navigateTo(url, { external: true });\n  }\n}\n",[78,1980,1981,2004,2043,2059,2072,2080,2084,2097,2126,2130],{"__ignoreMap":76},[81,1982,1983,1985,1988,1991,1993,1995,1997,2000,2002],{"class":83,"line":84},[81,1984,1091],{"class":392},[81,1986,1987],{"class":392}," function",[81,1989,1990],{"class":121}," subscribe",[81,1992,126],{"class":129},[81,1994,712],{"class":628},[81,1996,102],{"class":129},[81,1998,1999],{"class":87}," string",[81,2001,298],{"class":129},[81,2003,174],{"class":129},[81,2005,2006,2008,2010,2012,2014,2016,2018,2021,2023,2025,2028,2030,2032,2034,2037,2039,2041],{"class":83,"line":133},[81,2007,1107],{"class":392},[81,2009,1286],{"class":129},[81,2011,1931],{"class":125},[81,2013,612],{"class":129},[81,2015,1113],{"class":129},[81,2017,770],{"class":114},[81,2019,2020],{"class":121}," $fetch",[81,2022,126],{"class":136},[81,2024,832],{"class":129},[81,2026,2027],{"class":91},"\u002Fapi\u002Fprojects\u002F",[81,2029,964],{"class":129},[81,2031,1953],{"class":125},[81,2033,295],{"class":129},[81,2035,2036],{"class":91},"\u002Fbilling",[81,2038,832],{"class":129},[81,2040,198],{"class":129},[81,2042,174],{"class":129},[81,2044,2045,2048,2050,2052,2055,2057],{"class":83,"line":159},[81,2046,2047],{"class":136},"    method",[81,2049,102],{"class":129},[81,2051,604],{"class":129},[81,2053,2054],{"class":91},"POST",[81,2056,145],{"class":129},[81,2058,156],{"class":129},[81,2060,2061,2064,2066,2068,2070],{"class":83,"line":166},[81,2062,2063],{"class":136},"    body",[81,2065,102],{"class":129},[81,2067,1286],{"class":129},[81,2069,1378],{"class":125},[81,2071,1744],{"class":129},[81,2073,2074,2076,2078],{"class":83,"line":177},[81,2075,976],{"class":129},[81,2077,298],{"class":136},[81,2079,301],{"class":129},[81,2081,2082],{"class":83,"line":187},[81,2083,163],{"emptyLinePlaceholder":162},[81,2085,2086,2088,2090,2093,2095],{"class":83,"line":205},[81,2087,1219],{"class":114},[81,2089,916],{"class":136},[81,2091,2092],{"class":125},"url",[81,2094,1229],{"class":136},[81,2096,130],{"class":129},[81,2098,2099,2102,2105,2107,2109,2111,2113,2116,2118,2120,2122,2124],{"class":83,"line":211},[81,2100,2101],{"class":114},"    await",[81,2103,2104],{"class":121}," navigateTo",[81,2106,126],{"class":136},[81,2108,2092],{"class":125},[81,2110,198],{"class":129},[81,2112,1286],{"class":129},[81,2114,2115],{"class":136}," external",[81,2117,102],{"class":129},[81,2119,270],{"class":269},[81,2121,612],{"class":129},[81,2123,298],{"class":136},[81,2125,301],{"class":129},[81,2127,2128],{"class":83,"line":221},[81,2129,1275],{"class":129},[81,2131,2132],{"class":83,"line":236},[81,2133,985],{"class":129},[18,2135,2136,2139,2140,2143],{},[78,2137,2138],{},"external: true"," is required. Without it, Nuxt tries to resolve ",[78,2141,2142],{},"checkout.stripe.com"," as an internal route and nothing happens, which is a fun five minutes of debugging.",[34,2145,2147],{"id":2146},"the-webhook-where-the-real-work-is","The webhook, where the real work is",[18,2149,2150],{},"Two rules. Never trust the success redirect as proof of payment, and always verify the signature.",[18,2152,2153,2154,2157],{},"The redirect is a browser navigation. Users close the tab, lose connection, or (if you built it wrong) just visit ",[78,2155,2156],{},"\u002Fdashboard?billing_success=true"," manually. The webhook is the source of truth.",[71,2159,2161],{"className":105,"code":2160,"language":107,"meta":76,"style":76},"import { useServerStripe } from '#stripe\u002Fserver';\nimport type Stripe from 'stripe';\n\nexport default defineEventHandler(async (event) => {\n  const stripe = await useServerStripe(event);\n  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;\n\n  const signature = getHeader(event, 'stripe-signature');\n  if (!signature) {\n    throw createError({ statusCode: 400, message: 'Missing stripe-signature header' });\n  }\n\n  const body = await readRawBody(event);\n  if (!body) {\n    throw createError({ statusCode: 400, message: 'Missing request body' });\n  }\n\n  let stripeEvent: Stripe.Event;\n\n  try {\n    stripeEvent = await stripe.webhooks.constructEventAsync(body, signature, webhookSecret);\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Unknown error';\n    throw createError({ statusCode: 400, message: `Webhook Error: ${message}` });\n  }\n\n  \u002F\u002F handle events...\n  return { received: true };\n});\n",[78,2162,2163,2186,2208,2212,2234,2254,2277,2281,2310,2325,2362,2366,2370,2392,2407,2442,2446,2450,2470,2474,2481,2518,2534,2574,2613,2617,2621,2626,2642],{"__ignoreMap":76},[81,2164,2165,2168,2170,2172,2174,2177,2179,2182,2184],{"class":83,"line":84},[81,2166,2167],{"class":114},"import",[81,2169,1286],{"class":129},[81,2171,773],{"class":125},[81,2173,612],{"class":129},[81,2175,2176],{"class":114}," from",[81,2178,604],{"class":129},[81,2180,2181],{"class":91},"#stripe\u002Fserver",[81,2183,145],{"class":129},[81,2185,301],{"class":129},[81,2187,2188,2190,2193,2196,2199,2201,2204,2206],{"class":83,"line":133},[81,2189,2167],{"class":114},[81,2191,2192],{"class":114}," type",[81,2194,2195],{"class":125}," Stripe ",[81,2197,2198],{"class":114},"from",[81,2200,604],{"class":129},[81,2202,2203],{"class":91},"stripe",[81,2205,145],{"class":129},[81,2207,301],{"class":129},[81,2209,2210],{"class":83,"line":159},[81,2211,163],{"emptyLinePlaceholder":162},[81,2213,2214,2216,2218,2220,2222,2224,2226,2228,2230,2232],{"class":83,"line":166},[81,2215,115],{"class":114},[81,2217,118],{"class":114},[81,2219,1086],{"class":121},[81,2221,126],{"class":125},[81,2223,1091],{"class":392},[81,2225,916],{"class":129},[81,2227,1096],{"class":628},[81,2229,298],{"class":129},[81,2231,454],{"class":392},[81,2233,174],{"class":129},[81,2235,2236,2238,2240,2242,2244,2246,2248,2250,2252],{"class":83,"line":177},[81,2237,1107],{"class":392},[81,2239,796],{"class":125},[81,2241,1113],{"class":129},[81,2243,770],{"class":114},[81,2245,773],{"class":121},[81,2247,126],{"class":136},[81,2249,1096],{"class":125},[81,2251,298],{"class":136},[81,2253,301],{"class":129},[81,2255,2256,2258,2261,2263,2266,2268,2271,2273,2275],{"class":83,"line":187},[81,2257,1107],{"class":392},[81,2259,2260],{"class":125}," webhookSecret",[81,2262,1113],{"class":129},[81,2264,2265],{"class":125}," process",[81,2267,437],{"class":129},[81,2269,2270],{"class":125},"env",[81,2272,437],{"class":129},[81,2274,351],{"class":125},[81,2276,301],{"class":129},[81,2278,2279],{"class":83,"line":205},[81,2280,163],{"emptyLinePlaceholder":162},[81,2282,2283,2285,2288,2290,2293,2295,2297,2299,2301,2304,2306,2308],{"class":83,"line":211},[81,2284,1107],{"class":392},[81,2286,2287],{"class":125}," signature",[81,2289,1113],{"class":129},[81,2291,2292],{"class":121}," getHeader",[81,2294,126],{"class":136},[81,2296,1096],{"class":125},[81,2298,198],{"class":129},[81,2300,604],{"class":129},[81,2302,2303],{"class":91},"stripe-signature",[81,2305,145],{"class":129},[81,2307,298],{"class":136},[81,2309,301],{"class":129},[81,2311,2312,2314,2316,2318,2321,2323],{"class":83,"line":221},[81,2313,1219],{"class":114},[81,2315,916],{"class":136},[81,2317,919],{"class":129},[81,2319,2320],{"class":125},"signature",[81,2322,1229],{"class":136},[81,2324,130],{"class":129},[81,2326,2327,2329,2331,2333,2335,2337,2339,2342,2344,2347,2349,2351,2354,2356,2358,2360],{"class":83,"line":236},[81,2328,1236],{"class":114},[81,2330,932],{"class":121},[81,2332,126],{"class":136},[81,2334,501],{"class":129},[81,2336,1245],{"class":136},[81,2338,102],{"class":129},[81,2340,2341],{"class":894}," 400",[81,2343,198],{"class":129},[81,2345,2346],{"class":136}," message",[81,2348,102],{"class":129},[81,2350,604],{"class":129},[81,2352,2353],{"class":91},"Missing stripe-signature header",[81,2355,145],{"class":129},[81,2357,612],{"class":129},[81,2359,298],{"class":136},[81,2361,301],{"class":129},[81,2363,2364],{"class":83,"line":246},[81,2365,1275],{"class":129},[81,2367,2368],{"class":83,"line":261},[81,2369,163],{"emptyLinePlaceholder":162},[81,2371,2372,2374,2377,2379,2381,2384,2386,2388,2390],{"class":83,"line":275},[81,2373,1107],{"class":392},[81,2375,2376],{"class":125}," body",[81,2378,1113],{"class":129},[81,2380,770],{"class":114},[81,2382,2383],{"class":121}," readRawBody",[81,2385,126],{"class":136},[81,2387,1096],{"class":125},[81,2389,298],{"class":136},[81,2391,301],{"class":129},[81,2393,2394,2396,2398,2400,2403,2405],{"class":83,"line":281},[81,2395,1219],{"class":114},[81,2397,916],{"class":136},[81,2399,919],{"class":129},[81,2401,2402],{"class":125},"body",[81,2404,1229],{"class":136},[81,2406,130],{"class":129},[81,2408,2409,2411,2413,2415,2417,2419,2421,2423,2425,2427,2429,2431,2434,2436,2438,2440],{"class":83,"line":286},[81,2410,1236],{"class":114},[81,2412,932],{"class":121},[81,2414,126],{"class":136},[81,2416,501],{"class":129},[81,2418,1245],{"class":136},[81,2420,102],{"class":129},[81,2422,2341],{"class":894},[81,2424,198],{"class":129},[81,2426,2346],{"class":136},[81,2428,102],{"class":129},[81,2430,604],{"class":129},[81,2432,2433],{"class":91},"Missing request body",[81,2435,145],{"class":129},[81,2437,612],{"class":129},[81,2439,298],{"class":136},[81,2441,301],{"class":129},[81,2443,2444],{"class":83,"line":292},[81,2445,1275],{"class":129},[81,2447,2448],{"class":83,"line":1408},[81,2449,163],{"emptyLinePlaceholder":162},[81,2451,2452,2455,2458,2460,2463,2465,2468],{"class":83,"line":1413},[81,2453,2454],{"class":392},"  let",[81,2456,2457],{"class":125}," stripeEvent",[81,2459,102],{"class":129},[81,2461,2462],{"class":87}," Stripe",[81,2464,437],{"class":129},[81,2466,2467],{"class":87},"Event",[81,2469,301],{"class":129},[81,2471,2472],{"class":83,"line":1478},[81,2473,163],{"emptyLinePlaceholder":162},[81,2475,2476,2479],{"class":83,"line":1807},[81,2477,2478],{"class":114},"  try",[81,2480,174],{"class":129},[81,2482,2483,2486,2488,2490,2492,2494,2497,2499,2502,2504,2506,2508,2510,2512,2514,2516],{"class":83,"line":1848},[81,2484,2485],{"class":125},"    stripeEvent",[81,2487,1113],{"class":129},[81,2489,770],{"class":114},[81,2491,796],{"class":125},[81,2493,437],{"class":129},[81,2495,2496],{"class":125},"webhooks",[81,2498,437],{"class":129},[81,2500,2501],{"class":121},"constructEventAsync",[81,2503,126],{"class":136},[81,2505,2402],{"class":125},[81,2507,198],{"class":129},[81,2509,2287],{"class":125},[81,2511,198],{"class":129},[81,2513,2260],{"class":125},[81,2515,298],{"class":136},[81,2517,301],{"class":129},[81,2519,2520,2522,2525,2527,2530,2532],{"class":83,"line":1858},[81,2521,976],{"class":129},[81,2523,2524],{"class":114}," catch",[81,2526,916],{"class":136},[81,2528,2529],{"class":125},"error",[81,2531,1229],{"class":136},[81,2533,130],{"class":129},[81,2535,2536,2539,2541,2543,2546,2549,2552,2555,2557,2559,2562,2565,2567,2570,2572],{"class":83,"line":1874},[81,2537,2538],{"class":392},"    const",[81,2540,2346],{"class":125},[81,2542,1113],{"class":129},[81,2544,2545],{"class":125}," error",[81,2547,2548],{"class":129}," instanceof",[81,2550,2551],{"class":87}," Error",[81,2553,2554],{"class":129}," ?",[81,2556,2545],{"class":125},[81,2558,437],{"class":129},[81,2560,2561],{"class":125},"message",[81,2563,2564],{"class":129}," :",[81,2566,604],{"class":129},[81,2568,2569],{"class":91},"Unknown error",[81,2571,145],{"class":129},[81,2573,301],{"class":129},[81,2575,2576,2578,2580,2582,2584,2586,2588,2590,2592,2594,2596,2598,2601,2603,2605,2607,2609,2611],{"class":83,"line":1879},[81,2577,1236],{"class":114},[81,2579,932],{"class":121},[81,2581,126],{"class":136},[81,2583,501],{"class":129},[81,2585,1245],{"class":136},[81,2587,102],{"class":129},[81,2589,2341],{"class":894},[81,2591,198],{"class":129},[81,2593,2346],{"class":136},[81,2595,102],{"class":129},[81,2597,958],{"class":129},[81,2599,2600],{"class":91},"Webhook Error: ",[81,2602,964],{"class":129},[81,2604,2561],{"class":125},[81,2606,969],{"class":129},[81,2608,612],{"class":129},[81,2610,298],{"class":136},[81,2612,301],{"class":129},[81,2614,2615],{"class":83,"line":1884},[81,2616,1275],{"class":129},[81,2618,2619],{"class":83,"line":1918},[81,2620,163],{"emptyLinePlaceholder":162},[81,2622,2623],{"class":83,"line":1923},[81,2624,2625],{"class":201},"  \u002F\u002F handle events...\n",[81,2627,2629,2631,2633,2636,2638,2640],{"class":83,"line":2628},28,[81,2630,1416],{"class":114},[81,2632,1286],{"class":129},[81,2634,2635],{"class":136}," received",[81,2637,102],{"class":129},[81,2639,270],{"class":269},[81,2641,1845],{"class":129},[81,2643,2645,2647,2649],{"class":83,"line":2644},29,[81,2646,295],{"class":129},[81,2648,298],{"class":125},[81,2650,301],{"class":129},[18,2652,2653],{},"Two details that cost people hours.",[18,2655,2656,2666],{},[2657,2658,2659,2662,2663,437],"strong",{},[78,2660,2661],{},"readRawBody",", not ",[78,2664,2665],{},"readBody"," The signature is computed over the exact bytes Stripe sent. Parse the JSON first and re-serialize it and the signature won't match, because key order and whitespace changed.",[18,2668,2669,2676],{},[2657,2670,2671,2662,2673,437],{},[78,2672,2501],{},[78,2674,2675],{},"constructEvent"," The synchronous version uses Node's crypto module. On Cloudflare Workers, Vercel Edge, or any Web Crypto runtime, that either isn't there or works differently. The async version uses the Web Crypto API and works everywhere, including Node. If your webhook works locally and returns 400 in production, this is usually why.",[34,2678,2680],{"id":2679},"the-four-events-that-matter","The four events that matter",[18,2682,2683],{},"Stripe fires dozens of event types. For a basic subscription SaaS you need four.",[18,2685,2686,2691],{},[2657,2687,2688],{},[78,2689,2690],{},"checkout.session.completed"," fires when someone finishes paying. This is where the subscription starts:",[71,2693,2695],{"className":105,"code":2694,"language":107,"meta":76,"style":76},"case 'checkout.session.completed': {\n  const session = stripeEvent.data.object as Stripe.Checkout.Session;\n\n  const projectId = session.metadata?.projectId;\n  const planName = session.metadata?.planName as PlanName | undefined;\n\n  if (!projectId || !planName) {\n    console.error('Missing projectId or planName in session metadata');\n    break;\n  }\n\n  await db\n    .update(projects)\n    .set({\n      planName,\n      subscriptionStatus: 'active',\n      stripeCustomerId: session.customer as string,\n      stripeSubscriptionId: session.subscription as string,\n      stripeCurrentPeriodEnd: null,\n    })\n    .where(eq(projects.id, projectId));\n\n  break;\n}\n",[78,2696,2697,2713,2748,2752,2770,2801,2805,2824,2846,2853,2857,2861,2866,2871,2878,2885,2900,2920,2939,2949,2957,2979,2983,2988],{"__ignoreMap":76},[81,2698,2699,2702,2704,2706,2708,2711],{"class":83,"line":84},[81,2700,2701],{"class":114},"case",[81,2703,604],{"class":129},[81,2705,2690],{"class":91},[81,2707,145],{"class":129},[81,2709,2710],{"class":125},": ",[81,2712,130],{"class":129},[81,2714,2715,2718,2720,2722,2724,2726,2728,2731,2734,2736,2738,2741,2743,2746],{"class":83,"line":133},[81,2716,2717],{"class":125},"  const session ",[81,2719,333],{"class":129},[81,2721,2457],{"class":125},[81,2723,437],{"class":129},[81,2725,885],{"class":125},[81,2727,437],{"class":129},[81,2729,2730],{"class":125},"object ",[81,2732,2733],{"class":114},"as",[81,2735,2462],{"class":87},[81,2737,437],{"class":129},[81,2739,2740],{"class":87},"Checkout",[81,2742,437],{"class":129},[81,2744,2745],{"class":87},"Session",[81,2747,301],{"class":125},[81,2749,2750],{"class":83,"line":159},[81,2751,163],{"emptyLinePlaceholder":162},[81,2753,2754,2757,2759,2761,2763,2765,2767],{"class":83,"line":166},[81,2755,2756],{"class":125},"  const projectId ",[81,2758,333],{"class":129},[81,2760,1110],{"class":125},[81,2762,437],{"class":129},[81,2764,1949],{"class":125},[81,2766,888],{"class":129},[81,2768,2769],{"class":125},"projectId;\n",[81,2771,2772,2775,2777,2779,2781,2783,2785,2788,2790,2793,2796,2799],{"class":83,"line":177},[81,2773,2774],{"class":125},"  const planName ",[81,2776,333],{"class":129},[81,2778,1110],{"class":125},[81,2780,437],{"class":129},[81,2782,1949],{"class":125},[81,2784,888],{"class":129},[81,2786,2787],{"class":125},"planName ",[81,2789,2733],{"class":114},[81,2791,2792],{"class":87}," PlanName",[81,2794,2795],{"class":129}," |",[81,2797,2798],{"class":87}," undefined",[81,2800,301],{"class":125},[81,2802,2803],{"class":83,"line":187},[81,2804,163],{"emptyLinePlaceholder":162},[81,2806,2807,2809,2811,2813,2815,2818,2820,2822],{"class":83,"line":205},[81,2808,1219],{"class":136},[81,2810,916],{"class":129},[81,2812,919],{"class":125},[81,2814,1953],{"class":628},[81,2816,2817],{"class":125}," || !",[81,2819,712],{"class":628},[81,2821,298],{"class":129},[81,2823,174],{"class":129},[81,2825,2826,2829,2831,2833,2835,2837,2840,2842,2844],{"class":83,"line":211},[81,2827,2828],{"class":125},"    console",[81,2830,437],{"class":129},[81,2832,2529],{"class":121},[81,2834,126],{"class":136},[81,2836,145],{"class":129},[81,2838,2839],{"class":91},"Missing projectId or planName in session metadata",[81,2841,145],{"class":129},[81,2843,298],{"class":136},[81,2845,301],{"class":129},[81,2847,2848,2851],{"class":83,"line":221},[81,2849,2850],{"class":114},"    break",[81,2852,301],{"class":129},[81,2854,2855],{"class":83,"line":236},[81,2856,1275],{"class":129},[81,2858,2859],{"class":83,"line":246},[81,2860,163],{"emptyLinePlaceholder":162},[81,2862,2863],{"class":83,"line":261},[81,2864,2865],{"class":125},"  await db\n",[81,2867,2868],{"class":83,"line":275},[81,2869,2870],{"class":125},"    .update(projects)\n",[81,2872,2873,2876],{"class":83,"line":281},[81,2874,2875],{"class":125},"    .set(",[81,2877,130],{"class":129},[81,2879,2880,2883],{"class":83,"line":286},[81,2881,2882],{"class":125},"      planName",[81,2884,156],{"class":129},[81,2886,2887,2890,2892,2894,2896,2898],{"class":83,"line":292},[81,2888,2889],{"class":87},"      subscriptionStatus",[81,2891,102],{"class":129},[81,2893,604],{"class":129},[81,2895,550],{"class":91},[81,2897,145],{"class":129},[81,2899,156],{"class":129},[81,2901,2902,2905,2907,2909,2911,2913,2916,2918],{"class":83,"line":1408},[81,2903,2904],{"class":87},"      stripeCustomerId",[81,2906,102],{"class":129},[81,2908,1110],{"class":125},[81,2910,437],{"class":129},[81,2912,1794],{"class":125},[81,2914,2915],{"class":114}," as",[81,2917,1999],{"class":87},[81,2919,156],{"class":129},[81,2921,2922,2925,2927,2929,2931,2933,2935,2937],{"class":83,"line":1413},[81,2923,2924],{"class":87},"      stripeSubscriptionId",[81,2926,102],{"class":129},[81,2928,1110],{"class":125},[81,2930,437],{"class":129},[81,2932,1620],{"class":125},[81,2934,2915],{"class":114},[81,2936,1999],{"class":87},[81,2938,156],{"class":129},[81,2940,2941,2944,2946],{"class":83,"line":1478},[81,2942,2943],{"class":87},"      stripeCurrentPeriodEnd",[81,2945,102],{"class":129},[81,2947,2948],{"class":129}," null,\n",[81,2950,2951,2954],{"class":83,"line":1807},[81,2952,2953],{"class":129},"    }",[81,2955,2956],{"class":125},")\n",[81,2958,2959,2962,2965,2967,2970,2972,2974,2976],{"class":83,"line":1848},[81,2960,2961],{"class":125},"    .where(",[81,2963,2964],{"class":136},"eq",[81,2966,126],{"class":129},[81,2968,2969],{"class":125},"projects.id",[81,2971,198],{"class":129},[81,2973,1149],{"class":628},[81,2975,298],{"class":129},[81,2977,2978],{"class":125},");\n",[81,2980,2981],{"class":83,"line":1858},[81,2982,163],{"emptyLinePlaceholder":162},[81,2984,2985],{"class":83,"line":1874},[81,2986,2987],{"class":125},"  break;\n",[81,2989,2990],{"class":83,"line":1879},[81,2991,985],{"class":129},[18,2993,2994,2999,3000,3003],{},[2657,2995,2996],{},[78,2997,2998],{},"customer.subscription.updated"," is the one that does the most work. Plan changes, cancellations scheduled for period end, reactivations, and status transitions like ",[78,3001,3002],{},"past_due"," all arrive here:",[71,3005,3007],{"className":105,"code":3006,"language":107,"meta":76,"style":76},"case 'customer.subscription.updated': {\n  const subscription = stripeEvent.data.object as Stripe.Subscription;\n\n  const project = await db.query.projects.findFirst({\n    where: eq(projects.stripeSubscriptionId, subscription.id),\n  });\n\n  if (!project) {\n    break;\n  }\n\n  const newPlanName = subscription.items.data[0]?.price.lookup_key?.split('_')?.[0];\n\n  let subscriptionStatus: string = subscription.status;\n  if (subscription.cancel_at_period_end) {\n    subscriptionStatus = 'canceling';\n  }\n\n  await db\n    .update(projects)\n    .set({\n      planName: (newPlanName || project.planName) as PlanName,\n      stripeCurrentPeriodEnd: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null,\n      subscriptionStatus,\n    })\n    .where(eq(projects.id, project.id));\n\n  break;\n}\n",[78,3008,3009,3023,3051,3055,3084,3112,3118,3122,3137,3143,3147,3151,3203,3207,3228,3242,3258,3262,3266,3274,3285,3296,3321,3362,3368,3374,3400,3404,3408],{"__ignoreMap":76},[81,3010,3011,3013,3015,3017,3019,3021],{"class":83,"line":84},[81,3012,2701],{"class":114},[81,3014,604],{"class":129},[81,3016,2998],{"class":91},[81,3018,145],{"class":129},[81,3020,2710],{"class":125},[81,3022,130],{"class":129},[81,3024,3025,3028,3030,3032,3034,3036,3038,3040,3042,3044,3046,3049],{"class":83,"line":133},[81,3026,3027],{"class":125},"  const subscription ",[81,3029,333],{"class":129},[81,3031,2457],{"class":125},[81,3033,437],{"class":129},[81,3035,885],{"class":125},[81,3037,437],{"class":129},[81,3039,2730],{"class":125},[81,3041,2733],{"class":114},[81,3043,2462],{"class":87},[81,3045,437],{"class":129},[81,3047,3048],{"class":87},"Subscription",[81,3050,301],{"class":125},[81,3052,3053],{"class":83,"line":159},[81,3054,163],{"emptyLinePlaceholder":162},[81,3056,3057,3060,3062,3064,3066,3068,3071,3073,3075,3077,3080,3082],{"class":83,"line":166},[81,3058,3059],{"class":125},"  const project ",[81,3061,333],{"class":129},[81,3063,770],{"class":114},[81,3065,1133],{"class":125},[81,3067,437],{"class":129},[81,3069,3070],{"class":125},"query",[81,3072,437],{"class":129},[81,3074,412],{"class":125},[81,3076,437],{"class":129},[81,3078,3079],{"class":121},"findFirst",[81,3081,126],{"class":125},[81,3083,130],{"class":129},[81,3085,3086,3089,3091,3094,3097,3099,3101,3103,3105,3107,3110],{"class":83,"line":177},[81,3087,3088],{"class":136},"    where",[81,3090,102],{"class":129},[81,3092,3093],{"class":121}," eq",[81,3095,3096],{"class":125},"(projects",[81,3098,437],{"class":129},[81,3100,720],{"class":125},[81,3102,198],{"class":129},[81,3104,1012],{"class":125},[81,3106,437],{"class":129},[81,3108,3109],{"class":125},"id)",[81,3111,156],{"class":129},[81,3113,3114,3116],{"class":83,"line":187},[81,3115,976],{"class":129},[81,3117,2978],{"class":125},[81,3119,3120],{"class":83,"line":205},[81,3121,163],{"emptyLinePlaceholder":162},[81,3123,3124,3126,3128,3130,3133,3135],{"class":83,"line":211},[81,3125,1219],{"class":136},[81,3127,916],{"class":129},[81,3129,919],{"class":125},[81,3131,3132],{"class":628},"project",[81,3134,298],{"class":129},[81,3136,174],{"class":129},[81,3138,3139,3141],{"class":83,"line":221},[81,3140,2850],{"class":114},[81,3142,301],{"class":129},[81,3144,3145],{"class":83,"line":236},[81,3146,1275],{"class":129},[81,3148,3149],{"class":83,"line":246},[81,3150,163],{"emptyLinePlaceholder":162},[81,3152,3153,3156,3158,3160,3162,3164,3166,3168,3170,3172,3174,3176,3178,3180,3182,3184,3186,3188,3190,3192,3194,3196,3198,3200],{"class":83,"line":261},[81,3154,3155],{"class":125},"  const newPlanName ",[81,3157,333],{"class":129},[81,3159,1012],{"class":125},[81,3161,437],{"class":129},[81,3163,1017],{"class":125},[81,3165,437],{"class":129},[81,3167,1022],{"class":125},[81,3169,895],{"class":894},[81,3171,153],{"class":125},[81,3173,888],{"class":129},[81,3175,1031],{"class":125},[81,3177,437],{"class":129},[81,3179,994],{"class":125},[81,3181,888],{"class":129},[81,3183,1040],{"class":121},[81,3185,126],{"class":125},[81,3187,145],{"class":129},[81,3189,1047],{"class":91},[81,3191,145],{"class":129},[81,3193,298],{"class":125},[81,3195,888],{"class":129},[81,3197,891],{"class":125},[81,3199,895],{"class":894},[81,3201,3202],{"class":125},"];\n",[81,3204,3205],{"class":83,"line":275},[81,3206,163],{"emptyLinePlaceholder":162},[81,3208,3209,3212,3214,3216,3219,3221,3223,3225],{"class":83,"line":281},[81,3210,3211],{"class":125},"  let ",[81,3213,716],{"class":136},[81,3215,102],{"class":129},[81,3217,3218],{"class":125}," string ",[81,3220,333],{"class":129},[81,3222,1012],{"class":125},[81,3224,437],{"class":129},[81,3226,3227],{"class":125},"status;\n",[81,3229,3230,3232,3235,3237,3240],{"class":83,"line":286},[81,3231,1219],{"class":121},[81,3233,3234],{"class":125}," (subscription",[81,3236,437],{"class":129},[81,3238,3239],{"class":125},"cancel_at_period_end) ",[81,3241,130],{"class":129},[81,3243,3244,3247,3249,3251,3254,3256],{"class":83,"line":292},[81,3245,3246],{"class":125},"    subscriptionStatus ",[81,3248,333],{"class":129},[81,3250,604],{"class":129},[81,3252,3253],{"class":91},"canceling",[81,3255,145],{"class":129},[81,3257,301],{"class":125},[81,3259,3260],{"class":83,"line":1408},[81,3261,1275],{"class":129},[81,3263,3264],{"class":83,"line":1413},[81,3265,163],{"emptyLinePlaceholder":162},[81,3267,3268,3271],{"class":83,"line":1478},[81,3269,3270],{"class":114},"  await",[81,3272,3273],{"class":125}," db\n",[81,3275,3276,3279,3282],{"class":83,"line":1807},[81,3277,3278],{"class":129},"    .",[81,3280,3281],{"class":121},"update",[81,3283,3284],{"class":125},"(projects)\n",[81,3286,3287,3289,3292,3294],{"class":83,"line":1848},[81,3288,3278],{"class":129},[81,3290,3291],{"class":121},"set",[81,3293,126],{"class":125},[81,3295,130],{"class":129},[81,3297,3298,3300,3302,3305,3308,3310,3312,3315,3317,3319],{"class":83,"line":1858},[81,3299,2882],{"class":136},[81,3301,102],{"class":129},[81,3303,3304],{"class":125}," (newPlanName ",[81,3306,3307],{"class":129},"||",[81,3309,1294],{"class":125},[81,3311,437],{"class":129},[81,3313,3314],{"class":125},"planName) ",[81,3316,2733],{"class":114},[81,3318,2792],{"class":87},[81,3320,156],{"class":129},[81,3322,3323,3325,3327,3329,3331,3334,3337,3340,3343,3346,3348,3350,3353,3356,3358,3360],{"class":83,"line":1874},[81,3324,2943],{"class":136},[81,3326,102],{"class":129},[81,3328,1012],{"class":125},[81,3330,437],{"class":129},[81,3332,3333],{"class":125},"cancel_at ",[81,3335,3336],{"class":129},"?",[81,3338,3339],{"class":129}," new",[81,3341,3342],{"class":121}," Date",[81,3344,3345],{"class":125},"(subscription",[81,3347,437],{"class":129},[81,3349,3333],{"class":125},[81,3351,3352],{"class":129},"*",[81,3354,3355],{"class":894}," 1000",[81,3357,1229],{"class":125},[81,3359,102],{"class":129},[81,3361,2948],{"class":129},[81,3363,3364,3366],{"class":83,"line":1879},[81,3365,2889],{"class":125},[81,3367,156],{"class":129},[81,3369,3370,3372],{"class":83,"line":1884},[81,3371,2953],{"class":129},[81,3373,2956],{"class":125},[81,3375,3376,3378,3381,3383,3385,3387,3389,3391,3393,3395,3397],{"class":83,"line":1918},[81,3377,3278],{"class":129},[81,3379,3380],{"class":121},"where",[81,3382,126],{"class":125},[81,3384,2964],{"class":121},[81,3386,3096],{"class":125},[81,3388,437],{"class":129},[81,3390,902],{"class":125},[81,3392,198],{"class":129},[81,3394,1294],{"class":125},[81,3396,437],{"class":129},[81,3398,3399],{"class":125},"id));\n",[81,3401,3402],{"class":83,"line":1923},[81,3403,163],{"emptyLinePlaceholder":162},[81,3405,3406],{"class":83,"line":2628},[81,3407,2987],{"class":125},[81,3409,3410],{"class":83,"line":2644},[81,3411,985],{"class":129},[18,3413,1946,3414,3417,3418,3420,3421,3424,3425,3427],{},[78,3415,3416],{},"cancel_at_period_end"," branch is the one people get wrong. When a customer cancels, Stripe doesn't end the subscription immediately, it marks it to end at the period boundary. The status stays ",[78,3419,550],{},". If you only read ",[78,3422,3423],{},"subscription.status",", your app shows \"active\" and the customer keeps full access, which is correct, but you have no idea they're leaving and no way to show them \"your plan ends on the 12th.\" A separate ",[78,3426,3253],{}," status gives you both.",[18,3429,3430,3431,3434,3435,3438],{},"Also note the timestamp conversion. Stripe sends Unix seconds, JavaScript wants milliseconds, so ",[78,3432,3433],{},"new Date(subscription.cancel_at * 1000)",". Forget the ",[78,3436,3437],{},"* 1000"," and your customer's subscription ends in January 1971.",[18,3440,3441,3446],{},[2657,3442,3443],{},[78,3444,3445],{},"customer.subscription.deleted"," fires when the subscription actually ends. Downgrade to free:",[71,3448,3450],{"className":105,"code":3449,"language":107,"meta":76,"style":76},"case 'customer.subscription.deleted': {\n  const subscription = stripeEvent.data.object as Stripe.Subscription;\n\n  const project = await db.query.projects.findFirst({\n    where: eq(projects.stripeSubscriptionId, subscription.id),\n  });\n\n  if (project) {\n    await db\n      .update(projects)\n      .set({\n        planName: 'free',\n        subscriptionStatus: 'active',\n        stripeSubscriptionId: null,\n        stripeCurrentPeriodEnd: null,\n      })\n      .where(eq(projects.id, project.id));\n  }\n\n  break;\n}\n",[78,3451,3452,3466,3492,3496,3522,3546,3552,3556,3568,3574,3587,3597,3613,3628,3637,3646,3653,3684,3688,3692,3696],{"__ignoreMap":76},[81,3453,3454,3456,3458,3460,3462,3464],{"class":83,"line":84},[81,3455,2701],{"class":114},[81,3457,604],{"class":129},[81,3459,3445],{"class":91},[81,3461,145],{"class":129},[81,3463,2710],{"class":125},[81,3465,130],{"class":129},[81,3467,3468,3470,3472,3474,3476,3478,3480,3482,3484,3486,3488,3490],{"class":83,"line":133},[81,3469,3027],{"class":125},[81,3471,333],{"class":129},[81,3473,2457],{"class":125},[81,3475,437],{"class":129},[81,3477,885],{"class":125},[81,3479,437],{"class":129},[81,3481,2730],{"class":125},[81,3483,2733],{"class":114},[81,3485,2462],{"class":87},[81,3487,437],{"class":129},[81,3489,3048],{"class":87},[81,3491,301],{"class":125},[81,3493,3494],{"class":83,"line":159},[81,3495,163],{"emptyLinePlaceholder":162},[81,3497,3498,3500,3502,3504,3506,3508,3510,3512,3514,3516,3518,3520],{"class":83,"line":166},[81,3499,3059],{"class":125},[81,3501,333],{"class":129},[81,3503,770],{"class":114},[81,3505,1133],{"class":125},[81,3507,437],{"class":129},[81,3509,3070],{"class":125},[81,3511,437],{"class":129},[81,3513,412],{"class":125},[81,3515,437],{"class":129},[81,3517,3079],{"class":121},[81,3519,126],{"class":125},[81,3521,130],{"class":129},[81,3523,3524,3526,3528,3530,3532,3534,3536,3538,3540,3542,3544],{"class":83,"line":177},[81,3525,3088],{"class":136},[81,3527,102],{"class":129},[81,3529,3093],{"class":121},[81,3531,3096],{"class":125},[81,3533,437],{"class":129},[81,3535,720],{"class":125},[81,3537,198],{"class":129},[81,3539,1012],{"class":125},[81,3541,437],{"class":129},[81,3543,3109],{"class":125},[81,3545,156],{"class":129},[81,3547,3548,3550],{"class":83,"line":187},[81,3549,976],{"class":129},[81,3551,2978],{"class":125},[81,3553,3554],{"class":83,"line":205},[81,3555,163],{"emptyLinePlaceholder":162},[81,3557,3558,3560,3562,3564,3566],{"class":83,"line":211},[81,3559,1219],{"class":136},[81,3561,916],{"class":129},[81,3563,3132],{"class":628},[81,3565,298],{"class":129},[81,3567,174],{"class":129},[81,3569,3570,3572],{"class":83,"line":221},[81,3571,2101],{"class":114},[81,3573,3273],{"class":125},[81,3575,3576,3579,3581,3583,3585],{"class":83,"line":236},[81,3577,3578],{"class":129},"      .",[81,3580,3281],{"class":121},[81,3582,126],{"class":136},[81,3584,412],{"class":125},[81,3586,2956],{"class":136},[81,3588,3589,3591,3593,3595],{"class":83,"line":246},[81,3590,3578],{"class":129},[81,3592,3291],{"class":121},[81,3594,126],{"class":136},[81,3596,130],{"class":129},[81,3598,3599,3602,3604,3606,3609,3611],{"class":83,"line":261},[81,3600,3601],{"class":136},"        planName",[81,3603,102],{"class":129},[81,3605,604],{"class":129},[81,3607,3608],{"class":91},"free",[81,3610,145],{"class":129},[81,3612,156],{"class":129},[81,3614,3615,3618,3620,3622,3624,3626],{"class":83,"line":275},[81,3616,3617],{"class":136},"        subscriptionStatus",[81,3619,102],{"class":129},[81,3621,604],{"class":129},[81,3623,550],{"class":91},[81,3625,145],{"class":129},[81,3627,156],{"class":129},[81,3629,3630,3633,3635],{"class":83,"line":281},[81,3631,3632],{"class":136},"        stripeSubscriptionId",[81,3634,102],{"class":129},[81,3636,2948],{"class":129},[81,3638,3639,3642,3644],{"class":83,"line":286},[81,3640,3641],{"class":136},"        stripeCurrentPeriodEnd",[81,3643,102],{"class":129},[81,3645,2948],{"class":129},[81,3647,3648,3651],{"class":83,"line":292},[81,3649,3650],{"class":129},"      }",[81,3652,2956],{"class":136},[81,3654,3655,3657,3659,3661,3663,3665,3667,3669,3671,3673,3675,3677,3679,3682],{"class":83,"line":1408},[81,3656,3578],{"class":129},[81,3658,3380],{"class":121},[81,3660,126],{"class":136},[81,3662,2964],{"class":121},[81,3664,126],{"class":136},[81,3666,412],{"class":125},[81,3668,437],{"class":129},[81,3670,902],{"class":125},[81,3672,198],{"class":129},[81,3674,1294],{"class":125},[81,3676,437],{"class":129},[81,3678,902],{"class":125},[81,3680,3681],{"class":136},"))",[81,3683,301],{"class":129},[81,3685,3686],{"class":83,"line":1413},[81,3687,1275],{"class":129},[81,3689,3690],{"class":83,"line":1478},[81,3691,163],{"emptyLinePlaceholder":162},[81,3693,3694],{"class":83,"line":1807},[81,3695,2987],{"class":125},[81,3697,3698],{"class":83,"line":1848},[81,3699,985],{"class":129},[18,3701,3702,3703,3705],{},"Keep ",[78,3704,376],{}," here. Clearing it means a returning customer gets a fresh Stripe customer and loses their invoice history.",[18,3707,3708,3713],{},[2657,3709,3710],{},[78,3711,3712],{},"customer.subscription.trial_will_end"," fires 3 days before a trial ends. Send an email. This is the cheapest conversion win in the whole flow, and it's about 10 lines of code.",[18,3715,3716,3717,3719],{},"Everything else goes to a ",[78,3718,543],{}," branch that logs and returns 200. Returning a non-200 tells Stripe to retry, and retrying an event you don't handle just fills your logs.",[34,3721,3723],{"id":3722},"customer-portal-instead-of-building-billing-ui","Customer portal instead of building billing UI",[18,3725,3726],{},"Don't build plan-change forms, payment method updates, or invoice downloads. Stripe's hosted portal does all of it:",[71,3728,3730],{"className":105,"code":3729,"language":107,"meta":76,"style":76},"const portalSession = await stripe.billingPortal.sessions.create({\n  customer: project.stripeCustomerId,\n  return_url: `${config.public.siteUrl}\u002Fdashboard\u002Fbilling`,\n});\n\nreturn { url: portalSession.url };\n",[78,3731,3732,3762,3777,3804,3812,3816],{"__ignoreMap":76},[81,3733,3734,3736,3739,3741,3743,3745,3747,3750,3752,3754,3756,3758,3760],{"class":83,"line":84},[81,3735,762],{"class":392},[81,3737,3738],{"class":125}," portalSession ",[81,3740,333],{"class":129},[81,3742,770],{"class":114},[81,3744,796],{"class":125},[81,3746,437],{"class":129},[81,3748,3749],{"class":125},"billingPortal",[81,3751,437],{"class":129},[81,3753,1905],{"class":125},[81,3755,437],{"class":129},[81,3757,1910],{"class":121},[81,3759,126],{"class":125},[81,3761,130],{"class":129},[81,3763,3764,3767,3769,3771,3773,3775],{"class":83,"line":133},[81,3765,3766],{"class":136},"  customer",[81,3768,102],{"class":129},[81,3770,1294],{"class":125},[81,3772,437],{"class":129},[81,3774,376],{"class":125},[81,3776,156],{"class":129},[81,3778,3779,3782,3784,3786,3788,3790,3792,3794,3796,3798,3800,3802],{"class":83,"line":159},[81,3780,3781],{"class":136},"  return_url",[81,3783,102],{"class":129},[81,3785,1634],{"class":129},[81,3787,1637],{"class":125},[81,3789,437],{"class":129},[81,3791,1642],{"class":125},[81,3793,437],{"class":129},[81,3795,1647],{"class":125},[81,3797,295],{"class":129},[81,3799,1680],{"class":91},[81,3801,832],{"class":129},[81,3803,156],{"class":129},[81,3805,3806,3808,3810],{"class":83,"line":166},[81,3807,295],{"class":129},[81,3809,298],{"class":125},[81,3811,301],{"class":129},[81,3813,3814],{"class":83,"line":177},[81,3815,163],{"emptyLinePlaceholder":162},[81,3817,3818,3820,3822,3824,3826,3829,3831,3833],{"class":83,"line":187},[81,3819,1926],{"class":114},[81,3821,1286],{"class":129},[81,3823,1931],{"class":136},[81,3825,102],{"class":129},[81,3827,3828],{"class":125}," portalSession",[81,3830,437],{"class":129},[81,3832,1941],{"class":125},[81,3834,1766],{"class":129},[18,3836,3837,3838,3840],{},"That's the whole feature. Configure which plans are switchable and whether cancellation is allowed in the Stripe dashboard, under Settings, Billing, Customer portal. Changes users make there come back to you as ",[78,3839,2998],{}," webhooks, which you already handle.",[18,3842,3843],{},"Weeks of UI work replaced by a redirect. It's the best trade in the entire Stripe API.",[34,3845,3847],{"id":3846},"enforce-limits-on-the-server","Enforce limits on the server",[18,3849,3850,3851,3854],{},"Hiding an upgrade-gated button in the UI is a UX decision, not a security boundary. Anyone can ",[78,3852,3853],{},"curl"," your API.",[18,3856,3857],{},"Keep the limits in one shared constants file so client and server read the same numbers:",[71,3859,3861],{"className":105,"code":3860,"language":107,"meta":76,"style":76},"export const PLANS_CONSTANTS: Record\u003CPlanName, PlanConfig> = {\n  free: {\n    MAX_FILE_SIZE: 0.1 * 1024 * 1024,\n    MAX_NB_OF_FILES: 5,\n    MAX_NB_OF_AI_REPLIES: 200,\n    SUPPORT_ESCALATION: false,\n  },\n  pro: {\n    MAX_FILE_SIZE: 2 * 1024 * 1024,\n    MAX_NB_OF_FILES: 50,\n    MAX_NB_OF_AI_REPLIES: 5000,\n    SUPPORT_ESCALATION: true,\n  },\n};\n",[78,3862,3863,3892,3901,3923,3935,3947,3959,3963,3972,3991,4002,4013,4023,4027],{"__ignoreMap":76},[81,3864,3865,3867,3869,3872,3874,3876,3878,3881,3883,3886,3888,3890],{"class":83,"line":84},[81,3866,115],{"class":114},[81,3868,393],{"class":392},[81,3870,3871],{"class":125}," PLANS_CONSTANTS",[81,3873,102],{"class":129},[81,3875,1539],{"class":87},[81,3877,1542],{"class":129},[81,3879,3880],{"class":87},"PlanName",[81,3882,198],{"class":129},[81,3884,3885],{"class":87}," PlanConfig",[81,3887,1553],{"class":129},[81,3889,1113],{"class":129},[81,3891,174],{"class":129},[81,3893,3894,3897,3899],{"class":83,"line":133},[81,3895,3896],{"class":136},"  free",[81,3898,102],{"class":129},[81,3900,174],{"class":129},[81,3902,3903,3906,3908,3911,3914,3917,3919,3921],{"class":83,"line":159},[81,3904,3905],{"class":136},"    MAX_FILE_SIZE",[81,3907,102],{"class":129},[81,3909,3910],{"class":894}," 0.1",[81,3912,3913],{"class":129}," *",[81,3915,3916],{"class":894}," 1024",[81,3918,3913],{"class":129},[81,3920,3916],{"class":894},[81,3922,156],{"class":129},[81,3924,3925,3928,3930,3933],{"class":83,"line":166},[81,3926,3927],{"class":136},"    MAX_NB_OF_FILES",[81,3929,102],{"class":129},[81,3931,3932],{"class":894}," 5",[81,3934,156],{"class":129},[81,3936,3937,3940,3942,3945],{"class":83,"line":177},[81,3938,3939],{"class":136},"    MAX_NB_OF_AI_REPLIES",[81,3941,102],{"class":129},[81,3943,3944],{"class":894}," 200",[81,3946,156],{"class":129},[81,3948,3949,3952,3954,3957],{"class":83,"line":187},[81,3950,3951],{"class":136},"    SUPPORT_ESCALATION",[81,3953,102],{"class":129},[81,3955,3956],{"class":269}," false",[81,3958,156],{"class":129},[81,3960,3961],{"class":83,"line":205},[81,3962,289],{"class":129},[81,3964,3965,3968,3970],{"class":83,"line":211},[81,3966,3967],{"class":136},"  pro",[81,3969,102],{"class":129},[81,3971,174],{"class":129},[81,3973,3974,3976,3978,3981,3983,3985,3987,3989],{"class":83,"line":221},[81,3975,3905],{"class":136},[81,3977,102],{"class":129},[81,3979,3980],{"class":894}," 2",[81,3982,3913],{"class":129},[81,3984,3916],{"class":894},[81,3986,3913],{"class":129},[81,3988,3916],{"class":894},[81,3990,156],{"class":129},[81,3992,3993,3995,3997,4000],{"class":83,"line":236},[81,3994,3927],{"class":136},[81,3996,102],{"class":129},[81,3998,3999],{"class":894}," 50",[81,4001,156],{"class":129},[81,4003,4004,4006,4008,4011],{"class":83,"line":246},[81,4005,3939],{"class":136},[81,4007,102],{"class":129},[81,4009,4010],{"class":894}," 5000",[81,4012,156],{"class":129},[81,4014,4015,4017,4019,4021],{"class":83,"line":261},[81,4016,3951],{"class":136},[81,4018,102],{"class":129},[81,4020,270],{"class":269},[81,4022,156],{"class":129},[81,4024,4025],{"class":83,"line":275},[81,4026,289],{"class":129},[81,4028,4029],{"class":83,"line":281},[81,4030,1766],{"class":129},[18,4032,4033],{},"Then check it inside the endpoint that does the expensive thing:",[71,4035,4037],{"className":105,"code":4036,"language":107,"meta":76,"style":76},"export function validateFileUploadLimit(project: Project) {\n  const maxFiles = PLANS_CONSTANTS[project.planName].MAX_NB_OF_FILES;\n  const currentCount = (project.configuration as ProjectConfiguration)?.fileCount || 0;\n\n  if (currentCount >= maxFiles) {\n    throw createError({\n      statusCode: 400,\n      statusMessage: `File limit reached. You can upload a maximum of ${maxFiles} file(s). Please remove some files or upgrade your plan.`,\n    });\n  }\n\n  return { currentCount, maxFiles };\n}\n",[78,4038,4039,4061,4089,4127,4131,4149,4159,4170,4196,4204,4208,4212,4226],{"__ignoreMap":76},[81,4040,4041,4043,4045,4048,4050,4052,4054,4057,4059],{"class":83,"line":84},[81,4042,115],{"class":114},[81,4044,1987],{"class":392},[81,4046,4047],{"class":121}," validateFileUploadLimit",[81,4049,126],{"class":129},[81,4051,3132],{"class":628},[81,4053,102],{"class":129},[81,4055,4056],{"class":87}," Project",[81,4058,298],{"class":129},[81,4060,174],{"class":129},[81,4062,4063,4065,4068,4070,4072,4074,4076,4078,4080,4082,4084,4087],{"class":83,"line":133},[81,4064,1107],{"class":392},[81,4066,4067],{"class":125}," maxFiles",[81,4069,1113],{"class":129},[81,4071,3871],{"class":125},[81,4073,891],{"class":136},[81,4075,3132],{"class":125},[81,4077,437],{"class":129},[81,4079,712],{"class":125},[81,4081,153],{"class":136},[81,4083,437],{"class":129},[81,4085,4086],{"class":125},"MAX_NB_OF_FILES",[81,4088,301],{"class":129},[81,4090,4091,4093,4096,4098,4100,4102,4104,4107,4109,4112,4114,4116,4119,4122,4125],{"class":83,"line":159},[81,4092,1107],{"class":392},[81,4094,4095],{"class":125}," currentCount",[81,4097,1113],{"class":129},[81,4099,916],{"class":136},[81,4101,3132],{"class":125},[81,4103,437],{"class":129},[81,4105,4106],{"class":125},"configuration",[81,4108,2915],{"class":114},[81,4110,4111],{"class":87}," ProjectConfiguration",[81,4113,298],{"class":136},[81,4115,888],{"class":129},[81,4117,4118],{"class":125},"fileCount",[81,4120,4121],{"class":129}," ||",[81,4123,4124],{"class":894}," 0",[81,4126,301],{"class":129},[81,4128,4129],{"class":83,"line":166},[81,4130,163],{"emptyLinePlaceholder":162},[81,4132,4133,4135,4137,4140,4143,4145,4147],{"class":83,"line":177},[81,4134,1219],{"class":114},[81,4136,916],{"class":136},[81,4138,4139],{"class":125},"currentCount",[81,4141,4142],{"class":129}," >=",[81,4144,4067],{"class":125},[81,4146,1229],{"class":136},[81,4148,130],{"class":129},[81,4150,4151,4153,4155,4157],{"class":83,"line":187},[81,4152,1236],{"class":114},[81,4154,932],{"class":121},[81,4156,126],{"class":136},[81,4158,130],{"class":129},[81,4160,4161,4164,4166,4168],{"class":83,"line":205},[81,4162,4163],{"class":136},"      statusCode",[81,4165,102],{"class":129},[81,4167,2341],{"class":894},[81,4169,156],{"class":129},[81,4171,4172,4175,4177,4179,4182,4184,4187,4189,4192,4194],{"class":83,"line":211},[81,4173,4174],{"class":136},"      statusMessage",[81,4176,102],{"class":129},[81,4178,958],{"class":129},[81,4180,4181],{"class":91},"File limit reached. You can upload a maximum of ",[81,4183,964],{"class":129},[81,4185,4186],{"class":125},"maxFiles",[81,4188,295],{"class":129},[81,4190,4191],{"class":91}," file(s). Please remove some files or upgrade your plan.",[81,4193,832],{"class":129},[81,4195,156],{"class":129},[81,4197,4198,4200,4202],{"class":83,"line":221},[81,4199,2953],{"class":129},[81,4201,298],{"class":136},[81,4203,301],{"class":129},[81,4205,4206],{"class":83,"line":236},[81,4207,1275],{"class":129},[81,4209,4210],{"class":83,"line":246},[81,4211,163],{"emptyLinePlaceholder":162},[81,4213,4214,4216,4218,4220,4222,4224],{"class":83,"line":261},[81,4215,1416],{"class":114},[81,4217,1286],{"class":129},[81,4219,4095],{"class":125},[81,4221,198],{"class":129},[81,4223,4067],{"class":125},[81,4225,1845],{"class":129},[81,4227,4228],{"class":83,"line":275},[81,4229,985],{"class":129},[18,4231,4232,4233,4237],{},"For AI features this is also cost control. An unmetered chat endpoint on a free plan is someone else's OpenAI bill, and that lesson gets expensive fast. The ",[28,4234,4236],{"href":4235},"\u002Fblog\u002Fhow-to-build-ai-chatbot-with-rag-nuxt-4","RAG chatbot guide"," goes deeper on the AI side of the same stack.",[34,4239,4241],{"id":4240},"testing-locally","Testing locally",[18,4243,4244],{},"The Stripe CLI forwards real events to localhost:",[71,4246,4248],{"className":73,"code":4247,"language":75,"meta":76,"style":76},"stripe listen --forward-to localhost:3000\u002Fapi\u002Fwebhooks\u002Fstripe\n",[78,4249,4250],{"__ignoreMap":76},[81,4251,4252,4254,4257,4260],{"class":83,"line":84},[81,4253,2203],{"class":87},[81,4255,4256],{"class":91}," listen",[81,4258,4259],{"class":91}," --forward-to",[81,4261,4262],{"class":91}," localhost:3000\u002Fapi\u002Fwebhooks\u002Fstripe\n",[18,4264,4265,4266,4269,4270,437],{},"It prints a ",[78,4267,4268],{},"whsec_..."," secret that's different from your dashboard one. Use the printed one in your local ",[78,4271,320],{},[18,4273,4274],{},"Then trigger events without going through checkout:",[71,4276,4278],{"className":73,"code":4277,"language":75,"meta":76,"style":76},"stripe trigger checkout.session.completed\nstripe trigger customer.subscription.updated\nstripe trigger customer.subscription.deleted\n",[78,4279,4280,4290,4299],{"__ignoreMap":76},[81,4281,4282,4284,4287],{"class":83,"line":84},[81,4283,2203],{"class":87},[81,4285,4286],{"class":91}," trigger",[81,4288,4289],{"class":91}," checkout.session.completed\n",[81,4291,4292,4294,4296],{"class":83,"line":133},[81,4293,2203],{"class":87},[81,4295,4286],{"class":91},[81,4297,4298],{"class":91}," customer.subscription.updated\n",[81,4300,4301,4303,4305],{"class":83,"line":159},[81,4302,2203],{"class":87},[81,4304,4286],{"class":91},[81,4306,4307],{"class":91}," customer.subscription.deleted\n",[18,4309,4310,4311,4314],{},"Triggered events have empty metadata, so your handler should hit the \"missing projectId\" branch and log instead of crashing. That's a useful test on its own. For a realistic run, use test card ",[78,4312,4313],{},"4242 4242 4242 4242"," through your real checkout flow, then cancel from the portal and watch the events land.",[18,4316,4317,4318,4321,4322,4324],{},"Card ",[78,4319,4320],{},"4000 0000 0000 0341"," succeeds at checkout and then fails on the next charge, which is how you test ",[78,4323,3002],{}," handling before a real customer finds it for you.",[34,4326,4328],{"id":4327},"production-gotchas","Production gotchas",[18,4330,4331,4334,4335,4338,4339,4341],{},[2657,4332,4333],{},"Register the webhook endpoint and subscribe to the right events."," Stripe dashboard, Developers, Webhooks, add ",[78,4336,4337],{},"https:\u002F\u002Fyourdomain.com\u002Fapi\u002Fwebhooks\u002Fstripe",", then pick the four event types. Copy that endpoint's signing secret into ",[78,4340,351],{},". It's different per endpoint, so test mode and live mode each need their own.",[18,4343,4344,4354],{},[2657,4345,4346,4347,4350,4351,437],{},"On Cloudflare Workers, set ",[78,4348,4349],{},"keep_vars: true"," in ",[78,4352,4353],{},"wrangler.jsonc"," Without it, a deploy can wipe variables you set through the dashboard, and the first symptom is webhook signature verification failing on every event while checkout still works fine.",[18,4356,4357,4360],{},[2657,4358,4359],{},"Webhooks can arrive before your redirect completes, or after."," Don't write logic that assumes an order. The dashboard should read the plan from your database and show a neutral state if the webhook hasn't landed yet.",[18,4362,4363,4366,4367,4369,4370,4373],{},[2657,4364,4365],{},"Stripe retries failed webhooks for up to 3 days."," Your handler will see the same event more than once. The updates above are idempotent (setting ",[78,4368,712],{}," to ",[78,4371,4372],{},"pro"," twice is harmless), but the moment you add \"send a welcome email\" you need to guard it, or a retry storm sends the same email six times.",[18,4375,4376,4379],{},[2657,4377,4378],{},"Test mode and live mode are separate universes."," Separate customers, prices, webhook secrets, lookup keys. Create your prices in both, with the same lookup keys, or your first live checkout 500s on a missing price.",[34,4381,4383],{"id":4382},"when-stripe-isnt-the-right-pick","When Stripe isn't the right pick",[18,4385,4386,4387,4390],{},"Being fair about it: Stripe makes you the merchant of record, which means sales tax and VAT are your problem. ",[78,4388,4389],{},"automatic_tax"," calculates the right amount, but you still register, file, and remit in every jurisdiction where you have customers. For a solo founder selling to the EU, that's a real ongoing cost in money and attention.",[18,4392,4393,4394,4398],{},"Merchant-of-record providers like Polar, Paddle, or Lemon Squeezy take a bigger cut and handle all of it. The ",[28,4395,4397],{"href":4396},"\u002Fblog\u002Fstripe-vs-polar-which-to-choose","Stripe vs Polar comparison"," has the full breakdown, including the actual fee math.",[18,4400,4401,4402,1954,4405,4408],{},"The good news is that the architecture above barely changes. Put ",[78,4403,4404],{},"createCheckout",[78,4406,4407],{},"createPortalSession"," behind an interface, pick the implementation from an env var, and the only provider-specific code left is the webhook handler, because each provider signs payloads differently.",[34,4410,4412],{"id":4411},"wrapping-up","Wrapping up",[18,4414,4415],{},"The pattern that makes subscriptions manageable: metadata carries your database IDs through checkout, webhooks are the source of truth for plan state, lookup keys keep price mapping out of your code, the hosted portal handles all self-serve billing, and limits are enforced in the endpoint that costs you money.",[18,4417,4418],{},"Get those five right and the rest is dashboard configuration.",[18,4420,4421,4422,4424],{},"If you'd rather not build it, ",[28,4423,31],{"href":30}," ships with all of it working: Stripe and Polar behind one provider interface, the webhook handlers above, the customer portal, per-project subscriptions with roles, and plan limits wired into the AI and upload endpoints. $59 one-time, lifetime updates, unlimited projects.",[18,4426,4427,4428,4430,4431,4435,4436,4440],{},"Related reading: the ",[28,4429,734],{"href":733}," for the tenant modeling this billing sits on top of, the ",[28,4432,4434],{"href":4433},"\u002Fblog\u002Fembeddable-chat-widget-nuxt-4","embeddable widget tutorial"," for shipping a paid feature onto customer sites, and ",[28,4437,4439],{"href":4438},"\u002Fblog\u002Fbest-nuxt-boilerplates-compared","best Nuxt boilerplates compared"," if you're still evaluating options.",[4442,4443,4444],"style",{},"html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}",{"title":76,"searchDepth":133,"depth":133,"links":4446},[4447,4448,4449,4450,4451,4452,4453,4454,4455,4456,4457,4458,4459],{"id":36,"depth":133,"text":37},{"id":65,"depth":133,"text":66},{"id":369,"depth":133,"text":370},{"id":737,"depth":133,"text":738},{"id":1064,"depth":133,"text":1065},{"id":2146,"depth":133,"text":2147},{"id":2679,"depth":133,"text":2680},{"id":3722,"depth":133,"text":3723},{"id":3846,"depth":133,"text":3847},{"id":4240,"depth":133,"text":4241},{"id":4327,"depth":133,"text":4328},{"id":4382,"depth":133,"text":4383},{"id":4411,"depth":133,"text":4412},"2026-09-05","A practical guide to wiring Stripe subscriptions into a Nuxt 4 app: checkout sessions, webhook signature verification on the edge, plan upgrades, cancellations, and enforcing limits server-side.","md",{"src":4464},"\u002Fimages\u002Fhomepage\u002Fdemo.png",{},"\u002Fblog\u002Fstripe-subscriptions-nuxt-4",{"title":4468,"description":4469},"Stripe Subscriptions in Nuxt 4: Webhooks, Checkout & Portal (2026)","How to add Stripe subscriptions to Nuxt 4: price lookup keys, checkout sessions, webhook signature verification on Cloudflare Workers, the customer portal, and plan limit enforcement.","blog\u002Fstripe-subscriptions-nuxt-4","hTvT12ql6tftRwLiUE4z8CKJS7yz85WLAGSS03NsNpw",[4473,4478],{"title":4474,"path":4475,"stem":4476,"description":4477,"children":-1},"NuxtBeyond Initial Release: Ship AI-Powered SaaS in Days, Not Months","\u002Fblog\u002Fnuxtbeyond-initial-release","blog\u002Fnuxtbeyond-initial-release","Introducing NuxtBeyond, the complete Nuxt 4 boilerplate for building AI-first SaaS products with authentication, payments, AI chat with RAG, and Cloudflare deployment - all pre-configured.",{"title":4479,"path":4396,"stem":4480,"description":4481,"children":-1},"Stripe vs Polar: Which Payment Provider Should You Choose for Your SaaS?","blog\u002Fstripe-vs-polar-which-to-choose","An honest, developer-focused comparison of Stripe and Polar for SaaS billing — merchant of record vs DIY tax, pricing, developer experience, and when each one is the right call.",1788591959694]