[{"data":1,"prerenderedAt":342},["ShallowReactive",2],{"blog-stripe-webhooks-source-of-truth":3,"related-stripe-webhooks-source-of-truth":92},{"id":4,"title":5,"author":6,"body":7,"category":6,"date":76,"description":77,"draft":78,"extension":79,"image":6,"meta":80,"navigation":82,"path":83,"seo":84,"stem":85,"tags":86,"__hash__":91},"blog\u002Fblog\u002Fstripe-webhooks-source-of-truth.md","Your checkout success page should not touch your billing state",null,{"type":8,"value":9,"toc":72},"minimark",[10,14,22,28,31,34,37,44,54,60,66,69],[11,12,13],"p",{},"There is a bug almost every first SaaS ships, and it looks completely reasonable while you are writing it.",[11,15,16,17,21],{},"The user pays. Stripe redirects them to ",[18,19,20],"code",{},"\u002Fsuccess",". Your success page reads the session, sees that money changed hands, and flips a flag in your database to say this account is now a paying customer. Ship it. It works in testing. It works in the demo.",[11,23,24,25,27],{},"Then someone closes the tab on the Stripe checkout page half a second after paying, before the redirect fires. Or your success route throws because the database was busy. Or a curious user visits ",[18,26,20],{}," directly to see what happens. Now you have a customer who paid and has no access, or a freeloader who has access and never paid. Either way, the source of truth for who paid you is a page that only runs if the browser cooperates.",[11,29,30],{},"The browser does not work for you. Stripe does.",[11,32,33],{},"The fix is a rule worth writing on the wall: the only thing allowed to change billing state is the Stripe webhook. The success page says thank you and nothing else. It does not grant access, it does not read entitlements. It is a pretty dead end. Everything that matters happens server to server, where no tab can be closed and no user can wander in.",[11,35,36],{},"That rule sounds simple, then quietly demands four things from you.",[11,38,39,43],{},[40,41,42],"strong",{},"Verify the signature."," A webhook endpoint that trusts any POST is an open door to your billing logic. Stripe signs every event. Check the signature before you read a single field, and reject anything that fails. This is not the part to hand-wave.",[11,45,46,49,50,53],{},[40,47,48],{},"Be idempotent."," Stripe will send you the same event more than once. This is not a bug, it is the contract, because the network is unreliable and they would rather deliver twice than zero times. If your handler grants a month of access every time it sees ",[18,51,52],{},"checkout.session.completed",", a retry just gave someone two months. Claim each event once, by id, and make the second arrival a no-op.",[11,55,56,59],{},[40,57,58],{},"Handle the money going backwards."," Most billing code is written as if payments only ever happen. Then you issue your first full refund and discover the customer still has everything they paid for, because nothing in your system listens for access to be taken away. A refund should revoke access the same way a payment grants it: automatically, through the same webhook path.",[11,61,62,65],{},[40,63,64],{},"Enforce limits where the data lives."," This is the one people skip. If your plan limit is enforced in the UI, you do not have a limit, you have a suggestion. Hiding the upload button on the free plan stops nobody who can open the network tab. The check has to sit next to the data. In BoiledPlate that means the row-level security policy itself looks at the plan before it lets a row through, so the free tier is enforced by Postgres and not by a disabled button.",[11,67,68],{},"I wired all of this once, got it wrong in two of the ways above, fixed it, and then made it the default so I never have to think about it again. The webhook is signature-verified and idempotent out of the box. A refund revokes access on its own. The success page is a dead end on purpose. The limits live in the policies.",[11,70,71],{},"You can build all of this yourself. I did. I just think you have better things to spend a weekend on than relearning that the browser does not work for you.",{"title":73,"searchDepth":74,"depth":74,"links":75},"",3,[],"2026-05-28","The billing bug almost every first SaaS ships, why the browser is the wrong source of truth, and the four things a Stripe webhook actually owes you.",false,"md",{"slug":81},"stripe-webhooks-source-of-truth",true,"\u002Fblog\u002Fstripe-webhooks-source-of-truth",{"title":5,"description":77},"blog\u002Fstripe-webhooks-source-of-truth",[87,88,89,90],"stripe","billing","saas","postgres","PUIX4-0u1urTbslQCRXG2JeLSEfd8xzJqRiVhvMOFbs",[93,184,288],{"id":94,"title":95,"author":6,"body":96,"category":6,"date":171,"description":172,"draft":78,"extension":79,"image":6,"meta":173,"navigation":82,"path":175,"seo":176,"stem":177,"tags":178,"__hash__":183},"blog\u002Fblog\u002Fnuxt-supabase-stripe-saas-boilerplate.md","What it actually takes to wire up a Nuxt, Supabase and Stripe SaaS",{"type":8,"value":97,"toc":163},[98,101,104,107,112,115,118,122,125,133,137,140,143,147,150,154,157,160],[11,99,100],{},"If you are about to build a SaaS, the stack writes itself these days. Nuxt for the app, Supabase for the database and auth, Stripe for payments, Resend for email. It is a good stack. I have shipped on it more than once and I would pick it again tomorrow.",[11,102,103],{},"Then you open an empty project and remember that picking the stack was the easy decision. Wiring four services into something that does not leak data or lose payments is the part that takes the weeks.",[11,105,106],{},"Here is what each piece actually asks of you, roughly in the order it tends to bite.",[108,109,111],"h2",{"id":110},"supabase-auth-is-the-fast-part-rls-is-the-part-you-forget","Supabase: auth is the fast part, RLS is the part you forget",[11,113,114],{},"Getting a login form working with Supabase takes an afternoon. Email and password, maybe Google OAuth, a session, a redirect. Feels done.",[11,116,117],{},"It is not done. The moment you have more than one user, every table needs row-level security, and you need it from the first migration, not bolted on after launch when you are already holding real data. RLS is not optional polish. It is the only thing standing between user A and user B's rows. Get a policy wrong and the bug does not throw an error, it quietly serves the wrong data to the wrong person. You also want password recovery that does not reveal whether an email exists, route protection that lives in middleware instead of scattered redirects, and generated types so you stop guessing at column names.",[108,119,121],{"id":120},"stripe-the-webhook-is-the-product-the-checkout-is-the-easy-bit","Stripe: the webhook is the product, the checkout is the easy bit",[11,123,124],{},"Everyone wires the checkout first because it is satisfying. Money goes in, you get redirected, you feel like a founder.",[11,126,127,128,132],{},"The redirect is the least trustworthy thing in your whole system. The real billing state has to come from Stripe's webhooks, signature-verified and idempotent, because the browser will close the tab and the network will deliver the same event twice. Then there is the part most starters skip: a refund has to revoke access on its own, and plan limits have to be enforced in the database, not hidden in the UI. None of this is hard once. It is a lot of small correct decisions, and any one of them wrong is a billing bug in production. I wrote about ",[129,130,131],"a",{"href":83},"the webhook trap in more detail here"," if you want the longer version.",[108,134,136],{"id":135},"resend-simple-to-send-annoying-to-send-exactly-once","Resend: simple to send, annoying to send exactly once",[11,138,139],{},"Sending one email with Resend is three lines. The trouble starts when a Stripe retry fires a second payment-confirmation email, or a flaky request makes you send the welcome email twice. You want a single server-side utility, sends that are idempotent for anything a user can trigger, and every line of those emails living as a locale key so a German customer does not get an English receipt.",[11,141,142],{},"Which brings up the thing that quietly doubles the work on everything above.",[108,144,146],{"id":145},"nuxt-is-the-glue-and-i18n-is-the-tax-on-doing-it-right","Nuxt is the glue, and i18n is the tax on doing it right",[11,148,149],{},"Every string in all of this, the auth labels, the validation messages, the toasts, the Stripe error states, the email bodies, has to exist in every language you support. Tack i18n on at the end and you will spend a week hunting hardcoded strings. Build it in from the first component and it is just a habit. Shipping four languages on day one is a very different project from shipping one and promising the rest.",[108,151,153],{"id":152},"so-how-long-is-all-of-that","So how long is all of that",[11,155,156],{},"For me, the first time, weeks. Not because any single piece is clever, but because the correct version of each one hides about five non-obvious decisions, and you only learn which five by getting them wrong.",[11,158,159],{},"That is the entire reason BoiledPlate exists. It is a Nuxt 4 SaaS starter on exactly this stack, Supabase, Stripe and Resend, already wired the right way. RLS on every table from the first migration. The Stripe webhook as the source of truth, refund-aware, with limits in the policies. One idempotent email utility, localized. Four languages shipped, not promised. You point a coding agent at it, answer nine questions, and the thing reshapes itself to your product instead of you reshaping yourself to it.",[11,161,162],{},"You can absolutely wire this yourself. The stack is good and the docs are all there. I just think the second time you do it, you start wondering why you are paying the same tax twice.",{"title":73,"searchDepth":74,"depth":74,"links":164},[165,167,168,169,170],{"id":110,"depth":166,"text":111},2,{"id":120,"depth":166,"text":121},{"id":135,"depth":166,"text":136},{"id":145,"depth":166,"text":146},{"id":152,"depth":166,"text":153},"2026-06-09","Picking a Nuxt + Supabase + Stripe + Resend stack is the easy decision. Here is the wiring each piece really demands, and where a SaaS starter saves you weeks.",{"slug":174},"nuxt-supabase-stripe-saas-boilerplate","\u002Fblog\u002Fnuxt-supabase-stripe-saas-boilerplate",{"title":95,"description":172},"blog\u002Fnuxt-supabase-stripe-saas-boilerplate",[179,180,87,181,89,182],"nuxt","supabase","resend","boilerplate","O9aBZHsolIkxENJU_ebdFE9p0IW2dowSQQ-IXutuW_M",{"id":185,"title":186,"author":6,"body":187,"category":6,"date":171,"description":279,"draft":78,"extension":79,"image":6,"meta":280,"navigation":82,"path":282,"seo":283,"stem":284,"tags":285,"__hash__":287},"blog\u002Fblog\u002Fstripe-subscriptions-nuxt-supabase.md","How to set up Stripe subscriptions in a Nuxt + Supabase app",{"type":8,"value":188,"toc":271},[189,192,195,199,220,224,230,234,241,245,254,258,261,265,268],[11,190,191],{},"Subscriptions are the part of a SaaS where the demo and the reality diverge most. The happy path, where a user clicks subscribe, pays, and gets access, is maybe a fifth of the work. The other four fifths is everything that happens after, and Stripe plus Supabase have a clean division of labor for it if you set things up in the right order.",[11,193,194],{},"Here is the order that has worked for me on a Nuxt + Supabase app.",[108,196,198],{"id":197},"_1-define-your-plans-in-one-place","1. Define your plans in one place",[11,200,201,202,205,206,205,209,212,213,215,216,219],{},"Before any Stripe code, decide what your plans are and give each a slug your app understands, like ",[18,203,204],{},"free",", ",[18,207,208],{},"pro",[18,210,211],{},"team",". Keep the slugs in your codebase and keep the Stripe price IDs in an environment map next to them. The reason to separate the two: your app should reason about ",[18,214,208],{},", not about ",[18,217,218],{},"price_1Q2x...",", and you will have different price IDs in test and live mode anyway. One source of truth for the slugs, one env map for the IDs.",[108,221,223],{"id":222},"_2-checkout-is-a-redirect-nothing-more","2. Checkout is a redirect, nothing more",[11,225,226,227],{},"Create a Stripe Checkout session on the server, hand the URL to the browser, redirect. Do not record anything about the subscription at this point. The checkout session succeeding tells you the user started paying, not that they are entitled to anything yet. Resist the urge to flip a flag on the success page. ",[129,228,229],{"href":83},"That instinct is the single most common billing bug, and here is why.",[108,231,233],{"id":232},"_3-the-webhook-is-where-subscriptions-actually-live","3. The webhook is where subscriptions actually live",[11,235,236,237,240],{},"This is the heart of it. Stripe sends events for the whole lifecycle: subscription created, renewed, payment failed, cancelled, refunded. Your webhook handler, signature-verified and idempotent, is the only thing that writes subscription state to Supabase. When ",[18,238,239],{},"customer.subscription.updated"," says the plan changed, you update the row. When it says the payment is past due, you reflect that. The database is a mirror of what Stripe believes, never a guess your frontend made.",[108,242,244],{"id":243},"_4-turn-subscription-state-into-access","4. Turn subscription state into access",[11,246,247,248,250,251,253],{},"Now you have a row in Supabase that says this user is on ",[18,249,208],{}," and the subscription is active. Access control reads from that row. The important part, again, is where you enforce it: in your row-level security policies, so a ",[18,252,208],{},"-only table is genuinely unreachable for a free user, not just visually hidden. The UI can hide buttons for nice UX, but the policy is what makes the limit real.",[108,255,257],{"id":256},"_5-give-them-the-customer-portal","5. Give them the customer portal",[11,259,260],{},"You do not want to build cancellation, card updates, and invoice history yourself. Stripe's customer portal does all of it. Generate a portal session on the server, redirect the user, and let your webhook pick up whatever they change there. One less set of screens to build and keep correct.",[108,262,264],{"id":263},"the-shape-of-it","The shape of it",[11,266,267],{},"Plans in config. Checkout as a dumb redirect. Webhook as the only writer of state. RLS as the enforcer. Portal for self-service. Get those five right and subscriptions stop being scary, because every piece has exactly one job.",[11,269,270],{},"If you would rather not assemble all five by hand, this is the billing setup BoiledPlate ships by default on a Nuxt 4 + Supabase stack. Subscriptions, one-time, or none, chosen when the starter sets itself up, with the webhook, the RLS limits and the portal already wired. Think of it as the version of this post where someone already made the five decisions, so you just build your product on top.",{"title":73,"searchDepth":74,"depth":74,"links":272},[273,274,275,276,277,278],{"id":197,"depth":166,"text":198},{"id":222,"depth":166,"text":223},{"id":232,"depth":166,"text":233},{"id":243,"depth":166,"text":244},{"id":256,"depth":166,"text":257},{"id":263,"depth":166,"text":264},"The five decisions behind Stripe subscriptions on a Nuxt + Supabase stack: plans in config, checkout, webhooks, RLS-enforced access, and the customer portal.",{"slug":281},"stripe-subscriptions-nuxt-supabase","\u002Fblog\u002Fstripe-subscriptions-nuxt-supabase",{"title":186,"description":279},"blog\u002Fstripe-subscriptions-nuxt-supabase",[87,286,179,180,89],"subscriptions","WgV2n4beSVMVXw3OM_Wp6Q6RGGHUo73mCDuPwU0S7FM",{"id":289,"title":290,"author":6,"body":291,"category":6,"date":329,"description":330,"draft":78,"extension":79,"image":6,"meta":331,"navigation":82,"path":333,"seo":334,"stem":335,"tags":336,"__hash__":341},"blog\u002Fblog\u002Fconventions-for-ai-coding-agents.md","Write your conventions for the agent, not the next hire",{"type":8,"value":292,"toc":327},[293,296,299,302,305,308,315,318,321,324],[11,294,295],{},"We have all worked in a codebase with three ways to do the same thing. One the founder wrote in a hurry, one a contractor left behind, one that is just whatever the last person copied off the internet. You learn to read all three. It is annoying but survivable, because a human can hold a contradiction in their head and pick the right pattern by smell.",[11,297,298],{},"A coding agent cannot smell anything.",[11,300,301],{},"Give an agent a codebase with three ways to fetch data and it will cheerfully add a fourth. Not because it is dumb, but because it has no reason to prefer yours. It pattern-matches against everything it has ever seen, and everything it has ever seen includes every bad tutorial ever published. The drift is not a one-time mess you clean up later. It compounds. Every file the agent writes becomes context for the next file, so one sloppy week teaches the agent to keep being sloppy.",[11,303,304],{},"The usual answer to this is a long prompt. You paste your preferences at the start of every session and hope they survive. They do not. The model loses them, the session resets, you forget to paste them, and you are back to three patterns and counting.",[11,306,307],{},"The better answer is to write the rules down where the agent will find them on its own.",[11,309,310,311,314],{},"That is what ",[18,312,313],{},"AGENTS.md"," is. Not documentation for the next human hire, though it works for that too. It is a contract the agent reads before it works. One documented way to access data. One way to handle secrets. One way to gate an authenticated route. One way to add a migration, a locale string, a Stripe price. The agent does not have to guess your taste, because your taste is written down in a place it actually looks.",[11,316,317],{},"Writing it down is half the job. The other half is handing the agent the current facts instead of a stale memory of them. An agent that guesses your database columns will guess wrong, and confidently. So BoiledPlate ships read-only MCP servers that let the agent look at your real Supabase schema, the actual Nuxt UI component docs, and your Stripe test data while it builds. It is the difference between an assistant who read the manual once and one who has the manual open on the desk.",[11,319,320],{},"There is one more layer, and it is the one I am most attached to. Vendor skills. The Supabase team, the Stripe team, and the Nuxt UI team all have opinions about how their tools should be used, and those opinions live in skills the agent can consult. I vendor them into the repo, hash-locked, refreshed by a weekly CI pull request so they do not silently rot. On top of those I wrote one of my own for Nuxt 4 patterns, with every rule anchored to a real file in the repo so the agent can go read the example instead of trusting a description of it.",[11,322,323],{},"Put together, the agent building in BoiledPlate is not improvising your conventions from memory. It is reading them, checking them against your live schema, and following skills written by the people who made the tools. The output stays consistent in month three because consistency was designed in, not nagged in.",[11,325,326],{},"The old instinct says write clean code and document it for whoever comes next. That instinct is right. It is just aimed at the wrong reader now. The next contributor to your codebase is an agent, and it will follow your rules exactly as well as you wrote them down.",{"title":73,"searchDepth":74,"depth":74,"links":328},[],"2026-06-08","An agent cannot smell a codebase. Give it three ways to do one thing and it adds a fourth. Here is how BoiledPlate keeps agent output consistent past month three.",{"slug":332},"conventions-for-ai-coding-agents","\u002Fblog\u002Fconventions-for-ai-coding-agents",{"title":290,"description":330},"blog\u002Fconventions-for-ai-coding-agents",[337,338,339,340],"ai-agents","claude-code","conventions","developer-experience","NsQDMtlYDClnjq1hfj5qm_qUO25Y8pNl1Gi3j6Qd3rc",1780964587329]