<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Benjamin Touchard</title>
    <description>The latest articles on DEV Community by Benjamin Touchard (@benjy33000).</description>
    <link>https://dev.to/benjy33000</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1740181%2F3f1038da-05fb-410f-832b-0bd4c2f84fc9.png</url>
      <title>DEV Community: Benjamin Touchard</title>
      <link>https://dev.to/benjy33000</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/benjy33000"/>
    <language>en</language>
    <item>
      <title>21 questions to ask a SaaS boilerplate before you pay for it</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:09:25 +0000</pubDate>
      <link>https://dev.to/benjy33000/21-questions-to-ask-a-saas-boilerplate-before-you-pay-for-it-12im</link>
      <guid>https://dev.to/benjy33000/21-questions-to-ask-a-saas-boilerplate-before-you-pay-for-it-12im</guid>
      <description>&lt;p&gt;Feature lists are marketing. These are the twenty-one questions that separate a&lt;br&gt;
boilerplate you can build a business on from a demo with a login screen. They&lt;br&gt;
work on any kit — take them to whichever vendor you are evaluating, including&lt;br&gt;
this one.&lt;/p&gt;

&lt;p&gt;Each question is followed by GoVueKit's answer, so the comparison is concrete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. How are passwords hashed, with what parameters?&lt;/strong&gt;&lt;br&gt;
Bcrypt with default cost is acceptable; anything home-made is not. &lt;em&gt;GoVueKit:&lt;br&gt;
argon2id, 64 MiB memory, 3 iterations, per-password salt.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Do signup, login and password reset reveal whether an account exists?&lt;/strong&gt;&lt;br&gt;
Different messages or timings are account enumeration. &lt;em&gt;GoVueKit: neutral&lt;br&gt;
responses on all three, and a test that asserts it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Where do sessions live, and what happens on password change?&lt;/strong&gt;&lt;br&gt;
Stateless JWTs that cannot be revoked are a liability for a product with&lt;br&gt;
billing. &lt;em&gt;GoVueKit: server-side sessions in the database; a password change&lt;br&gt;
terminates every other session.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Is there two-factor authentication, and does it cover every sign-in&lt;br&gt;
path?&lt;/strong&gt; 2FA that guards the password form but not the magic link is theatre.&lt;br&gt;
&lt;em&gt;GoVueKit: TOTP with ten single-use recovery codes, enforced on password,&lt;br&gt;
magic link and OAuth sign-in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. What happens when an OAuth email matches an existing account?&lt;/strong&gt;&lt;br&gt;
Silent merging is an account-takeover vector. &lt;em&gt;GoVueKit: no implicit merge; the&lt;br&gt;
user must sign in and link the provider explicitly.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tenancy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;6. Where is the tenant boundary enforced?&lt;/strong&gt;&lt;br&gt;
"In the service layer" means "wherever someone remembered". &lt;em&gt;GoVueKit: one&lt;br&gt;
middleware resolves the organization against the caller's memberships, and&lt;br&gt;
every business query filters on &lt;code&gt;organization_id&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. What does a request for another tenant's resource return?&lt;/strong&gt;&lt;br&gt;
403 confirms the resource exists. &lt;em&gt;GoVueKit: 404, indistinguishable from a&lt;br&gt;
resource that never existed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Is cross-tenant isolation tested end to end?&lt;/strong&gt;&lt;br&gt;
Ask to see the test. &lt;em&gt;GoVueKit: a Playwright journey creates two&lt;br&gt;
organizations and asserts the second cannot read the first — plus integration&lt;br&gt;
tests per endpoint.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Can roles be bypassed from the frontend?&lt;/strong&gt;&lt;br&gt;
Every kit hides buttons; the question is what the server does. &lt;em&gt;GoVueKit: RBAC&lt;br&gt;
is server-side middleware, frontend checks are explicitly cosmetic.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. What protects the last owner of an organization?&lt;/strong&gt;&lt;br&gt;
Products lose ownership when the last owner leaves. &lt;em&gt;GoVueKit: the last owner&lt;br&gt;
cannot be demoted, removed, or leave without transferring ownership.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Billing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;11. Are webhooks idempotent, and how?&lt;/strong&gt;&lt;br&gt;
"We check if we already processed it" is not enough if the check and the write&lt;br&gt;
are in different transactions. &lt;em&gt;GoVueKit: the event id is inserted with a&lt;br&gt;
primary key and the business write commits in the same transaction —&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/idempotent-billing-webhooks" rel="noopener noreferrer"&gt;the full pattern&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Is payload data trusted?&lt;/strong&gt;&lt;br&gt;
A valid signature proves the sender, not that the state is current. &lt;em&gt;GoVueKit:&lt;br&gt;
every object is re-fetched from the provider API by id; nothing beyond the id&lt;br&gt;
is trusted.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. Who owns the subscription — the user or the organization?&lt;/strong&gt;&lt;br&gt;
Per-user billing on a team product is a migration you will do later, painfully.&lt;br&gt;
&lt;em&gt;GoVueKit: the organization owns it, and solo mode makes it per-user when that&lt;br&gt;
is what you want.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;14. What happens when money leaves the account, and what would a second&lt;br&gt;
provider cost?&lt;/strong&gt; Most kits handle payment and stop there; refunds and&lt;br&gt;
chargebacks arrive anyway. &lt;em&gt;GoVueKit: Stripe only — &lt;code&gt;charge.refunded&lt;/code&gt; stamps&lt;br&gt;
the purchase rather than deleting it (the row reconciles with Stripe), and&lt;br&gt;
&lt;code&gt;charge.dispute.created&lt;/code&gt; is logged at Error level so it reaches your error&lt;br&gt;
reporter. Stripe sits behind a five-method &lt;code&gt;billing.Provider&lt;/code&gt; interface, so&lt;br&gt;
another provider is a package to write, not a rewrite — but it is an&lt;br&gt;
integration you would write, not a switch you flip.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15. Where do prices live?&lt;/strong&gt;&lt;br&gt;
An admin UI that can disagree with what the payment provider charges is a bug&lt;br&gt;
generator. &lt;em&gt;GoVueKit: plans are configuration, served to both the pricing page&lt;br&gt;
and the billing screen; the amount charged always comes from the provider.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Operations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;16. What must be running in production besides the app and the database?&lt;/strong&gt;&lt;br&gt;
Every additional service is an on-call surface. &lt;em&gt;GoVueKit: nothing. The job&lt;br&gt;
queue is a table, sessions are rows, files can be on disk.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;17. What happens to queued emails when you redeploy?&lt;/strong&gt;&lt;br&gt;
In-memory queues lose them. &lt;em&gt;GoVueKit: jobs are durable rows, claimed and&lt;br&gt;
retried after a restart.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;18. How are errors reported, and can you swap the reporter?&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;GoVueKit: every Error-level log record reaches a pluggable reporter;&lt;br&gt;
&lt;code&gt;SENTRY_DSN&lt;/code&gt; enables the bundled one, and the interface is one method.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;19. What does the deployment story look like, in commands?&lt;/strong&gt;&lt;br&gt;
If the answer is a platform button, ask what self-hosting takes. &lt;em&gt;GoVueKit:&lt;br&gt;
one binary, or &lt;code&gt;docker compose -f docker-compose.prod.yml up -d --build&lt;/code&gt; with&lt;br&gt;
Traefik and Let's Encrypt included.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The purchase itself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;20. What exactly does the licence allow, and forbid?&lt;/strong&gt;&lt;br&gt;
The clauses that matter: unlimited end products, client work, whether updates&lt;br&gt;
are perpetual, and the redistribution restriction. &lt;em&gt;GoVueKit: perpetual,&lt;br&gt;
unlimited products including client work, lifetime updates; you may not&lt;br&gt;
republish the source as a competing kit.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;21. How are updates delivered, and how do you merge them?&lt;/strong&gt;&lt;br&gt;
A zip file is a fork you maintain alone. &lt;em&gt;GoVueKit: a private repository you&lt;br&gt;
are invited to, with atomic conventional commits and a changelog — you pull,&lt;br&gt;
read the diff, merge.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bonus question
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can you read the code before buying?&lt;/strong&gt;&lt;br&gt;
Most kits publish something: a demo repository, documentation, code excerpts,&lt;br&gt;
a video walkthrough. A vendor that shows nothing is asking you to buy a&lt;br&gt;
promise. GoVueKit publishes excerpts on the &lt;a href="https://govuekit.dev/#code" rel="noopener noreferrer"&gt;landing page&lt;/a&gt; and its&lt;br&gt;
guides in the open at &lt;code&gt;docs.govuekit.dev&lt;/code&gt; — including the ten-minute tour that&lt;br&gt;
follows one request through every layer, from the chi route to the SQL and&lt;br&gt;
back. The repository adds &lt;code&gt;DECISIONS.md&lt;/code&gt;, which is the file to read second:&lt;br&gt;
every non-obvious call, with its reasoning, including the features that were&lt;br&gt;
built and then cut. And the site you are reading is served by the product&lt;br&gt;
itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions worth asking about what is &lt;em&gt;missing&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;A serious kit says no to things. Ask what a vendor deliberately left out and&lt;br&gt;
why; "everything is included" is the answer of a product nobody has had to&lt;br&gt;
maintain. &lt;em&gt;GoVueKit: no code generator, no example business module, no second&lt;br&gt;
payment provider, no passkeys, no feature flags — several of those existed and&lt;br&gt;
were removed, and &lt;code&gt;DECISIONS.md&lt;/code&gt; explains each cut. If your evaluation is&lt;br&gt;
scored on feature count, that is a column GoVueKit loses on purpose.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use this list
&lt;/h2&gt;

&lt;p&gt;Do not send all twenty-one questions to a vendor — you will get a marketing&lt;br&gt;
reply. Pick the five that map to your risk: if you sell to teams, ask 6, 7, 8;&lt;br&gt;
if you charge money, ask 11, 12, 14; if you have no ops team, ask 16, 17, 19.&lt;/p&gt;

&lt;p&gt;And ask for the test, not the answer. "Where is the test that proves that?" is&lt;br&gt;
the single most useful sentence in this entire process.&lt;/p&gt;

&lt;p&gt;Want the comparison by architecture rather than by feature? Read&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/saas-boilerplate-comparison-2026" rel="noopener noreferrer"&gt;choosing a SaaS boilerplate in 2026&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/saas-boilerplate-evaluation-checklist" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>startup</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Self-hosted SaaS boilerplate: what \"self-hosted\" has to mean, and a checklist</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Tue, 13 Oct 2026 07:11:19 +0000</pubDate>
      <link>https://dev.to/benjy33000/self-hosted-saas-boilerplate-what-self-hosted-has-to-mean-and-a-checklist-56bm</link>
      <guid>https://dev.to/benjy33000/self-hosted-saas-boilerplate-what-self-hosted-has-to-mean-and-a-checklist-56bm</guid>
      <description>&lt;p&gt;"Self-hosted" has become a checkbox on boilerplate landing pages, and the&lt;br&gt;
checkbox is usually true in the narrowest sense: yes, the Node process can&lt;br&gt;
run on your VPS. Then you read the &lt;code&gt;.env.example&lt;/code&gt; and find the auth&lt;br&gt;
provider's API key, the managed database URL, the queue service token, the&lt;br&gt;
email API key and the file-storage bucket. The process is yours; the&lt;br&gt;
product is theirs. This article proposes a definition strict enough to be&lt;br&gt;
useful, and turns it into questions you can ask any kit — including ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  A definition
&lt;/h2&gt;

&lt;p&gt;A SaaS foundation is self-hosted when &lt;strong&gt;the product keeps working, for all&lt;br&gt;
its users, with nothing but the servers you operate and the one payment&lt;br&gt;
processor you cannot avoid.&lt;/strong&gt; Everything else is optional or replaceable&lt;br&gt;
with something you run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sign-in works with your database alone. External identity providers are
a feature you add, not a dependency you start with.&lt;/li&gt;
&lt;li&gt;Email goes out through any SMTP server, including your own; a hosted
email API is a configuration, not a requirement.&lt;/li&gt;
&lt;li&gt;Background jobs run inside the application, backed by your database,
and survive a restart.&lt;/li&gt;
&lt;li&gt;Files go to a local directory or to any S3-compatible service you
choose, including one you run.&lt;/li&gt;
&lt;li&gt;Analytics, error reporting and monitoring are opt-in and can point at
self-hosted tools.&lt;/li&gt;
&lt;li&gt;The payment processor is the exception, and its failure mode is
bounded: with no key configured, the application runs, and billing is
simply off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By that definition most "self-hostable" kits are not. That is not an&lt;br&gt;
accusation; it is a consequence of building on a BaaS. It is worth knowing&lt;br&gt;
before you sign a data-processing agreement that lists sub-processors you&lt;br&gt;
did not choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters, concretely
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bill.&lt;/strong&gt; A rented stack is five invoices with five floors. A binary on&lt;br&gt;
a server is the server. We priced it in&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/the-rented-stack-bill" rel="noopener noreferrer"&gt;The rented stack&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The questionnaire.&lt;/strong&gt; The first enterprise customer sends a security&lt;br&gt;
questionnaire. "Where is the data, who are the sub-processors, how do you&lt;br&gt;
delete a user?" With a self-hosted foundation the answers are one country,&lt;br&gt;
three names and a button. See &lt;a href="https://govuekit.dev/blog/eu-hosting-gdpr-saas" rel="noopener noreferrer"&gt;Keeping a SaaS in the EU&lt;/a&gt;&lt;br&gt;
for what self-hosting settles and what it does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The exit.&lt;/strong&gt; When a vendor changes pricing or terms, a self-hosted product&lt;br&gt;
moves by copying a binary and a database dump. A product built on managed&lt;br&gt;
auth moves by re-implementing auth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Ask these of any kit. The honest answers are usually in the &lt;code&gt;.env.example&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Delete every third-party key from the configuration. Does it boot,
can a user sign up, sign in, reset a password?&lt;/strong&gt; If sign-in needs an
external provider to exist, it is not self-hosted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which database, and can I run it myself?&lt;/strong&gt; PostgreSQL on your server
counts. A vendor's "serverless Postgres" with a connection string does
not, unless the kit also runs against a plain instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where do background jobs run?&lt;/strong&gt; In-process with a database table:
yes. A hosted queue with a token: no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How does email leave?&lt;/strong&gt; SMTP to any host: yes. Only through one API
vendor: no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where do uploads go?&lt;/strong&gt; Local disk or any S3-compatible endpoint: yes.
One vendor's bucket API: no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens with no payment key?&lt;/strong&gt; The app should run and hide
billing. A crash, or a checkout button that errors, means the
unconfigured path was never tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What runs in production?&lt;/strong&gt; One process you can list is better than a
platform-specific build output. Count the runtimes: Node, PHP, Redis,
workers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can I read all of it?&lt;/strong&gt; Lines of code you will own, not features
advertised. If the answer is "it's a monorepo, you don't need to read
the packages", you are renting with extra steps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is the licence perpetual, and does it cover client work?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the repository come with a way to run everything locally,
including the external pieces (identity provider, S3, mail, payment
sandbox)?&lt;/strong&gt; If the vendor's own demo needs their cloud, so will yours.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How GoVueKit answers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Boots and signs users up with a database file and nothing else: argon2id
passwords, verification, reset, magic links, TOTP, session revocation.
Google, GitHub, Microsoft and any OIDC provider are switched on by
adding keys; the unconfigured path is tested in CI.&lt;/li&gt;
&lt;li&gt;PostgreSQL &lt;strong&gt;and&lt;/strong&gt; SQLite, same migrations, same generated queries,
both engines in the test matrix. &lt;code&gt;DB_DRIVER&lt;/code&gt; decides.&lt;/li&gt;
&lt;li&gt;Jobs are rows in your database, run in-process, retried, surviving
redeploys.&lt;/li&gt;
&lt;li&gt;SMTP by default; Resend is an option behind the same &lt;code&gt;Mailer&lt;/code&gt;
interface.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;STORAGE=disk&lt;/code&gt; or &lt;code&gt;STORAGE=s3&lt;/code&gt; against any endpoint (the labs uses
MinIO).&lt;/li&gt;
&lt;li&gt;No Stripe key means billing is off and the app is otherwise complete;
the tests cover that configuration because it is the one most
deployments run.&lt;/li&gt;
&lt;li&gt;One static binary, ~27 MB, the Vue app embedded. Traefik for TLS if you
want automatic certificates. Nothing else.&lt;/li&gt;
&lt;li&gt;About 11 000 lines of Go, 19 packages, one migration file, and a
&lt;code&gt;DECISIONS.md&lt;/code&gt; that explains the non-obvious choices — public before
you buy, at &lt;a href="https://github.com/kOlapsis/govuekit" rel="noopener noreferrer"&gt;github.com/kOlapsis/govuekit&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;€199 once, perpetual, lifetime updates of the major version, unlimited
end products including client work.&lt;/li&gt;
&lt;li&gt;One compose file, no account:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLO&lt;/span&gt; https://govuekit.dev/labs/docker-compose.yml
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;   &lt;span class="c"&gt;# app + PostgreSQL + Dex (OIDC) + MinIO (S3) + stripe-mock + Mailpit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tenth question is the one to start with. A kit that can boot its whole&lt;br&gt;
world on your laptop can boot it on your server; one that cannot has&lt;br&gt;
already told you where it really lives.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/self-hosted-saas-boilerplate-checklist" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>saas</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Embedding a Vue 3 app in a Go binary with go:embed — and keeping SEO without SSR</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Thu, 08 Oct 2026 07:07:04 +0000</pubDate>
      <link>https://dev.to/benjy33000/embedding-a-vue-3-app-in-a-go-binary-with-goembed-and-keeping-seo-without-ssr-1p2m</link>
      <guid>https://dev.to/benjy33000/embedding-a-vue-3-app-in-a-go-binary-with-goembed-and-keeping-seo-without-ssr-1p2m</guid>
      <description>&lt;p&gt;"One binary" is easy to say and easy to get half right. Serving &lt;code&gt;dist/&lt;/code&gt; from&lt;br&gt;
Go takes six lines; serving it so that deep links work, real 404s stay 404s,&lt;br&gt;
crawlers see content, canonicals point at the right origin and the build&lt;br&gt;
stays deployment-agnostic takes a few decisions. This is how GoVueKit does&lt;br&gt;
it, with the code, and the reasons.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Embed the build
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// web/embed.go&lt;/span&gt;
&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;web&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"embed"&lt;/span&gt;

&lt;span class="c"&gt;//go:embed all:dist&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;Dist&lt;/span&gt; &lt;span class="n"&gt;embed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two details matter. &lt;code&gt;all:&lt;/code&gt; includes dotfiles, so a &lt;code&gt;.gitkeep&lt;/code&gt; keeps &lt;code&gt;dist/&lt;/code&gt;&lt;br&gt;
present in a fresh clone and the embed directive never fails to match. And&lt;br&gt;
the frontend is built &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;go build&lt;/code&gt;, never at runtime: the&lt;br&gt;
&lt;code&gt;Makefile&lt;/code&gt; runs &lt;code&gt;npm ci &amp;amp;&amp;amp; vite build&lt;/code&gt; first, the Dockerfile has a Node stage&lt;br&gt;
whose only output is &lt;code&gt;web/dist&lt;/code&gt;, and the final image is &lt;code&gt;FROM scratch&lt;/code&gt; with&lt;br&gt;
the binary in it. Node exists at build time and nowhere else.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Serve the SPA without lying about 404s
&lt;/h2&gt;

&lt;p&gt;The naïve handler serves &lt;code&gt;index.html&lt;/code&gt; for every unknown path. That is also&lt;br&gt;
how you end up with &lt;code&gt;/blog/does-not-exist&lt;/code&gt; returning a 200 with an empty&lt;br&gt;
shell, which search engines index as duplicate content. GoVueKit mounts&lt;br&gt;
routes in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;/api/...&lt;/code&gt; — JSON handlers; an unknown API path answers a JSON 404.&lt;/li&gt;
&lt;li&gt;Server-rendered pages that must be real files to crawlers: &lt;code&gt;/blog&lt;/code&gt;,
&lt;code&gt;/blog/{slug}&lt;/code&gt;, &lt;code&gt;/blog/feed.xml&lt;/code&gt;, &lt;code&gt;/sitemap.xml&lt;/code&gt;, &lt;code&gt;/robots.txt&lt;/code&gt;. An
unknown slug renders the blog's own 404 page with status 404.&lt;/li&gt;
&lt;li&gt;Everything else — the SPA handler: a static asset if the path exists in
&lt;code&gt;dist/&lt;/code&gt;, the prerendered HTML if the route was captured (below), the
shell otherwise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shell is the fallback for &lt;strong&gt;application&lt;/strong&gt; routes (&lt;code&gt;/dashboard&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;/orgs/…&lt;/code&gt;), where the router owns the URL and a guest gets bounced to&lt;br&gt;
&lt;code&gt;/login&lt;/code&gt;. Nothing indexable relies on it.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Prerender the public pages at build time
&lt;/h2&gt;

&lt;p&gt;SSR would solve SEO and cost a Node process in production, which defeats&lt;br&gt;
the binary. Prerendering solves it at build time instead: the public pages&lt;br&gt;
are static enough (landing, pricing, legal, contact) that the HTML a crawler&lt;br&gt;
needs is the HTML an anonymous first visit renders.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;web/scripts/prerender.mjs&lt;/code&gt; runs after &lt;code&gt;vite build&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it serves &lt;code&gt;dist/&lt;/code&gt; through &lt;code&gt;vite preview&lt;/code&gt; and drives each route from
&lt;code&gt;prerender.routes.json&lt;/code&gt; (English and French twins) with the Playwright
Chromium;&lt;/li&gt;
&lt;li&gt;every request to another origin is aborted — an analytics script must not
record a build as a visit, and the build must not depend on a third-party
host being up;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/api/**&lt;/code&gt; is stubbed (&lt;code&gt;/api/auth/me&lt;/code&gt; → 401, anything else → 404), so the
captured page is exactly what an anonymous visitor sees;&lt;/li&gt;
&lt;li&gt;it fails loudly: a route that rendered under 500 characters, or that has
no canonical tag, breaks the build instead of embedding an empty shell;&lt;/li&gt;
&lt;li&gt;the captured HTML lands in &lt;code&gt;dist/prerendered/&amp;lt;route&amp;gt;/index.html&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Go server loads that directory at startup into a map from route to&lt;br&gt;
bytes. A route present in the map is served before the shell; a build&lt;br&gt;
without the directory gets an empty map and behaves as a plain SPA. The&lt;br&gt;
whole feature is one build flag (&lt;code&gt;PRERENDER=1&lt;/code&gt; in the Docker build).&lt;/p&gt;
&lt;h2&gt;
  
  
  4. The origin problem, and the placeholder
&lt;/h2&gt;

&lt;p&gt;Canonicals and &lt;code&gt;hreflang&lt;/code&gt; links are absolute URLs. At build time the app&lt;br&gt;
runs on &lt;code&gt;http://localhost:4173&lt;/code&gt;; in production it runs on whatever&lt;br&gt;
&lt;code&gt;BASE_URL&lt;/code&gt; the operator configured. Baking the production origin into the&lt;br&gt;
build would make the image deployment-specific, which is exactly what a&lt;br&gt;
signed, published image must not be.&lt;/p&gt;

&lt;p&gt;So the prerenderer rewrites its own origin to a placeholder,&lt;br&gt;
&lt;code&gt;__BASE_URL__&lt;/code&gt;, and the server substitutes it &lt;strong&gt;once at startup&lt;/strong&gt; while&lt;br&gt;
loading the pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"__BASE_URL__"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same image serves &lt;code&gt;https://govuekit.dev&lt;/code&gt; and &lt;code&gt;https://demo.example.com&lt;/code&gt;&lt;br&gt;
with correct canonicals on both. The e2e suite asserts that no&lt;br&gt;
&lt;code&gt;__BASE_URL__&lt;/code&gt; survives in a served page.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. Hydration, hreflang and the sitemap
&lt;/h2&gt;

&lt;p&gt;The Vue app mounts on top of the prerendered DOM, so a browser gets content&lt;br&gt;
first and interactivity a moment later; the head tags the app manages&lt;br&gt;
(title, description, canonical, the &lt;code&gt;hreflang&lt;/code&gt; pair for &lt;code&gt;/x&lt;/code&gt; and &lt;code&gt;/fr/x&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;x-default&lt;/code&gt; on English) are written by a small &lt;code&gt;useSeoHead&lt;/code&gt; composable and&lt;br&gt;
captured with the page. The sitemap is derived from the set of prerendered&lt;br&gt;
routes plus the blog posts with their &lt;code&gt;lastmod&lt;/code&gt;, so a route added to&lt;br&gt;
&lt;code&gt;prerender.routes.json&lt;/code&gt; is indexed without touching Go.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. What this does not do
&lt;/h2&gt;

&lt;p&gt;It does not render per-user or per-request content for crawlers: the&lt;br&gt;
dashboard is not prerendered, and should not be. It does not replace a&lt;br&gt;
content-heavy marketing site with thousands of pages; for that, a static&lt;br&gt;
site generator next to the binary is the right tool. And it does not&lt;br&gt;
prerender the blog, because the blog is already server-rendered by Go from&lt;br&gt;
embedded markdown — the other half of the "content in the binary" story.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;GoVueKit's own sales site is built this way: &lt;code&gt;curl -sI https://govuekit.dev/fr&lt;/code&gt;&lt;br&gt;
shows a real HTML document with a canonical and two &lt;code&gt;hreflang&lt;/code&gt; links before&lt;br&gt;
any JavaScript runs. The mechanism, the knobs and the reasons live in&lt;br&gt;
&lt;code&gt;docs/SEO.md&lt;/code&gt; of the kit, and the whole product runs locally from one file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLO&lt;/span&gt; https://govuekit.dev/labs/docker-compose.yml
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/embed-vue-app-in-go-binary-seo" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>vue</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Idempotent billing webhooks: exactly-once fulfilment with Stripe</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Tue, 06 Oct 2026 07:05:40 +0000</pubDate>
      <link>https://dev.to/benjy33000/idempotent-billing-webhooks-exactly-once-fulfilment-with-stripe-44jb</link>
      <guid>https://dev.to/benjy33000/idempotent-billing-webhooks-exactly-once-fulfilment-with-stripe-44jb</guid>
      <description>&lt;p&gt;Payment providers deliver webhooks &lt;em&gt;at least once&lt;/em&gt;. That is not a bug in their&lt;br&gt;
systems, it is the only guarantee a network can offer: if your endpoint is&lt;br&gt;
slow, times out, or answers 500 after committing, the event comes back. Twice.&lt;br&gt;
Sometimes days later.&lt;/p&gt;

&lt;p&gt;Everything that follows exists to make the second delivery boring.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three ways this goes wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Double fulfilment.&lt;/strong&gt; The event arrives twice, you grant credits twice. The&lt;br&gt;
customer notices only when it goes the other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lost fulfilment.&lt;/strong&gt; You verify the signature, start work, and time out. The&lt;br&gt;
provider retries, your handler crashes on a half-written state, and the&lt;br&gt;
subscription never activates. The customer paid; your database disagrees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting the payload.&lt;/strong&gt; The webhook body says the subscription is active.&lt;br&gt;
Between the event being generated and your handler running, the payment was&lt;br&gt;
disputed and the subscription cancelled. A valid signature proves who sent the&lt;br&gt;
message, not that the world still looks like that.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Four rules, in order.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Read the raw body and verify the signature
&lt;/h3&gt;

&lt;p&gt;The signature is computed over the exact bytes sent. Any middleware that&lt;br&gt;
parses and re-serialises JSON before you check it will break verification, so&lt;br&gt;
the webhook route must read the body before anything touches it.&lt;/p&gt;

&lt;p&gt;Stripe's tolerance window (300 seconds by default in GoVueKit) also matters:&lt;br&gt;
it is what stops an attacker from replaying a captured, still-valid request&lt;br&gt;
next month.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Answer fast, work after
&lt;/h3&gt;

&lt;p&gt;Providers treat a slow endpoint as a failed one. The handler acknowledges as&lt;br&gt;
soon as the event is durably recorded, and the actual work happens in the&lt;br&gt;
background worker — which, because the queue is a database table, survives the&lt;br&gt;
redeploy that happens three seconds later.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Deduplicate with a primary key, in the same transaction as the write
&lt;/h3&gt;

&lt;p&gt;This is the part that people get almost right. The failure mode of "check if&lt;br&gt;
processed, then process, then mark processed" is a crash between steps two and&lt;br&gt;
three — or two deliveries racing.&lt;/p&gt;

&lt;p&gt;The fix is not a lock. It is a single transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;   &lt;span class="c1"&gt;-- primary key&lt;/span&gt;
  &lt;span class="c1"&gt;-- unique violation → this event is already done, ROLLBACK and answer 200&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either both happen or neither does. A duplicate delivery hits the primary key&lt;br&gt;
and becomes a no-op. A crash mid-way rolls back and the provider's retry&lt;br&gt;
replays it cleanly. There is no window in which the event is marked done but&lt;br&gt;
the business write is missing.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Re-fetch the object by id; never trust the payload
&lt;/h3&gt;

&lt;p&gt;The handler takes exactly one thing from the payload: the object id. Then it&lt;br&gt;
asks the provider's API what that object looks like &lt;em&gt;now&lt;/em&gt;, and writes that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;event → id → provider.Resolve(ctx, ev) → authoritative state → write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This costs one API call per event and removes a whole class of race&lt;br&gt;
conditions. It also means a replayed six-day-old event cannot resurrect a&lt;br&gt;
cancelled subscription: the re-fetch returns the current state.&lt;/p&gt;

&lt;p&gt;It has a second benefit that only shows up in production: because nothing is&lt;br&gt;
read from the payload beyond the id, the API release train stamped on the&lt;br&gt;
envelope stops mattering. Verification checks the signature and the timestamp,&lt;br&gt;
deliberately not the version, and every re-fetch sends the SDK's own&lt;br&gt;
&lt;code&gt;Stripe-Version&lt;/code&gt; — so a dashboard that upgrades under you does not turn every&lt;br&gt;
delivery into a 400.&lt;/p&gt;

&lt;p&gt;The same rule forbids fetching URLs found in a payload — a webhook body is&lt;br&gt;
attacker-influenced input, and following its links is how SSRF starts. Any&lt;br&gt;
outbound call in GoVueKit that is not to a hardcoded host goes through the&lt;br&gt;
hardened &lt;code&gt;safehttp&lt;/code&gt; client, which validates the IP at dial time and refuses&lt;br&gt;
private ranges.&lt;/p&gt;
&lt;h2&gt;
  
  
  One provider, behind a seam
&lt;/h2&gt;

&lt;p&gt;GoVueKit sells through Stripe. A store has one merchant of record, so a second&lt;br&gt;
provider is not a feature the kit switches between — but it is a package&lt;br&gt;
away rather than a rewrite, because the billing page, the admin metrics and&lt;br&gt;
the subscription row talk to an interface, not to Stripe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Provider&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Checkout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actorEmail&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="n"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actorEmail&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;VerifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Change&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five methods. If you need Paddle, Lemon Squeezy or a local PSP, that is what&lt;br&gt;
you implement, and nothing above the seam changes. Be honest with yourself&lt;br&gt;
about the size of that job: it is a real integration, not a config switch, and&lt;br&gt;
the kit does not ship it.&lt;/p&gt;

&lt;p&gt;What the seam does buy you is that the guarantee travels. The shape of the&lt;br&gt;
dedup — one row, one primary key, one transaction — does not depend on what&lt;br&gt;
the key is made of. Stripe hands you a unique event id; providers that do not&lt;br&gt;
force you to build the key from the pair (event name, object id) instead. The&lt;br&gt;
pattern survives the substitution, which is the whole point of writing it&lt;br&gt;
against an interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money leaving the account counts too
&lt;/h2&gt;

&lt;p&gt;Eight event types cover money arriving. Two more cover it leaving, and they&lt;br&gt;
are the ones that get forgotten:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;charge.refunded&lt;/code&gt; &lt;strong&gt;stamps&lt;/strong&gt; the purchase rather than deleting it. The row
stays because it is what reconciles with the provider's own records.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;charge.dispute.created&lt;/code&gt; is logged at Error level, so it reaches your error
reporter rather than a log file nobody greps. A chargeback has a deadline;
it should behave like an incident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three &lt;code&gt;checkout.session.*&lt;/code&gt; events matter together, not individually. A&lt;br&gt;
card pays inside the checkout, so &lt;code&gt;completed&lt;/code&gt; already reports&lt;br&gt;
&lt;code&gt;payment_status=paid&lt;/code&gt;. A deferred method — SEPA direct debit, which Stripe&lt;br&gt;
enables readily on a EUR checkout — completes &lt;strong&gt;unpaid&lt;/strong&gt; and settles days&lt;br&gt;
later as &lt;code&gt;async_payment_succeeded&lt;/code&gt;, or bounces as &lt;code&gt;async_payment_failed&lt;/code&gt;.&lt;br&gt;
Subscribe to &lt;code&gt;completed&lt;/code&gt; alone and those sales are never fulfilled, silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to test
&lt;/h2&gt;

&lt;p&gt;Three tests, and they are quick:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bad signature → 400, nothing written.&lt;/strong&gt; The most common configuration
error in production is a mismatched signing secret; you want it loud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same event delivered twice → one business write.&lt;/strong&gt; Send the identical
payload twice and assert the subscription row changed once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown event type → 200.&lt;/strong&gt; Providers add event types; your endpoint must
ignore them without erroring, or their dashboard will start disabling your
webhook.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;GoVueKit ships all three as integration tests, plus a Playwright journey that&lt;br&gt;
posts a signed duplicate through the real binary. The unconfigured path is&lt;br&gt;
tested too: with no Stripe key — which is the default, and what most&lt;br&gt;
deployments run — the endpoints answer a deliberate 501 and nothing is&lt;br&gt;
written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Point Stripe at &lt;code&gt;/api/billing/webhook&lt;/code&gt;, and subscribe the eight events the
README lists rather than "all events": an endpoint that receives everything
is an endpoint whose logs tell you nothing.&lt;/li&gt;
&lt;li&gt;Local testing: &lt;code&gt;stripe listen --forward-to localhost:8080/api/billing/webhook&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Log the event id on every branch. When a customer says "I paid and nothing
happened", the id is what turns a conversation into a query.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;processed_events&lt;/code&gt; forever, or at least well past the provider's retry
window. It is small, and it is your proof.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule underneath all of this is the same one that governs&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/row-level-multi-tenancy-go" rel="noopener noreferrer"&gt;tenancy&lt;/a&gt;: make the safe path the only&lt;br&gt;
convenient path, then write the test that fails when someone finds a shortcut.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://govuekit.dev/#inside" rel="noopener noreferrer"&gt;See how billing fits in the kit&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/idempotent-billing-webhooks" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>go</category>
      <category>backend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Row-level multi-tenancy in Go: the rule, the middleware and the test that proves it</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Thu, 01 Oct 2026 07:07:03 +0000</pubDate>
      <link>https://dev.to/benjy33000/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves-it-3gkb</link>
      <guid>https://dev.to/benjy33000/row-level-multi-tenancy-in-go-the-rule-the-middleware-and-the-test-that-proves-it-3gkb</guid>
      <description>&lt;p&gt;Multi-tenancy has three common implementations: a database per tenant, a&lt;br&gt;
schema per tenant, and a column on every row. For a SaaS that starts small and&lt;br&gt;
must stay operable by one person, the column wins — provided the rule is&lt;br&gt;
enforced in one place rather than remembered in fifty.&lt;/p&gt;

&lt;p&gt;Here is the whole design, as implemented in GoVueKit, and the reasoning behind&lt;br&gt;
each choice.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Every business table has an &lt;code&gt;organization_id&lt;/code&gt;. Every query filters on it.&lt;br&gt;
The value comes from the request context, never from the client.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three sentences, and they are the entire security model for tenancy. The&lt;br&gt;
implementation exists to make violating them awkward.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: the route carries the tenant
&lt;/h2&gt;

&lt;p&gt;Tenant-scoped routes live under &lt;code&gt;/api/orgs/{orgID}/…&lt;/code&gt;. That is deliberate: the&lt;br&gt;
tenant is part of the URL, so it is visible in logs, in tests, and in the&lt;br&gt;
router itself. The alternative — an implicit "current organization" in the&lt;br&gt;
session — makes every request depend on hidden state, and makes tab-switching&lt;br&gt;
users a bug report.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: one middleware resolves membership
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Context loads the organization and the caller's membership. Non-members&lt;/span&gt;
&lt;span class="c"&gt;// get a 404 — indistinguishable from a nonexistent org, so org IDs never&lt;/span&gt;
&lt;span class="c"&gt;// leak across tenants. Must be mounted after RequireAuth.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Org&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;UserFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"authentication required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;orgID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URLParam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"orgID"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrgMember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrgMemberParams&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;OrganizationID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orgID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNoRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c"&gt;// ... load the organization, put {Org, Role} in the context&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The membership lookup &lt;em&gt;is&lt;/em&gt; the authorisation check. If the caller is not a&lt;br&gt;
member, the request dies here — before any handler, before any query, before&lt;br&gt;
any chance to forget.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: 404, not 403
&lt;/h2&gt;

&lt;p&gt;A 403 says: this organization exists, and you are not in it. That is an&lt;br&gt;
information leak, and a useful one for an attacker enumerating ids. A 404 says&lt;br&gt;
nothing at all.&lt;/p&gt;

&lt;p&gt;The same reasoning applies inside a tenant: a resource that belongs to another&lt;br&gt;
organization is not "forbidden", it is &lt;em&gt;not found&lt;/em&gt;, because from the caller's&lt;br&gt;
point of view it does not exist.&lt;/p&gt;

&lt;p&gt;The exception is roles inside an organization the caller &lt;em&gt;is&lt;/em&gt; a member of.&lt;br&gt;
There, 403 is correct: the member knows the organization exists, they can see&lt;br&gt;
the button is disabled, and hiding the difference would only confuse them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: the queries cannot be called wrong
&lt;/h2&gt;

&lt;p&gt;sqlc generates a function per SQL statement. Write the statement with the&lt;br&gt;
tenant in the &lt;code&gt;WHERE&lt;/code&gt; clause and the generated signature demands it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- name: DeleteOrgInvitation :exec&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;org_invitations&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeleteOrgInvitation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeleteOrgInvitationParams&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;invID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrganizationID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orgCtx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A developer who wants to bypass tenancy has to write a new SQL statement,&lt;br&gt;
which shows up in review as a file change in &lt;code&gt;internal/db/queries/&lt;/code&gt;. That is&lt;br&gt;
the property you want: the unsafe path is visible, not convenient.&lt;/p&gt;

&lt;p&gt;This is also why the kit uses sqlc rather than an ORM. With a query builder,&lt;br&gt;
&lt;code&gt;Invitation.find(id)&lt;/code&gt; compiles, runs, and returns another tenant's row. There&lt;br&gt;
is no equivalent of that mistake here, because there is no generic finder.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5: roles are checked server-side
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;With&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireOrgRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoleOwner&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orgsH&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The frontend also hides the delete button, and that check is explicitly&lt;br&gt;
cosmetic. Anyone can open the console and call the endpoint; the middleware is&lt;br&gt;
what answers.&lt;/p&gt;

&lt;p&gt;One rule that is easy to miss: the &lt;strong&gt;last owner&lt;/strong&gt; of an organization cannot be&lt;br&gt;
demoted, removed, or leave. Without it, organizations become unadministrable&lt;br&gt;
and support tickets become database surgery.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 6: the test that keeps it true
&lt;/h2&gt;

&lt;p&gt;Every one of the above can be undone by a well-meaning refactor. So the&lt;br&gt;
property gets a test at the level where it actually matters — through HTTP,&lt;br&gt;
against a real database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Create user A and organization A.
2. Create user B and organization B.
3. As B,   GET   /api/orgs/{orgA}                       → 404
4. As B,   GET   /api/orgs/{orgA}/members               → 404
5. As a member (not owner) of A,
           PATCH /api/orgs/{orgA}/members/{userID}      → 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 3 and 4 are the ones that matter. They are cheap to write once and they&lt;br&gt;
fail loudly the day someone adds a query without the tenant.&lt;/p&gt;

&lt;p&gt;In GoVueKit this exists twice: as integration tests run against both database&lt;br&gt;
engines in CI, and as a Playwright journey against the production binary.&lt;br&gt;
Write it at the same time as the resource, never afterwards — a test you must&lt;br&gt;
remember to add later is a test that gets added after the incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this design does not give you
&lt;/h2&gt;

&lt;p&gt;Be clear about the limits before you adopt it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No physical isolation.&lt;/strong&gt; A bug in a query can, in principle, return
another tenant's row. The mitigations are structural, not physical. If your
compliance requirement is literally separate databases, this is not the
model you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No per-tenant backup/restore.&lt;/strong&gt; Restoring one customer's data means
selective export, not a file copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Noisy neighbours share resources.&lt;/strong&gt; One tenant's heavy query can slow
others; you handle that with indexes, pagination and limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In exchange you get one database to operate, migrations that run once, and the&lt;br&gt;
ability to answer "how many organizations are on the paid plan?" with a single&lt;br&gt;
&lt;code&gt;GROUP BY&lt;/code&gt;. For the overwhelming majority of SaaS products, that trade is&lt;br&gt;
correct — and if you outgrow it, you outgrow it with revenue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-line summary for your own codebase
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Put &lt;code&gt;organization_id&lt;/code&gt; on every business table, with a cascade and an index.&lt;/li&gt;
&lt;li&gt;Resolve membership in one middleware, from the URL, after authentication.&lt;/li&gt;
&lt;li&gt;Answer 404 to outsiders, 403 only inside an organization the caller belongs
to.&lt;/li&gt;
&lt;li&gt;Write queries that take the tenant, so the generated code refuses to be
called without it.&lt;/li&gt;
&lt;li&gt;Write the cross-tenant test once per resource, and run it in CI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;See it in the &lt;a href="https://govuekit.dev/#code" rel="noopener noreferrer"&gt;code excerpts on the landing page&lt;/a&gt;, or read how the&lt;br&gt;
same discipline applies to &lt;a href="https://govuekit.dev/blog/idempotent-billing-webhooks" rel="noopener noreferrer"&gt;billing webhooks&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/row-level-multi-tenancy-go" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>database</category>
      <category>saas</category>
    </item>
    <item>
      <title>SQLite or PostgreSQL: one codebase, two engines, one env var</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Tue, 29 Sep 2026 07:12:02 +0000</pubDate>
      <link>https://dev.to/benjy33000/sqlite-or-postgresql-one-codebase-two-engines-one-env-var-36k4</link>
      <guid>https://dev.to/benjy33000/sqlite-or-postgresql-one-codebase-two-engines-one-env-var-36k4</guid>
      <description>&lt;p&gt;Most boilerplates pick one database and bake it in. GoVueKit supports both,&lt;br&gt;
selected by &lt;code&gt;DB_DRIVER&lt;/code&gt;, and the same SQL runs on either. This post explains&lt;br&gt;
how that is done, what it costs, and — the part that actually matters — when&lt;br&gt;
SQLite is a legitimate production choice rather than a toy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother supporting both
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Development.&lt;/strong&gt; SQLite means &lt;code&gt;make dev&lt;/code&gt; works on a fresh laptop with no&lt;br&gt;
service to install, no container to start, no credentials to invent. The file&lt;br&gt;
is created and migrated on first run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small production.&lt;/strong&gt; A single-server product with a few thousand users and&lt;br&gt;
read-heavy traffic runs perfectly on SQLite, with backups that are a file&lt;br&gt;
copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests that mean something.&lt;/strong&gt; CI runs the entire Go suite twice, once per&lt;br&gt;
engine. Bugs that only appear on one engine are found by the machine, not by&lt;br&gt;
a customer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optionality.&lt;/strong&gt; If you grow into concurrent writes, you change one variable&lt;br&gt;
instead of rewriting queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  How portability is actually achieved
&lt;/h2&gt;

&lt;p&gt;Four rules, and they are less restrictive than they sound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Portable types only.&lt;/strong&gt; &lt;code&gt;TEXT&lt;/code&gt; for ids (UUIDs generated in Go, never&lt;br&gt;
&lt;code&gt;SERIAL&lt;/code&gt;), &lt;code&gt;TIMESTAMP&lt;/code&gt; for time, &lt;code&gt;BOOLEAN&lt;/code&gt;, &lt;code&gt;INTEGER&lt;/code&gt;. No engine-specific&lt;br&gt;
column types, no &lt;code&gt;JSONB&lt;/code&gt;, no arrays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;RETURNING&lt;/code&gt; is allowed.&lt;/strong&gt; Both engines support it, which removes the&lt;br&gt;
last-insert-id dance and keeps inserts to one round trip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. One code generation.&lt;/strong&gt; sqlc generates against the PostgreSQL dialect with&lt;br&gt;
&lt;code&gt;$1&lt;/code&gt; placeholders — and the pure-Go SQLite driver (&lt;code&gt;modernc.org/sqlite&lt;/code&gt;)&lt;br&gt;
accepts &lt;code&gt;$N&lt;/code&gt; natively. So one set of generated Go types serves both engines&lt;br&gt;
instead of two divergent packages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. No CGO.&lt;/strong&gt; &lt;code&gt;modernc.org/sqlite&lt;/code&gt; is SQLite translated to Go. That is what&lt;br&gt;
keeps &lt;code&gt;go build&lt;/code&gt; cross-compiling to a static binary with no C toolchain, and&lt;br&gt;
it is the reason the "one file to deploy" story holds on both engines.&lt;/p&gt;

&lt;p&gt;What you give up: &lt;code&gt;JSONB&lt;/code&gt; operators, PostgreSQL full-text search, array&lt;br&gt;
columns, materialised views, &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;. If your product's core feature&lt;br&gt;
needs one of those, use PostgreSQL from day one — the kit runs on it natively&lt;br&gt;
and you simply stop caring about the SQLite path.&lt;/p&gt;

&lt;h2&gt;
  
  
  When SQLite is enough in production
&lt;/h2&gt;

&lt;p&gt;SQLite's limit is concurrent &lt;em&gt;writes&lt;/em&gt;: one writer at a time, serialised. In&lt;br&gt;
practice, with WAL mode, that supports far more than people assume — thousands&lt;br&gt;
of writes per second on decent hardware, because each write is short.&lt;/p&gt;

&lt;p&gt;It is a good fit when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one application server is enough (SQLite is a file, not a network service);&lt;/li&gt;
&lt;li&gt;traffic is read-heavy, which describes most B2B SaaS;&lt;/li&gt;
&lt;li&gt;you value operational simplicity over horizontal scaling;&lt;/li&gt;
&lt;li&gt;your backup story can be "copy the file, plus the WAL".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Switch to PostgreSQL when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you need more than one application instance sharing state;&lt;/li&gt;
&lt;li&gt;writes are sustained and concurrent (event ingestion, chat, telemetry);&lt;/li&gt;
&lt;li&gt;you want managed backups, replicas, point-in-time recovery;&lt;/li&gt;
&lt;li&gt;your queries need what only PostgreSQL offers, per the list above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest summary: SQLite scales further than its reputation, and the moment&lt;br&gt;
you need two application servers, none of that matters — the file cannot be&lt;br&gt;
shared, and you move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving from one to the other
&lt;/h2&gt;

&lt;p&gt;The schema is the same; only the data has to travel. The path is a dump and a&lt;br&gt;
load, done during a maintenance window:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stop the app.&lt;/li&gt;
&lt;li&gt;Export each table from SQLite (CSV or &lt;code&gt;INSERT&lt;/code&gt; statements).&lt;/li&gt;
&lt;li&gt;Start with &lt;code&gt;DB_DRIVER=postgres&lt;/code&gt; and &lt;code&gt;AUTO_MIGRATE=true&lt;/code&gt; so goose creates the
schema.&lt;/li&gt;
&lt;li&gt;Load the data, then check row counts per table and re-run the test suite
against the new database.&lt;/li&gt;
&lt;li&gt;Start the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no ORM-generated schema drift to reconcile, because the migrations&lt;br&gt;
are the same files. Do it early — moving 50,000 rows is an evening, moving 50&lt;br&gt;
million is a project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules you must keep if you edit the SQL
&lt;/h2&gt;

&lt;p&gt;The portability is a discipline, not a library. Three habits keep it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write the query in &lt;code&gt;internal/db/queries/*.sql&lt;/code&gt;&lt;/strong&gt;, never inline in Go, so
every statement is reviewable in one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;make test&lt;/code&gt; before committing.&lt;/strong&gt; CI runs both engines; your laptop
runs SQLite by default, and &lt;code&gt;TEST_DB_DRIVER=postgres&lt;/code&gt; (with
&lt;code&gt;TEST_POSTGRES_DSN&lt;/code&gt; pointing at a server) runs the same suite locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid the tempting extension.&lt;/strong&gt; The moment you write &lt;code&gt;JSONB&lt;/code&gt;, decide
explicitly that PostgreSQL is now required, and note it in &lt;code&gt;DECISIONS.md&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the real trade-off. Portability costs you the fancy features&lt;br&gt;
of both engines; in exchange, development has no dependencies and production&lt;br&gt;
has an escape hatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable question
&lt;/h2&gt;

&lt;p&gt;Is supporting two engines worth it for &lt;em&gt;your&lt;/em&gt; product? If you know you will&lt;br&gt;
run PostgreSQL, you could delete the SQLite path and use &lt;code&gt;JSONB&lt;/code&gt; freely. The&lt;br&gt;
kit does not stop you — it is your source code.&lt;/p&gt;

&lt;p&gt;What you would lose is the property that makes a first run cheap: clone the&lt;br&gt;
repository, start the server, and the database is a file that did not exist a&lt;br&gt;
second ago. That is also what makes the test suite fast enough to run on every&lt;br&gt;
save.&lt;/p&gt;

&lt;p&gt;Keep both while you are small. Drop one deliberately, with a line in&lt;br&gt;
&lt;code&gt;DECISIONS.md&lt;/code&gt;, if your product outgrows the compromise.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://govuekit.dev/#code" rel="noopener noreferrer"&gt;See how the data layer fits together&lt;/a&gt;, or read&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/deploy-go-saas-vps-traefik" rel="noopener noreferrer"&gt;the deployment guide&lt;/a&gt; for the production&lt;br&gt;
setup on either engine.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/sqlite-or-postgres" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>postgres</category>
      <category>go</category>
      <category>database</category>
    </item>
    <item>
      <title>GoVueKit vs supastarter vs SaaS Pegasus vs ShipFast: an architecture comparison</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Thu, 24 Sep 2026 07:13:03 +0000</pubDate>
      <link>https://dev.to/benjy33000/govuekit-vs-supastarter-vs-saas-pegasus-vs-shipfast-an-architecture-comparison-158m</link>
      <guid>https://dev.to/benjy33000/govuekit-vs-supastarter-vs-saas-pegasus-vs-shipfast-an-architecture-comparison-158m</guid>
      <description>&lt;p&gt;People searching for "ShipFast alternative" or "supastarter vs" are rarely&lt;br&gt;
asking which kit has more checkboxes. They are asking which &lt;em&gt;architecture&lt;/em&gt;&lt;br&gt;
they want to live with. This is a comparison of four kits that each&lt;br&gt;
represent a family, written by the people who sell one of them (GoVueKit),&lt;br&gt;
with every price linked to the page it came from so you can verify it the&lt;br&gt;
day you read this.&lt;/p&gt;
&lt;h2&gt;
  
  
  The four, in one line each
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://shipfa.st" rel="noopener noreferrer"&gt;&lt;strong&gt;ShipFast&lt;/strong&gt;&lt;/a&gt; — Next.js on managed services (MongoDB
or Supabase, Google OAuth and magic links, Stripe or Lemon Squeezy,
Mailgun or Resend). The fastest path from zero to a landing page with a
checkout. Built for solo makers shipping many small products.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://supastarter.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;supastarter&lt;/strong&gt;&lt;/a&gt; — a full-stack TypeScript kit
for Next.js, Nuxt or TanStack Start: better-auth, Prisma or Drizzle,
five payment providers, organizations with seat billing, i18n, an admin
UI, serverless-friendly. Built for TypeScript teams shipping a B2B
product.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.saaspegasus.com" rel="noopener noreferrer"&gt;&lt;strong&gt;SaaS Pegasus&lt;/strong&gt;&lt;/a&gt; — a Django monolith with
React or HTMX on the front (a Vue example too), Celery for background
work, teams with roles, 2FA, i18n, feature flags, impersonation, Wagtail
CMS. Built for Python teams, and the most mature of the four.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://govuekit.dev" rel="noopener noreferrer"&gt;&lt;strong&gt;GoVueKit&lt;/strong&gt;&lt;/a&gt; — a Go API (chi, sqlc) with a Vue 3 +
TypeScript SPA embedded in one binary, PostgreSQL or SQLite, accounts
with OIDC and TOTP, organizations with row-level tenancy, Stripe with
idempotent webhooks, a durable email queue, an admin backoffice. Built
for teams who want to own and self-host one artefact.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;ShipFast&lt;/th&gt;
&lt;th&gt;supastarter&lt;/th&gt;
&lt;th&gt;SaaS Pegasus&lt;/th&gt;
&lt;th&gt;GoVueKit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Next.js API routes&lt;/td&gt;
&lt;td&gt;Nuxt/Next server + Hono&lt;/td&gt;
&lt;td&gt;Django&lt;/td&gt;
&lt;td&gt;Go (chi + sqlc)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;React (Next.js)&lt;/td&gt;
&lt;td&gt;Vue (Nuxt) or React&lt;/td&gt;
&lt;td&gt;React or HTMX&lt;/td&gt;
&lt;td&gt;Vue 3 SPA, embedded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;MongoDB or Supabase&lt;/td&gt;
&lt;td&gt;Prisma/Drizzle (PostgreSQL…)&lt;/td&gt;
&lt;td&gt;PostgreSQL (Django ORM)&lt;/td&gt;
&lt;td&gt;PostgreSQL &lt;strong&gt;or&lt;/strong&gt; SQLite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Teams / tenancy&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;organizations, seat billing&lt;/td&gt;
&lt;td&gt;teams, roles&lt;/td&gt;
&lt;td&gt;organizations, roles, row-level isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;Stripe or Lemon Squeezy&lt;/td&gt;
&lt;td&gt;Stripe, Lemon Squeezy, Polar, Creem, Dodo&lt;/td&gt;
&lt;td&gt;Stripe (subs, per-seat, e-commerce)&lt;/td&gt;
&lt;td&gt;Stripe (subs, one-time), idempotent webhooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background jobs&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;trigger.dev / QStash (external)&lt;/td&gt;
&lt;td&gt;Celery (+ broker)&lt;/td&gt;
&lt;td&gt;in-process, DB-backed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;Mailgun / Resend&lt;/td&gt;
&lt;td&gt;transactional templates&lt;/td&gt;
&lt;td&gt;templates&lt;/td&gt;
&lt;td&gt;FR/EN templates, durable queue, SMTP or Resend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;admin UI&lt;/td&gt;
&lt;td&gt;Django admin + impersonation&lt;/td&gt;
&lt;td&gt;backoffice: MRR, ARPU, churn, audit, impersonation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i18n&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;yes, incl. emails&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;FR/EN UI + emails, parity tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs in production&lt;/td&gt;
&lt;td&gt;Node (Vercel-shaped)&lt;/td&gt;
&lt;td&gt;Node / serverless&lt;/td&gt;
&lt;td&gt;Python + Celery + broker&lt;/td&gt;
&lt;td&gt;one static binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests shipped&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Playwright e2e&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;234 tests on both DBs + 14 Playwright journeys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price (time of writing)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://shipfa.st" rel="noopener noreferrer"&gt;$199–$299&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://supastarter.dev/nuxt" rel="noopener noreferrer"&gt;€299–€1,499&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.saaspegasus.com/pricing/" rel="noopener noreferrer"&gt;$449 (1 project) / $649&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://govuekit.dev/#pricing" rel="noopener noreferrer"&gt;€199 (launch) / €249&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refund&lt;/td&gt;
&lt;td&gt;none after access&lt;/td&gt;
&lt;td&gt;see site&lt;/td&gt;
&lt;td&gt;7 days&lt;/td&gt;
&lt;td&gt;14 days until the code is claimed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cells report what each product states on its own pages; where a page&lt;br&gt;
does not say, the cell is empty rather than guessed.&lt;/p&gt;
&lt;h2&gt;
  
  
  What each architecture assumes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ShipFast assumes you will not need a backend.&lt;/strong&gt; That is a feature, not a&lt;br&gt;
gap: a landing page, a checkout, a login and a database-as-a-service is&lt;br&gt;
the right shape for a $9/month micro-SaaS, and nothing ships faster. It&lt;br&gt;
stops being the right shape when a customer asks for teams, an audit log&lt;br&gt;
or a data-processing agreement listing your sub-processors. Its&lt;br&gt;
non-refundable policy is worth knowing before you buy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;supastarter assumes TypeScript end to end and a Node host.&lt;/strong&gt; If your&lt;br&gt;
team writes TypeScript on both sides and deploys on Vercel or a Node&lt;br&gt;
runtime, it is the most complete kit in the Vue and React worlds: real&lt;br&gt;
organizations, seat billing, i18n down to the emails. The cost is&lt;br&gt;
structural: background jobs run on an external service, and production&lt;br&gt;
means a JavaScript runtime plus whatever the serverless target adds. The&lt;br&gt;
top tiers are priced for agencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SaaS Pegasus assumes Python and a team that will run Celery.&lt;/strong&gt; It is the&lt;br&gt;
most mature product here, with features the others lack (feature flags,&lt;br&gt;
Wagtail, async channels) and a per-project licence at the lower tier. A&lt;br&gt;
Django monolith is a fine place to live; it also means a Python runtime, a&lt;br&gt;
worker, a broker and, usually, a Node build for the React front. If your&lt;br&gt;
backend team is Python, this is the serious choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GoVueKit assumes you want one artefact you can read.&lt;/strong&gt; The Vue app is&lt;br&gt;
compiled into the Go binary; the public pages are prerendered for SEO; the&lt;br&gt;
database is PostgreSQL or SQLite from the same generated queries; jobs and&lt;br&gt;
the email queue are tables in that database. Production is one ~27 MB&lt;br&gt;
file on any VPS, no Node, no broker, no platform. The cost is a Go backend&lt;br&gt;
— a feature for Go teams, a wall for others — and a smaller ecosystem of&lt;br&gt;
add-ons than Django or Next.js. It is also deliberately a &lt;em&gt;core&lt;/em&gt;: a blog&lt;br&gt;
engine, a code generator and a second payment provider were built and then&lt;br&gt;
removed to keep the codebase readable in an afternoon, and&lt;br&gt;
&lt;a href="https://github.com/kOlapsis/govuekit/blob/main/DECISIONS.md" rel="noopener noreferrer"&gt;DECISIONS.md&lt;/a&gt;&lt;br&gt;
says why.&lt;/p&gt;
&lt;h2&gt;
  
  
  Five questions that decide it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which language is your backend team fluent in?&lt;/strong&gt; This settles it
more often than any feature. TypeScript → supastarter. Python →
Pegasus. Go → GoVueKit. None yet, solo, shipping a small product →
ShipFast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you need organizations on day one?&lt;/strong&gt; ShipFast does not have them.
The other three do; only GoVueKit and Pegasus test the cross-tenant
boundary in an end-to-end suite that you can read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where will it run, and who signs the data-processing agreement?&lt;/strong&gt;
Managed services mean sub-processors; a binary or a monolith on your
VPS means your hosting provider and Stripe. European customers ask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the exit?&lt;/strong&gt; A binary and a database dump move anywhere. A
product built on a BaaS moves by re-implementing what the BaaS did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How much code will you own?&lt;/strong&gt; Ask each vendor for the line count.
Owning more is not better; owning what you can read is.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Try the one you can try without paying
&lt;/h2&gt;

&lt;p&gt;ShipFast and supastarter have demos on their sites; Pegasus has an&lt;br&gt;
open-source tier. GoVueKit runs its &lt;strong&gt;entire&lt;/strong&gt; stack locally from one file,&lt;br&gt;
no account, no licence — the app, PostgreSQL, single sign-on through Dex,&lt;br&gt;
MinIO for S3, a Stripe double and a mail inbox:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLO&lt;/span&gt; https://govuekit.dev/labs/docker-compose.yml
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever you pick, read its tenancy code and its webhook handler before&lt;br&gt;
you bill through it. That hour is the whole evaluation.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If a cell above misstates your product, open an issue on&lt;br&gt;
&lt;a href="https://github.com/kOlapsis/govuekit/issues" rel="noopener noreferrer"&gt;github.com/kOlapsis/govuekit&lt;/a&gt;&lt;br&gt;
and it will be corrected with attribution.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/govuekit-vs-supastarter-shipfast-saas-pegasus" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>nextjs</category>
      <category>django</category>
      <category>go</category>
    </item>
    <item>
      <title>Multi-tenant SaaS in Go with chi and sqlc: the whole tenancy layer</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Tue, 22 Sep 2026 07:04:03 +0000</pubDate>
      <link>https://dev.to/benjy33000/multi-tenant-saas-in-go-with-chi-and-sqlc-the-whole-tenancy-layer-45i2</link>
      <guid>https://dev.to/benjy33000/multi-tenant-saas-in-go-with-chi-and-sqlc-the-whole-tenancy-layer-45i2</guid>
      <description>&lt;p&gt;Row-level multi-tenancy is the least glamorous security boundary in a B2B&lt;br&gt;
SaaS and the one most often broken by a Tuesday afternoon commit: a new&lt;br&gt;
query, a missing &lt;code&gt;WHERE organization_id = $1&lt;/code&gt;, and one customer reads&lt;br&gt;
another's data. &lt;a href="https://govuekit.dev/blog/row-level-multi-tenancy-go" rel="noopener noreferrer"&gt;Row-level multi-tenancy in Go&lt;/a&gt;&lt;br&gt;
explained the design; this is the layer itself, as it ships in GoVueKit,&lt;br&gt;
file by file.&lt;/p&gt;
&lt;h2&gt;
  
  
  The model
&lt;/h2&gt;

&lt;p&gt;Three tables carry tenancy: &lt;code&gt;organizations&lt;/code&gt;, &lt;code&gt;org_members&lt;/code&gt; (organization,&lt;br&gt;
user, role) and every business table, which carries &lt;code&gt;organization_id&lt;/code&gt;.&lt;br&gt;
Identifiers are TEXT, generated in Go (UUIDs), so the schema is identical&lt;br&gt;
on PostgreSQL and SQLite and nothing depends on a sequence.&lt;/p&gt;

&lt;p&gt;Roles are three strings with an order: &lt;code&gt;owner &amp;gt; admin &amp;gt; member&lt;/code&gt;. There is&lt;br&gt;
no permission matrix, because a boilerplate that ships one ships a&lt;br&gt;
maintenance burden; a product that needs one adds it next to&lt;br&gt;
&lt;code&gt;RoleAtLeast&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The query that decides everything
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- name: GetOrgMember :one&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;org_members&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;sqlc turns it into a typed Go method. Every request that names an&lt;br&gt;
organization goes through it, once, before any handler runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  The middleware
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Org&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;UserFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"authentication required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;orgID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URLParam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"orgID"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrgMember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrgMemberParams&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;OrganizationID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orgID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNoRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org middleware: load membership"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"internal error"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrganization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;orgID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;orgKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrgCtx&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Org&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeHTTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Three things to notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;404, not 403, for a non-member.&lt;/strong&gt; A 403 says "this exists and you may&lt;br&gt;
not see it", which is exactly the enumeration oracle an attacker wants&lt;br&gt;
when guessing organization ids. To an outsider, the organization does not&lt;br&gt;
exist. The same status is returned whether the id is wrong or the&lt;br&gt;
membership is missing, so the two cases are indistinguishable from&lt;br&gt;
outside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The membership row is the tenant context.&lt;/strong&gt; The handler downstream&lt;br&gt;
never re-derives who the user is or what they may do; it reads&lt;br&gt;
&lt;code&gt;OrgFrom(ctx)&lt;/code&gt; and gets the organization and the role. There is one place&lt;br&gt;
to get tenancy wrong, and it is thirty lines long.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors are boring on purpose.&lt;/strong&gt; A database failure is a 500 with a log&lt;br&gt;
line, never a 404 that would make an outage look like a permission&lt;br&gt;
problem.&lt;/p&gt;
&lt;h2&gt;
  
  
  The role check, after the tenant check
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;RequireOrgRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minRole&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;oc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;OrgFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoleAtLeast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minRole&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusForbidden&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"insufficient permissions"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeHTTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Here a 403 is correct: the caller is a member, the organization is not a&lt;br&gt;
secret to them, they simply lack the role. The order matters — membership&lt;br&gt;
first (404), then role (403) — and the router enforces it structurally.&lt;/p&gt;
&lt;h2&gt;
  
  
  The route group
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orgs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;or&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{orgID}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;one&lt;/span&gt; &lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orgMW&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;With&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireOrgRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoleAdmin&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orgsH&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;With&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireOrgRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoleOwner&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orgsH&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;With&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireOrgRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoleOwner&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/members/{userID}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orgsH&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ChangeRole&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;With&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireOrgRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoleAdmin&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/members/{userID}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orgsH&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemoveMember&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;With&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireOrgRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RoleOwner&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/billing/checkout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;billingH&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Checkout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Everything under &lt;code&gt;/api/orgs/{orgID}&lt;/code&gt; inherits &lt;code&gt;orgMW.Context&lt;/code&gt;. A new&lt;br&gt;
tenant-scoped endpoint cannot be mounted outside the group by accident,&lt;br&gt;
because the group is where &lt;code&gt;{orgID}&lt;/code&gt; is defined. The role requirement sits&lt;br&gt;
on the route line, next to the verb, where a reviewer sees it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The convention for business tables
&lt;/h2&gt;

&lt;p&gt;When you add your first resource — the &lt;a href="https://docs.govuekit.dev/guide/tour" rel="noopener noreferrer"&gt;ten-minute tour&lt;/a&gt;&lt;br&gt;
walks a &lt;code&gt;projects&lt;/code&gt; table through every layer — the queries take this&lt;br&gt;
shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- name: GetProject :one&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- name: ListProjects :many&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler passes &lt;code&gt;OrgFrom(ctx).Org.ID&lt;/code&gt; as the first argument, always.&lt;br&gt;
Because sqlc generates a typed parameter struct per query, a query that&lt;br&gt;
lacks the tenant column also lacks the parameter, which is visible in the&lt;br&gt;
generated signature and in review. It is not a guarantee; it is a&lt;br&gt;
convention that is hard to violate silently, which is what a small&lt;br&gt;
codebase can afford.&lt;/p&gt;

&lt;p&gt;Lists of organizations themselves go through membership too: &lt;code&gt;GET&lt;br&gt;
/api/orgs&lt;/code&gt; returns the organizations the user belongs to, nothing else,&lt;br&gt;
so an outsider does not even learn that an id exists.&lt;/p&gt;
&lt;h2&gt;
  
  
  The test that keeps it true
&lt;/h2&gt;

&lt;p&gt;Unit tests on the middleware are cheap and exist. The one that matters&lt;br&gt;
runs against the production binary in Playwright, with three real&lt;br&gt;
browsers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RBAC: the member hits an admin-only route → 403, server-side.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;patch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;member&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/orgs/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hacked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-CSRF-Token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;csrf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;member&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Cross-tenant isolation: an unrelated user gets 404 on the org, and&lt;/span&gt;
&lt;span class="c1"&gt;// never sees it in their list.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;probe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stranger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/orgs/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stranger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/orgs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An owner creates the organization and invites a member; the member&lt;br&gt;
accepts from the email and gets a 403 on an admin action; a stranger gets&lt;br&gt;
a 404 and an empty list. Remove the &lt;code&gt;WHERE&lt;/code&gt; clause, or mount a route&lt;br&gt;
outside the group, and CI goes red on both database engines.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this does not cover, and why
&lt;/h2&gt;

&lt;p&gt;No PostgreSQL row-level security policies: they would make the SQLite&lt;br&gt;
target a second-class citizen and hide the boundary in the database, where&lt;br&gt;
a Go reviewer does not look. No per-tenant schemas or databases: at the&lt;br&gt;
scale a boilerplate targets, one schema with an indexed &lt;code&gt;organization_id&lt;/code&gt;&lt;br&gt;
is faster to operate and to back up. The 404-for-outsiders rule itself is&lt;br&gt;
recorded in the kit's &lt;code&gt;DECISIONS.md&lt;/code&gt;, which is public.&lt;/p&gt;

&lt;p&gt;See it run, three browsers included, in the labs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLO&lt;/span&gt; https://govuekit.dev/labs/docker-compose.yml
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/multi-tenant-saas-golang-chi-sqlc" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>saas</category>
      <category>security</category>
      <category>backend</category>
    </item>
    <item>
      <title>Stripe webhooks in Go: the complete idempotent handler, line by line</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:12:31 +0000</pubDate>
      <link>https://dev.to/benjy33000/stripe-webhooks-in-go-the-complete-idempotent-handler-line-by-line-3ggj</link>
      <guid>https://dev.to/benjy33000/stripe-webhooks-in-go-the-complete-idempotent-handler-line-by-line-3ggj</guid>
      <description>&lt;p&gt;Every Stripe tutorial in Go shows the same forty lines: read the body, call&lt;br&gt;
&lt;code&gt;ConstructEvent&lt;/code&gt;, switch on the type, update the database. Those forty lines&lt;br&gt;
double-charge on a retry, lose a payment on a restart, trust a payload an&lt;br&gt;
attacker can shape, and time out under a burst. This is the handler&lt;br&gt;
GoVueKit ships instead — the actual code, then each design choice and the&lt;br&gt;
failure it closes. It is the pattern we described in&lt;br&gt;
&lt;a href="https://govuekit.dev/blog/idempotent-billing-webhooks" rel="noopener noreferrer"&gt;Idempotent billing webhooks&lt;/a&gt;; this&lt;br&gt;
article is the implementation.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape: receive fast, process later, once
&lt;/h2&gt;

&lt;p&gt;Two moving parts and one table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event_type&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;object_id&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attempts&lt;/span&gt;     &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;received_at&lt;/span&gt;  &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;processed_events_status_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Portable SQL on purpose: TEXT ids, TIMESTAMP, no engine-specific type, so&lt;br&gt;
the same migration runs on PostgreSQL and SQLite.&lt;/p&gt;
&lt;h2&gt;
  
  
  Part one: the HTTP handler
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Billing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requireEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LimitReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cannot read body"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Svc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VerifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"webhook signature rejected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"invalid signature"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Svc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"webhook enqueue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"internal error"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"webhook duplicate ignored"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusOK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"received"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Five decisions in twenty lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;requireEnabled&lt;/code&gt;.&lt;/strong&gt; With no &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt; the route answers 501,&lt;br&gt;
not a panic. Most deployments start without billing; the unconfigured path&lt;br&gt;
is a first-class, tested state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;io.LimitReader(r.Body, 1&amp;lt;&amp;lt;20)&lt;/code&gt;.&lt;/strong&gt; Signature verification hashes the&lt;br&gt;
body. Without a cap, a 2 GB POST is a free CPU bill. One mebibyte is an&lt;br&gt;
order of magnitude above any Stripe event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signature over the raw bytes.&lt;/strong&gt; &lt;code&gt;VerifyWebhook&lt;/code&gt; hands the untouched body&lt;br&gt;
to Stripe's &lt;code&gt;webhook.ConstructEventWithOptions&lt;/code&gt; with the endpoint secret;&lt;br&gt;
the library checks the HMAC and rejects timestamps older than five&lt;br&gt;
minutes. Decode the JSON first and re-serialize it and the signature will&lt;br&gt;
never match — a classic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConstructEventWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Stripe-Signature"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WebhookSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConstructEventOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;IgnoreAPIVersionMismatch&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IgnoreAPIVersionMismatch&lt;/code&gt; is deliberate: the dashboard lets you pick any&lt;br&gt;
release train for the endpoint, and nothing in the payload is read beyond&lt;br&gt;
identifiers, so the envelope version is irrelevant. Refusing it would turn&lt;br&gt;
a dashboard setting into a silent outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Enqueue&lt;/code&gt; and the primary key.&lt;/strong&gt; The handler writes one row and nothing&lt;br&gt;
else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- name: InsertEvent :execrows&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;:execrows&lt;/code&gt; returns the number of rows inserted. Zero means Stripe&lt;br&gt;
delivered this event before; the handler logs it and still answers 200,&lt;br&gt;
because a duplicate is not an error, it is the retry policy working.&lt;br&gt;
Dedup is the database's job, not a &lt;code&gt;SELECT&lt;/code&gt; followed by an &lt;code&gt;INSERT&lt;/code&gt; that&lt;br&gt;
two concurrent deliveries can both pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;200 in a millisecond.&lt;/strong&gt; No Stripe API call, no email, no business write&lt;br&gt;
in the request. Stripe retries anything slower than its timeout, and a&lt;br&gt;
burst of a hundred events during an outage recovery must not take a hundred&lt;br&gt;
round-trips to survive.&lt;/p&gt;
&lt;h2&gt;
  
  
  Part two: the worker
&lt;/h2&gt;

&lt;p&gt;A goroutine runs &lt;code&gt;ProcessPending&lt;/code&gt; on an interval. It lists pending events&lt;br&gt;
(oldest first, at most 20, fewer than 8 attempts) and hands each to&lt;br&gt;
&lt;code&gt;processEvent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;processEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Provider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EventID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ObjectID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ObjectID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrUnknownEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Change&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="c"&gt;// acknowledged, nothing to write&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Buyer&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payment from unknown customer %s (%s): no organization to credit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Buyer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Buyer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BeginTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"begin: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}()&lt;/span&gt;
    &lt;span class="n"&gt;qtx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subscription&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;qtx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdateSubscriptionState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"apply subscription: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Purchase&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;qtx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InsertPurchase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Purchase&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"apply purchase: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RefundedPaymentRef&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;qtx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MarkPurchaseRefunded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MarkPurchaseRefundedParams&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;RefundedAt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NullTime&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;Valid&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;PaymentRef&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RefundedPaymentRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"apply refund: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;qtx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MarkEventDone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sqlcgen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MarkEventDoneParams&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;EventID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EventID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProcessedAt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NullTime&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;Valid&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mark done: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentFailedCustomer&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notifyPaymentFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentFailedCustomer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DisputedCharge&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"billing: chargeback opened"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"charge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DisputedCharge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;Resolve&lt;/code&gt; re-fetches by ID.&lt;/strong&gt; The provider takes the event's object id&lt;br&gt;
and asks Stripe for the current subscription or checkout session. The&lt;br&gt;
payload is never the source of truth: it may be stale (two events about the&lt;br&gt;
same subscription arrive out of order), and it is attacker-shaped input by&lt;br&gt;
definition even when the signature is valid. What comes back is a small&lt;br&gt;
&lt;code&gt;Change&lt;/code&gt; struct — subscription state, a purchase, a refund reference, a&lt;br&gt;
failed-payment customer, a disputed charge — translated from Stripe's&lt;br&gt;
vocabulary into ours. That translation is the &lt;code&gt;billing.Provider&lt;/code&gt; interface,&lt;br&gt;
which is also the seam for a second processor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business write and &lt;code&gt;MarkEventDone&lt;/code&gt; in one transaction.&lt;/strong&gt; Either both&lt;br&gt;
land or neither does. A crash between "wrote the purchase" and "marked the&lt;br&gt;
event done" would otherwise replay the purchase on restart; here the replay&lt;br&gt;
finds the event still pending, re-fetches, and the &lt;code&gt;INSERT … ON CONFLICT&lt;br&gt;
(checkout_id) DO NOTHING&lt;/code&gt; on purchases makes the second application a&lt;br&gt;
no-op anyway. Two layers of idempotency, each cheap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unknown customer stays pending.&lt;/strong&gt; A payment from a customer this&lt;br&gt;
database has never seen has nowhere to land. Dropping it would be silent&lt;br&gt;
data loss about money; the event stays visible with its attempts count&lt;br&gt;
rising until a human decides. After eight attempts the worker stops&lt;br&gt;
retrying and the row remains for inspection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero rows on refund is fine.&lt;/strong&gt; A refund can reference a payment this&lt;br&gt;
deployment never recorded (a test-mode leftover, a migration). The event&lt;br&gt;
must complete rather than retry forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Side effects after the commit.&lt;/strong&gt; The failed-payment email to the&lt;br&gt;
organization's owners goes out only once the state is durable; an email is&lt;br&gt;
not worth losing a fulfilled payment. A chargeback logs at error level on&lt;br&gt;
purpose, because that reaches the configured error reporter, and a dispute&lt;br&gt;
ignored until the deadline is lost money.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two events every subscription integration forgets
&lt;/h2&gt;

&lt;p&gt;Subscribe the endpoint to &lt;code&gt;checkout.session.async_payment_succeeded&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;checkout.session.async_payment_failed&lt;/code&gt; next to&lt;br&gt;
&lt;code&gt;checkout.session.completed&lt;/code&gt;. A card completes &lt;code&gt;paid&lt;/code&gt;; a deferred method&lt;br&gt;
(SEPA debit, which Stripe enables readily on euro prices) completes&lt;br&gt;
&lt;code&gt;unpaid&lt;/code&gt; and settles days later as the second event. Listen to the first&lt;br&gt;
alone and every SEPA sale is lost in silence.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the tests cover
&lt;/h2&gt;

&lt;p&gt;The suite drives the service with a Stripe double and asserts the failure&lt;br&gt;
modes above, on PostgreSQL and SQLite in CI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a duplicate delivery is a no-op: the second &lt;code&gt;Enqueue&lt;/code&gt; inserts nothing and
the object is fetched once;&lt;/li&gt;
&lt;li&gt;a replayed fulfilment leaves exactly one purchase;&lt;/li&gt;
&lt;li&gt;a deferred payment (SEPA) is not fulfilled until it settles, and its
failure emails the organization's owners;&lt;/li&gt;
&lt;li&gt;a payment from an unknown customer fails the event and leaves it
pending, visible;&lt;/li&gt;
&lt;li&gt;a refund marks the purchase once and its replay is acknowledged; a refund
of a payment this database never saw, a dispute and an unknown event type
are acknowledged without a write;&lt;/li&gt;
&lt;li&gt;with no Stripe key the billing routes answer 501, because the
unconfigured deployment is the one most people run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Locally, &lt;code&gt;stripe listen --forward-to localhost:8080/api/billing/webhook&lt;/code&gt;&lt;br&gt;
against the labs stack replays real events against the real binary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLO&lt;/span&gt; https://govuekit.dev/labs/docker-compose.yml
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The labs runs stripe-mock, which answers the API but emits no webhook; put&lt;br&gt;
real test keys in a &lt;code&gt;.env&lt;/code&gt; next to the compose file and the path above&lt;br&gt;
runs end to end.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/stripe-webhooks-golang-idempotent-handler" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>go</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
    <item>
      <title>Vibe coding on a boilerplate: stop your agent from rebuilding what it already has</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Tue, 15 Sep 2026 07:13:31 +0000</pubDate>
      <link>https://dev.to/benjy33000/vibe-coding-on-a-boilerplate-stop-your-agent-from-rebuilding-what-it-already-has-4g85</link>
      <guid>https://dev.to/benjy33000/vibe-coding-on-a-boilerplate-stop-your-agent-from-rebuilding-what-it-already-has-4g85</guid>
      <description>&lt;p&gt;Point a coding agent at a SaaS boilerplate and ask for "premium plans with a&lt;br&gt;
14-day trial". If the agent does not know that &lt;code&gt;internal/billing&lt;/code&gt; exists,&lt;br&gt;
what you get back is impressive, plausible — and wrong: a second Stripe&lt;br&gt;
client, a second webhook route, a &lt;code&gt;plans&lt;/code&gt; table that duplicates one already&lt;br&gt;
served by configuration. Everything compiles. Half of it is untested. You&lt;br&gt;
now own two billing systems.&lt;/p&gt;

&lt;p&gt;This is not a hallucination problem in the usual sense. The agent did not&lt;br&gt;
invent an API; it invented &lt;em&gt;work&lt;/em&gt;. And it invented it because nothing told&lt;br&gt;
it the work was already done.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why prompts do not fix it
&lt;/h2&gt;

&lt;p&gt;The reflex is to prompt harder: "use the existing billing code". It helps&lt;br&gt;
once. It does not survive the tenth session, the second agent, or the&lt;br&gt;
teammate who forgot the incantation. Instructions that live in chat&lt;br&gt;
history die with the chat.&lt;/p&gt;

&lt;p&gt;What survives is a file in the repository that every agent reads before&lt;br&gt;
writing — if, and only if, that file cannot rot.&lt;/p&gt;
&lt;h2&gt;
  
  
  The map
&lt;/h2&gt;

&lt;p&gt;GoVueKit ships &lt;code&gt;AGENTS.md&lt;/code&gt; at the repository root — the file Codex, Cursor&lt;br&gt;
and their kind read natively; &lt;code&gt;CLAUDE.md&lt;/code&gt; imports it, so Claude Code reads&lt;br&gt;
the same one. It is not prose about being careful. It is a table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;capability&lt;/th&gt;
&lt;th&gt;package&lt;/th&gt;
&lt;th&gt;extend through&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;payments&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal/billing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;billing.Provider&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;email&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal/email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;email.Mailer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;files&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal/storage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;storage.Storage&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;background jobs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal/jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Queue.Register&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;signup side effects&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal/auth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OnUserCreated&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;roles &amp;amp; tenancy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal/http&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RequireOrgRole&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;with one instruction above it: before writing anything, find it here.&lt;br&gt;
"Premium plans" now resolves to &lt;em&gt;configuration plus the existing provider&lt;br&gt;
seam&lt;/em&gt;, because the map says so in the place the agent looks.&lt;/p&gt;

&lt;p&gt;The map also carries the rules the codebase enforces (portable SQL,&lt;br&gt;
&lt;code&gt;organization_id&lt;/code&gt; on every business query, webhooks idempotent, French and&lt;br&gt;
English in step) and the chain a new feature follows — migration, queries,&lt;br&gt;
domain package, handler, route, store, view, tests. An agent that knows&lt;br&gt;
the chain produces a diff shaped like the codebase instead of a framework&lt;br&gt;
of its own.&lt;/p&gt;
&lt;h2&gt;
  
  
  A map that cannot rot
&lt;/h2&gt;

&lt;p&gt;Documentation drifts; that is why agents ignore it. GoVueKit's map is held&lt;br&gt;
by a test, &lt;code&gt;cmd/server/agents_test.go&lt;/code&gt;, and the suite goes red when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an &lt;code&gt;internal/&lt;/code&gt; package exists that AGENTS.md does not mention — you
added code, you map it, or CI fails;&lt;/li&gt;
&lt;li&gt;AGENTS.md references a path that no longer exists;&lt;/li&gt;
&lt;li&gt;a named seam is renamed — the seams are compile-time references, so
&lt;code&gt;billing.Provider&lt;/code&gt; cannot silently become something else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The consequence is worth stating plainly: &lt;strong&gt;if &lt;code&gt;make test&lt;/code&gt; is green, what&lt;br&gt;
the map says is true.&lt;/strong&gt; That is a property most documentation cannot claim,&lt;br&gt;
and it is the property that makes an agent's trust in the file rational.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rest of the harness
&lt;/h2&gt;

&lt;p&gt;The map narrows what the agent writes; the suites judge it. 186 Go tests&lt;br&gt;
run on both PostgreSQL and SQLite, 48 frontend tests include a check that&lt;br&gt;
the French and English catalogues define exactly the same keys, and 14&lt;br&gt;
Playwright journeys drive the real production binary — including the&lt;br&gt;
cross-tenant request that must 404. An agent's diff that leaks tenant data&lt;br&gt;
or forgets a translation fails before you read it.&lt;/p&gt;

&lt;p&gt;For the final look, the labs boots the whole product locally — SSO,&lt;br&gt;
uploads, checkout, email — from one file, with nothing to configure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLO&lt;/span&gt; https://govuekit.dev/labs/docker-compose.yml
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;p&gt;Three concessions, because a harness is not magic.&lt;/p&gt;

&lt;p&gt;First, the map keeps an agent from rebuilding the foundation; it does not&lt;br&gt;
make your feature idea good. An agent will faithfully implement a workflow&lt;br&gt;
your users never asked for, tests green all the way.&lt;/p&gt;

&lt;p&gt;Second, an agent can still write a &lt;em&gt;worse&lt;/em&gt; version of something inside the&lt;br&gt;
right file. The seams tell it where; they do not guarantee elegance.&lt;br&gt;
Review stays your job — the harness only shrinks what needs reviewing.&lt;/p&gt;

&lt;p&gt;Third, this works partly because the kit is small: 19 Go packages, a&lt;br&gt;
schema in one migration file, an architecture that walks in ten minutes.&lt;br&gt;
A map over a sprawling framework would be a book, and agents do not read&lt;br&gt;
books. If you evaluate another starter for agent work, ask how much of it&lt;br&gt;
fits in a context window — and whether anything fails the build when its&lt;br&gt;
documentation lies.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/vibe-coding-saas-boilerplate" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>go</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Vue 3 SaaS starter kits in 2026: Nuxt kits, Vue on another backend, and one Go binary</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Thu, 10 Sep 2026 07:07:36 +0000</pubDate>
      <link>https://dev.to/benjy33000/vue-3-saas-starter-kits-in-2026-nuxt-kits-vue-on-another-backend-and-one-go-binary-1h26</link>
      <guid>https://dev.to/benjy33000/vue-3-saas-starter-kits-in-2026-nuxt-kits-vue-on-another-backend-and-one-go-binary-1h26</guid>
      <description>&lt;p&gt;Every "best SaaS boilerplate" list is a Next.js list with a Vue footnote. If&lt;br&gt;
you want Vue, you are choosing between three families that are not really&lt;br&gt;
comparable: &lt;strong&gt;Nuxt full-stack kits&lt;/strong&gt; (JavaScript end to end), &lt;strong&gt;Vue on a&lt;br&gt;
non-JavaScript backend&lt;/strong&gt; (Laravel, .NET, Go), and &lt;strong&gt;UI templates&lt;/strong&gt; that look&lt;br&gt;
like kits and are not. We sell a kit in the second family (GoVueKit), so it&lt;br&gt;
is in the table under the same questions as everyone else, with links to&lt;br&gt;
each project's own page.&lt;/p&gt;
&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kit&lt;/th&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;Backend&lt;/th&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Teams&lt;/th&gt;
&lt;th&gt;Billing&lt;/th&gt;
&lt;th&gt;i18n&lt;/th&gt;
&lt;th&gt;Licence / price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://supastarter.dev/nuxt" rel="noopener noreferrer"&gt;supastarter for Nuxt&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Nuxt full-stack&lt;/td&gt;
&lt;td&gt;Nuxt 3 server + Hono API&lt;/td&gt;
&lt;td&gt;Prisma or Drizzle (PostgreSQL…)&lt;/td&gt;
&lt;td&gt;yes, orgs + seat billing&lt;/td&gt;
&lt;td&gt;Stripe, Lemon Squeezy, Polar, Creem, Dodo&lt;/td&gt;
&lt;td&gt;yes, incl. emails&lt;/td&gt;
&lt;td&gt;commercial, from €299&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://nuxsaas.com" rel="noopener noreferrer"&gt;NuxSaaS&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Nuxt full-stack&lt;/td&gt;
&lt;td&gt;Nuxt (v4-ready)&lt;/td&gt;
&lt;td&gt;PostgreSQL + Drizzle&lt;/td&gt;
&lt;td&gt;see repo&lt;/td&gt;
&lt;td&gt;Stripe / Polar&lt;/td&gt;
&lt;td&gt;see repo&lt;/td&gt;
&lt;td&gt;open source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/JavascriptMick/supanuxt-saas" rel="noopener noreferrer"&gt;SupaNuxt SaaS&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Nuxt + BaaS&lt;/td&gt;
&lt;td&gt;Nuxt 3 + Supabase + tRPC&lt;/td&gt;
&lt;td&gt;Supabase (Prisma)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;open source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/nuxt-ui-templates/saas" rel="noopener noreferrer"&gt;Nuxt UI SaaS template&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;UI template&lt;/td&gt;
&lt;td&gt;none (pages only)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;free (Nuxt UI + Nuxt Content)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel + Inertia + Vue kits (e.g. &lt;a href="https://larafast.com" rel="noopener noreferrer"&gt;Larafast&lt;/a&gt; VILT)&lt;/td&gt;
&lt;td&gt;Vue on PHP&lt;/td&gt;
&lt;td&gt;Laravel&lt;/td&gt;
&lt;td&gt;MySQL / PostgreSQL&lt;/td&gt;
&lt;td&gt;varies&lt;/td&gt;
&lt;td&gt;Stripe / Lemon Squeezy via Cashier&lt;/td&gt;
&lt;td&gt;varies&lt;/td&gt;
&lt;td&gt;commercial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.brickstarter.net/" rel="noopener noreferrer"&gt;Brick Starter&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Vue on .NET&lt;/td&gt;
&lt;td&gt;ASP.NET Core&lt;/td&gt;
&lt;td&gt;SQL Server / PostgreSQL&lt;/td&gt;
&lt;td&gt;yes, multi-tenant&lt;/td&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;see site&lt;/td&gt;
&lt;td&gt;commercial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://govuekit.dev" rel="noopener noreferrer"&gt;GoVueKit&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Vue on Go&lt;/td&gt;
&lt;td&gt;Go (chi, sqlc)&lt;/td&gt;
&lt;td&gt;PostgreSQL &lt;strong&gt;and&lt;/strong&gt; SQLite&lt;/td&gt;
&lt;td&gt;yes, RBAC + row-level&lt;/td&gt;
&lt;td&gt;Stripe: subscriptions + one-time, idempotent webhooks&lt;/td&gt;
&lt;td&gt;yes, FR/EN UI + emails&lt;/td&gt;
&lt;td&gt;commercial, €199 one-time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cells report what each project advertises on its own pages at the time of&lt;br&gt;
writing; where a page did not say, the cell says so.&lt;/p&gt;
&lt;h2&gt;
  
  
  Family one: Nuxt full-stack kits
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;supastarter&lt;/strong&gt; is the reference here and priced like it: Nuxt 3, better-auth&lt;br&gt;
(passwords, passkeys, magic links, OAuth, 2FA), Prisma or Drizzle, five&lt;br&gt;
payment providers, organizations with seat-based billing,&lt;br&gt;
internationalization down to the emails, an admin UI, S3 storage,&lt;br&gt;
background jobs through trigger.dev or QStash, Playwright tests, and&lt;br&gt;
serverless-friendly deployment. If your team is TypeScript end to end and&lt;br&gt;
you deploy on Vercel or a Node host, this is the most complete option in&lt;br&gt;
the Vue world. What you are buying is also what you are committing to: the&lt;br&gt;
Nuxt server, an external job runner, and a JavaScript runtime in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NuxSaaS&lt;/strong&gt; is the free alternative that has kept up: Nuxt with Better Auth,&lt;br&gt;
PostgreSQL through Drizzle, Stripe or Polar. It is the right first look if&lt;br&gt;
budget is the constraint; check organizations and admin against your needs&lt;br&gt;
before you assume they are there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SupaNuxt SaaS&lt;/strong&gt; predates both and shows the 2023 shape of the market:&lt;br&gt;
Supabase for auth and data, tRPC, Prisma, teams and plans, an OpenAI helper.&lt;br&gt;
Fine to learn from; the Supabase dependency is a decision you make on day&lt;br&gt;
one and unmake with a rewrite.&lt;/p&gt;
&lt;h2&gt;
  
  
  Family two: Vue on a backend that is not JavaScript
&lt;/h2&gt;

&lt;p&gt;This is the family for teams whose backend language is already chosen. The&lt;br&gt;
Vue app is a real SPA with its own build; the backend is whatever your team&lt;br&gt;
is good at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel + Inertia + Vue&lt;/strong&gt; is the largest branch. Inertia lets Laravel&lt;br&gt;
controllers render Vue pages without an API layer; Cashier handles Stripe.&lt;br&gt;
Several commercial kits ship a VILT flavour (Larafast is the best-known),&lt;br&gt;
and the Laravel ecosystem gives you queues, mail and Nova/Filament for&lt;br&gt;
admin. You get PHP hosting economics and a huge hiring pool; you run a PHP&lt;br&gt;
runtime, a Node build and, usually, Redis and a queue worker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;.NET + Vue&lt;/strong&gt; exists for shops that live in C#. Brick Starter ships one&lt;br&gt;
ASP.NET Core backend with multi-tenancy and Stripe, and a choice of&lt;br&gt;
frontends including Vue; evaluate it as you would any .NET template, with&lt;br&gt;
the licence terms in front of you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go + Vue&lt;/strong&gt; is the smallest branch, and it is where GoVueKit sits. The&lt;br&gt;
pitch is operational: &lt;code&gt;vite build&lt;/code&gt; output is embedded in the Go binary with&lt;br&gt;
&lt;code&gt;go:embed&lt;/code&gt;, the public pages are prerendered to static HTML at build time&lt;br&gt;
(canonical, hreflang, sitemap, no SSR server), and the result is &lt;strong&gt;one&lt;br&gt;
~27 MB static file&lt;/strong&gt; that runs on any VPS with no Node, no PHP, no runtime&lt;br&gt;
to patch. Inside: Vue 3 with TypeScript and the Composition API, Pinia, Vue&lt;br&gt;
Router, Tailwind, dark mode, FR/EN with a test that fails when the two&lt;br&gt;
catalogues drift, and a typed fetch client over a Go API built with chi and&lt;br&gt;
sqlc. The SaaS parts are the ones the Nuxt kits have — accounts with TOTP&lt;br&gt;
and OIDC, organizations with row-level tenancy, Stripe with idempotent&lt;br&gt;
webhooks, a durable email queue, an admin backoffice — with the difference&lt;br&gt;
that the whole thing is ~11 000 lines of Go you can read in an afternoon&lt;br&gt;
and runs on PostgreSQL &lt;em&gt;or&lt;/em&gt; SQLite from the same generated queries.&lt;/p&gt;
&lt;h2&gt;
  
  
  Family three: templates that are not kits
&lt;/h2&gt;

&lt;p&gt;The Nuxt UI SaaS template, the Vuexy and Vuestic admin themes, the&lt;br&gt;
Tailwind UI application shells: beautiful pages with no backend, no auth&lt;br&gt;
model, no billing, no tenancy. They are a &lt;em&gt;design&lt;/em&gt; purchase. Buying one and&lt;br&gt;
believing you bought a SaaS foundation is the most common mistake on the&lt;br&gt;
Vue side of this market, because the screenshots look identical to the real&lt;br&gt;
thing.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do you want JavaScript in production?&lt;/strong&gt; If yes, Nuxt kits are the natural&lt;br&gt;
home and supastarter is the safe choice. If no — because your backend team&lt;br&gt;
writes Go, PHP or C#, or because you want a single deployable artefact — the&lt;br&gt;
second family is the only one that fits, and the comparison becomes a&lt;br&gt;
backend comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where will it run?&lt;/strong&gt; Serverless and Vercel favour Nuxt. One VPS, an&lt;br&gt;
on-prem install at a customer, a European host with a data-residency&lt;br&gt;
clause: favour a binary or a PHP app you fully control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who reads the code?&lt;/strong&gt; Every kit above says "you own the code". Owning&lt;br&gt;
40 000 lines of someone else's TypeScript across a monorepo is a different&lt;br&gt;
ownership from 11 000 lines of Go plus a Vue app you can &lt;code&gt;grep&lt;/code&gt; end to end.&lt;br&gt;
Ask for the count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the licence's restriction clause?&lt;/strong&gt; Seats, client work, and&lt;br&gt;
whether "lifetime" means the product's lifetime or the major version's.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try before you decide
&lt;/h2&gt;

&lt;p&gt;supastarter has a demo and a documentation site. NuxSaaS and SupaNuxt clone&lt;br&gt;
and run. GoVueKit runs its full stack — app, PostgreSQL, single sign-on,&lt;br&gt;
S3, a Stripe double and a mail inbox — from one file, no account, no&lt;br&gt;
licence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLO&lt;/span&gt; https://govuekit.dev/labs/docker-compose.yml
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open the Vue source of whichever kit you liked and look for three&lt;br&gt;
things: how a page gets its data (typed client, tRPC, Inertia props), how a&lt;br&gt;
route decides you are allowed there (server-side, always), and how a&lt;br&gt;
missing translation is caught (a test, or a user).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If a cell above is wrong about your project, open an issue on&lt;br&gt;
&lt;a href="https://github.com/kOlapsis/govuekit/issues" rel="noopener noreferrer"&gt;github.com/kOlapsis/govuekit&lt;/a&gt;&lt;br&gt;
and it will be corrected with attribution.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/vue-saas-starter-kits-2026" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vue</category>
      <category>nuxt</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Go SaaS boilerplates in 2026: the honest list</title>
      <dc:creator>Benjamin Touchard</dc:creator>
      <pubDate>Tue, 08 Sep 2026 07:07:41 +0000</pubDate>
      <link>https://dev.to/benjy33000/go-saas-boilerplates-in-2026-the-honest-list-4eml</link>
      <guid>https://dev.to/benjy33000/go-saas-boilerplates-in-2026-the-honest-list-4eml</guid>
      <description>&lt;p&gt;Search "Go SaaS boilerplate" and you get directory pages that list three&lt;br&gt;
Next.js kits and a Laravel one, because the Go entries do not exist or are&lt;br&gt;
buried. This is the list I wanted when I started: what runs in Go today,&lt;br&gt;
what each kit really includes, and what it commits you to. We sell one of&lt;br&gt;
them (GoVueKit), so it sits in the same table under the same questions, and&lt;br&gt;
the links go to every project's own page so you can check.&lt;/p&gt;
&lt;h2&gt;
  
  
  What counts
&lt;/h2&gt;

&lt;p&gt;A &lt;em&gt;SaaS boilerplate&lt;/em&gt; is not "a Go web starter". A starter gives you a router,&lt;br&gt;
a template layer and a session cookie. A SaaS foundation gives you the five&lt;br&gt;
things every product rewrites and nobody wants to: &lt;strong&gt;accounts&lt;/strong&gt; (with the&lt;br&gt;
unglamorous flows — reset, verification, two-factor, revocation),&lt;br&gt;
&lt;strong&gt;organizations&lt;/strong&gt; (roles and tenant isolation), &lt;strong&gt;billing&lt;/strong&gt; (a webhook&lt;br&gt;
handler you can bill through), &lt;strong&gt;transactional email&lt;/strong&gt; (durable, not&lt;br&gt;
fire-and-forget) and an &lt;strong&gt;admin&lt;/strong&gt; surface. Kits below are judged on those&lt;br&gt;
five, plus the questions that matter in month six: which database, which&lt;br&gt;
licence, how it deploys.&lt;/p&gt;

&lt;p&gt;Excluded on purpose: &lt;a href="https://github.com/Melkeydev/go-blueprint" rel="noopener noreferrer"&gt;go-blueprint&lt;/a&gt;&lt;br&gt;
and &lt;a href="https://github.com/gowebly/gowebly" rel="noopener noreferrer"&gt;gowebly&lt;/a&gt; (generators for a project&lt;br&gt;
skeleton, excellent, not SaaS kits), &lt;a href="https://pocketbase.io" rel="noopener noreferrer"&gt;PocketBase&lt;/a&gt;&lt;br&gt;
(a backend-in-one-file you build a SaaS &lt;em&gt;on&lt;/em&gt;, not a SaaS), and the dozens&lt;br&gt;
of "Go + HTMX + templ starter" repositories that stop at a login form.&lt;/p&gt;
&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kit&lt;/th&gt;
&lt;th&gt;Frontend&lt;/th&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Teams / tenancy&lt;/th&gt;
&lt;th&gt;Billing&lt;/th&gt;
&lt;th&gt;Email&lt;/th&gt;
&lt;th&gt;Admin&lt;/th&gt;
&lt;th&gt;Licence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://goilerplate.com" rel="noopener noreferrer"&gt;goilerplate&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;templ + HTMX (or Datastar), server-rendered&lt;/td&gt;
&lt;td&gt;SQLite or PostgreSQL&lt;/td&gt;
&lt;td&gt;not advertised&lt;/td&gt;
&lt;td&gt;Polar / Stripe subscriptions&lt;/td&gt;
&lt;td&gt;Resend / SMTP&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;free tier + paid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/mikestefanello/pagoda" rel="noopener noreferrer"&gt;Pagoda&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Gomponents + HTMX + Alpine&lt;/td&gt;
&lt;td&gt;SQLite (Ent; Postgres swappable)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;skeleton only&lt;/td&gt;
&lt;td&gt;yes, entity admin&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jonradoff/lastsaas" rel="noopener noreferrer"&gt;LastSaaS&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;React 19 SPA&lt;/td&gt;
&lt;td&gt;MongoDB&lt;/td&gt;
&lt;td&gt;yes, RBAC&lt;/td&gt;
&lt;td&gt;Stripe: subscriptions, per-seat, trials, credits&lt;/td&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://saasboostkit.com" rel="noopener noreferrer"&gt;SaaS Boost Kit&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;SvelteKit&lt;/td&gt;
&lt;td&gt;PocketBase&lt;/td&gt;
&lt;td&gt;see site&lt;/td&gt;
&lt;td&gt;Stripe, guest checkout&lt;/td&gt;
&lt;td&gt;see site&lt;/td&gt;
&lt;td&gt;PocketBase admin&lt;/td&gt;
&lt;td&gt;commercial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://saasstartupkit.com" rel="noopener noreferrer"&gt;Go SaaS Startup Kit&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;server-rendered Go templates&lt;/td&gt;
&lt;td&gt;PostgreSQL (AWS-oriented)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;AWS SES&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;open source, older&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/go-saas/kit" rel="noopener noreferrer"&gt;go-saas/kit&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;React + Ant Design admin&lt;/td&gt;
&lt;td&gt;PostgreSQL / MySQL&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Apache-2.0, microservices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://govuekit.dev" rel="noopener noreferrer"&gt;GoVueKit&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Vue 3 + TypeScript SPA, embedded&lt;/td&gt;
&lt;td&gt;PostgreSQL &lt;strong&gt;and&lt;/strong&gt; SQLite&lt;/td&gt;
&lt;td&gt;yes, RBAC + row-level&lt;/td&gt;
&lt;td&gt;Stripe: subscriptions + one-time, idempotent webhooks, refunds&lt;/td&gt;
&lt;td&gt;FR/EN templates, durable DB queue&lt;/td&gt;
&lt;td&gt;yes, MRR/churn/audit&lt;/td&gt;
&lt;td&gt;commercial, one-time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cells say what each project &lt;em&gt;advertises&lt;/em&gt;. Where a page did not say, the cell&lt;br&gt;
says so rather than guessing; every name links to the source.&lt;/p&gt;
&lt;h2&gt;
  
  
  One paragraph each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;goilerplate&lt;/strong&gt; is the most polished of the server-rendered kits, from the&lt;br&gt;
author of templUI, and the free tier is genuinely usable: Go, templ, SQLite,&lt;br&gt;
SMTP, auth, security headers, tests and CI. The paid tier adds the SaaS&lt;br&gt;
parts (subscriptions through Polar or Stripe, S3 uploads, analytics, legal&lt;br&gt;
pages). If you want &lt;strong&gt;no JavaScript build step&lt;/strong&gt; and are happy with HTMX or&lt;br&gt;
Datastar as your interactivity model, start here. What to check before&lt;br&gt;
paying: how organizations and roles are modelled, because "SaaS" on the&lt;br&gt;
landing mostly means "individual subscriptions".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pagoda&lt;/strong&gt; is a starter kit, and says so: "not a framework". Echo, Ent,&lt;br&gt;
type-safe HTML in Go, HTMX, a task queue with a monitoring UI, an admin&lt;br&gt;
panel generated from your entities, caching, CSRF, sessions. No payments,&lt;br&gt;
and email is a skeleton you finish. It has the largest community of the&lt;br&gt;
list (over 3 000 GitHub stars) and the cleanest code to learn from. Pick it&lt;br&gt;
when you want to &lt;em&gt;build&lt;/em&gt; your SaaS layer yourself on good foundations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LastSaaS&lt;/strong&gt; is the newest and the most feature-dense: multi-tenant RBAC,&lt;br&gt;
Stripe with per-seat pricing and credit bundles, outgoing webhooks, API keys,&lt;br&gt;
white-label theming, an analytics dashboard, even an MCP server for&lt;br&gt;
AI-driven admin — MIT-licensed, deployed as one small container on Fly.io.&lt;br&gt;
Two things to weigh: the database is &lt;strong&gt;MongoDB&lt;/strong&gt; (fine, but it is the one&lt;br&gt;
non-relational choice on this list, and multi-tenant reporting in SQL is a&lt;br&gt;
different life), and the README states it was built entirely through&lt;br&gt;
conversation with Claude Code. That is not a criticism; it means you should&lt;br&gt;
read the tenancy and webhook code before you bill through it, which is true&lt;br&gt;
of every kit here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SaaS Boost Kit&lt;/strong&gt; pairs a Go API with SvelteKit and PocketBase for the&lt;br&gt;
database and admin, with Stripe subscriptions, webhooks and a guest checkout&lt;br&gt;
for one-off sales, unit and Playwright tests, and a configurable landing.&lt;br&gt;
It is commercial (pricing on the site). The PocketBase choice gives you an&lt;br&gt;
admin UI for free and a SQLite file for a database; it also means part of&lt;br&gt;
your backend is a framework you extend by hooks rather than code you own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go SaaS Startup Kit&lt;/strong&gt; is the veteran: a set of libraries and a working&lt;br&gt;
multi-tenant example aimed at AWS (SES, ECS). It is open source and&lt;br&gt;
complete for its era, but check the commit history before committing your&lt;br&gt;
own; it predates most of what the Go ecosystem now takes for granted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;go-saas/kit&lt;/strong&gt; is the outlier: a microservice architecture (Kratos-style)&lt;br&gt;
with tenant management, Stripe plans and a React/Ant Design admin. If you&lt;br&gt;
already run services and need a SaaS control plane, it is the only kit on&lt;br&gt;
the list shaped for that. For a product team of one to five, it is an&lt;br&gt;
architecture to grow into, not start from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GoVueKit&lt;/strong&gt; — ours — is the only one of the list built around a &lt;strong&gt;real SPA&lt;br&gt;
embedded in the binary&lt;/strong&gt;: Vue 3 with TypeScript, Pinia and a typed fetch&lt;br&gt;
client, compiled by Vite and served with &lt;code&gt;go:embed&lt;/code&gt;, prerendered to static&lt;br&gt;
HTML for the public pages so SEO does not need SSR. Go with chi and sqlc on&lt;br&gt;
the back, no ORM. Accounts include argon2id, magic links, OAuth/OIDC and&lt;br&gt;
TOTP; organizations have owner/admin/member roles and row-level tenancy&lt;br&gt;
(non-members get a 404, and a Playwright test proves it cross-tenant);&lt;br&gt;
Stripe covers subscriptions and one-time products with idempotent webhooks,&lt;br&gt;
refunds and chargebacks; email is a durable queue with French and English&lt;br&gt;
templates; the backoffice computes MRR, ARPU and churn from your own tables.&lt;br&gt;
Both PostgreSQL and SQLite are first-class: one migration file, the same&lt;br&gt;
generated queries, both engines in CI. It is commercial (€199 one-time,&lt;br&gt;
perpetual, unlimited projects) and it is a &lt;em&gt;core&lt;/em&gt;: a blog, a code generator&lt;br&gt;
and a second payment provider were built and then removed, and&lt;br&gt;
&lt;a href="https://github.com/kOlapsis/govuekit/blob/main/DECISIONS.md" rel="noopener noreferrer"&gt;DECISIONS.md&lt;/a&gt;&lt;br&gt;
says why.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;By frontend philosophy.&lt;/strong&gt; This is the real fork in the Go world. HTMX and&lt;br&gt;
templ (goilerplate, Pagoda) give you one language, no Node in the build,&lt;br&gt;
and pages that are HTML first; the cost is that anything app-like (an&lt;br&gt;
editor, a dashboard with live filters, offline) gets awkward. An SPA&lt;br&gt;
(LastSaaS with React, GoVueKit with Vue) costs a second toolchain and gives&lt;br&gt;
you a typed contract between front and back and a frontend team can work&lt;br&gt;
in. Nobody is wrong here; be honest about what your product's screens look&lt;br&gt;
like in year two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;By database.&lt;/strong&gt; If you may ever want SQLite in production (single-node&lt;br&gt;
SaaS, on-prem installs, a customer who wants "just a binary and a file"),&lt;br&gt;
only goilerplate and GoVueKit treat it as a target rather than a dev&lt;br&gt;
convenience, and only GoVueKit runs the whole test suite on both. If your&lt;br&gt;
world is PostgreSQL forever, that stops mattering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;By licence.&lt;/strong&gt; MIT (Pagoda, LastSaaS) means you can fork and never pay, and&lt;br&gt;
also that nobody owes you the next security fix. Commercial with source&lt;br&gt;
delivered (goilerplate paid tier, SaaS Boost Kit, GoVueKit) means you pay&lt;br&gt;
once and own a repository, with updates for as long as the vendor lasts.&lt;br&gt;
Read the &lt;em&gt;restrictions&lt;/em&gt; clause: the one that matters is whether client work&lt;br&gt;
is allowed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;By what you must read.&lt;/strong&gt; Count the lines you will own. A kit is not&lt;br&gt;
"more" when it contains more; it is more when you can hold it in your head.&lt;br&gt;
Ask each vendor for the line count and the migration count; the honest ones&lt;br&gt;
will tell you.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try before you decide
&lt;/h2&gt;

&lt;p&gt;The fastest way to compare is to run them. Pagoda and LastSaaS clone and&lt;br&gt;
start in minutes. goilerplate's free tier is a GitHub sign-in away. GoVueKit&lt;br&gt;
runs its &lt;strong&gt;entire&lt;/strong&gt; stack from one file, no account, no licence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLO&lt;/span&gt; https://govuekit.dev/labs/docker-compose.yml
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;      &lt;span class="c"&gt;# app + PostgreSQL + Dex (SSO) + MinIO + stripe-mock + Mailpit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sign in through SSO, create an organization, invite a teammate, read the&lt;br&gt;
invitation in the inbox, open the backoffice. Then read the code of whichever&lt;br&gt;
kit you liked — that is the only review that counts.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Corrections welcome: if a cell above is wrong about your project, open an&lt;br&gt;
issue on &lt;a href="https://github.com/kOlapsis/govuekit/issues" rel="noopener noreferrer"&gt;github.com/kOlapsis/govuekit&lt;/a&gt;&lt;br&gt;
and it will be fixed with attribution.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://govuekit.dev/blog/go-saas-boilerplates-2026" rel="noopener noreferrer"&gt;govuekit.dev&lt;/a&gt;. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://govuekit.dev/labs" rel="noopener noreferrer"&gt;https://govuekit.dev/labs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>saas</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
