Skip to content
← All writing

The product was the easy part

17 min read
.md
Cover illustration for The product was the easy part

TL;DR

Four things have to work before a SaaS can charge anyone, and none of them is the product. Credits, which either expire or do not. Invoicing and sales tax, where Stripe makes you the legal seller. Email, where a message that sells something needs an unsubscribe link. And an admin panel that logs what staff do. Plan all four as features.

The feature worked. I could put a document in, get structured output back, and the whole thing ran on a stack I understood. I thought I was close to launching.

I was not close. What stood between me and a first paying customer was a credit ledger, a tax question I could not answer, an email header I had never heard of, a legal document I was not qualified to write, and an admin panel to fix the things that would go wrong in all four. None of that is product work. All of it has to exist before you can charge anyone.

They also arrived in that order, and not by coincidence. Each one was created by the one before it. Deciding how to charge produced a document. The document raised the question of who owed the tax printed on it. The document and the charge both produced email, which turned out to be regulated. The tax details and the email consent were both personal data I was now collecting, which is what the terms and the privacy policy govern. And every one of the four eventually broke in a way only I could fix, which is the admin panel.

So it starts with the charge, because everything downstream inherits its shape.

What does “a credit” mean, exactly?

I had already decided to bill on tokens rather than messages, for reasons I wrote up in metering tokens when the bill is the product. That decides the unit. It does not decide the accounting, and the accounting is where the product decisions hide.

The decision is what the customer is buying when they buy “100 credits a month”, and that phrase can mean at least four different things. Customers assume whichever one suits them.

ModelRevenue shapeWhat the customer feelsWhat it costs you
Monthly allowance, expiresPredictable, recognised monthlyPunished for a quiet monthSupport tickets about lost balance
Purchased pack, never expiresLumpy, deferredFair, it was paid forAn open-ended liability on your books
Auto-reload at a thresholdSmooth, usage-trackingInvisible until the card statementRunaway spend, disputes, chargebacks
Allowance plus paid packsPredictable base, usage upsideUnderstandableTwo balances to draw down in the right order

I ended up in the last row, which is the awkward one to implement and the only one I could defend in writing. A monthly allowance that expires, plus top-up packs that do not, and a drawdown rule that always spends the expiring balance first. If you spend the non-expiring balance first you have deleted something the customer paid for, and they find out when they go looking for it.

Stripe calls these prepaid credits and models them with billing credits, where a credit grant carries an optional expires_at. Leave it unset and the credits never expire, which is exactly the primitive the two-balance design needs. Two limits matter before you build on it: a customer can hold at most 100 unused credit grants at a time, and once a grant has been applied to an invoice you can expire it but not void it.

Auto-reload is the one I would think hardest about. It is the best experience of the four and the only one whose failure is silent while it is happening: an agent in a retry loop can drain a card at machine speed, and the customer finds out from their bank. If you offer it, cap the number of reloads per period rather than only the amount per reload. A cap on the amount limits one mistake. A cap on the count limits a loop.

Then the charge has to become an invoice

Settling on credits and a drawdown order tells you what to charge. It does not produce the artefact the customer’s accounts department asks for two days later, and that artefact is a separate build.

Taking the money is a Stripe call. Producing a document a finance team will accept is a different job, and it is the one that starts generating email.

Three things I did not expect to matter:

  1. Invoices need to be reissued. A customer will ask for the same invoice with a company name, a registration number or a purchase order number added. Stripe can regenerate a hosted invoice, but only if you kept the customer’s billing details as editable data rather than baking them into the charge.
  2. A refund and a credit note are different events. A refund moves money. A credit note adjusts what was invoiced. Accountants care which one happened, and picking the wrong one produces books that do not reconcile.
  3. Failed payments are a state machine, not an error. Dunning, the part of subscription billing everyone defers, is retries, emails, a grace period and eventually a downgrade. That downgrade path touches your entitlement code, so it is product work wearing a billing costume.

What does Malaysia’s e-Invoice require from a SaaS?

Those three assume the invoice is yours to design. In a growing number of countries it is not.

Tax rules are per country, and some of those countries have opinions about the invoice itself rather than only the tax on it. A PDF that satisfies a US customer is not a legal invoice everywhere it lands.

Malaysia is the case I deal with. LHDN’s e-Invoice regime routes invoices through the MyInvois portal for validation before they count, and the rollout is threshold-based: large companies from August 2024, then downward, reaching businesses with revenue between RM1 million and RM5 million on 1 January 2026. An e-Invoice carries 55 data fields, and several of them describe the buyer.

That last part is the bit that reaches into your signup form. You cannot generate a compliant e-Invoice from an email address and a card token, because the buyer’s tax identity is a required field and only the buyer can tell you it.

BuyerWhat you have to collectWhere it comes from
Malaysian companyTIN, business name, SSM registration number, SST number if registered, addressTheir finance team, at checkout
Malaysian sole proprietorNRIC-linked TIN, name, addressThe individual, who may not know their TIN
Individual consumerNothing, if they do not request oneConsolidated submission covers them
Foreign customerNothing under this regimeOut of scope, different rules apply

So the schema gets a nullable tax profile per account, the checkout gets an “invoice details” step you can defer without blocking the sale, and the account settings get a place to fill it in later. The part I underestimated is that this is a collection problem before it is a submission problem: the integration with the portal is finite work, and chasing 40 customers for a TIN they have to go and look up is not.

Whatever country you sell from, ask early whether your invoice is a document you generate or a document a tax authority has to accept. The answer changes the signup form, and the signup form is the most expensive thing to change later.

Who actually owes the tax on it?

Collecting a tax ID and printing a tax line raises the obvious question, and I had the answer wrong for a long time. You owe it.

With Stripe you are the merchant of record: the legal seller. Stripe processes payments. Stripe Tax calculates what to charge and applies the reverse charge when a cross-border business customer supplies a valid tax ID. What it does not do is register you in a jurisdiction, file a return, or absorb the liability if you should have been registered and were not.

Stripe plus Stripe TaxMerchant of record platform
Legal sellerYouThe platform
Tax calculationAutomaticAutomatic
Registration and filingYours, per jurisdictionTheirs
Liability if you get it wrongYoursTheirs
CostProcessing plus a Tax feeA noticeably higher percentage
Control over billing logicCompleteWhatever they expose

The fee gap between the two is what the liability transfer costs. That is the whole trade, and it is a business decision rather than an engineering one. Engineering only inherits it afterwards: if you go the merchant of record route, your entitlement system is now downstream of a webhook you do not control.

Tax ID validation is also not tax ID verification. Stripe validates format synchronously and checks EU VAT, GB VAT and Australian ABN numbers asynchronously against government databases, which means a customer can complete checkout with a number that later comes back invalid. If you granted a reverse charge on that number, the tax was yours to pay. So treat validation as a state on the customer record, not as a checkout gate, and reconcile it later.

By this point the system sends mail for its own reasons: a receipt, a validated invoice, a failed payment, a balance running low. Add the mail you want to send about the product and you have an email system, which is regulated in ways the billing code is not.

The rule is that any message whose primary purpose is commercial needs an unsubscribe link, and the label you attach has nothing to do with which those are.

I assumed there were two clean categories, transactional and marketing, and that the first was exempt. The categories are real. The boundary is not where I thought. Under CAN-SPAM the FTC applies a primary purpose test to the content: if the subject line reads like an advertisement, or the transactional content is buried below promotional content, the message is commercial. Adding “upgrade to Pro” above the reset link in a password reset email reclassifies that email.

The penalty is assessed per message. The FTC’s 2025 penalty adjustment puts the maximum civil penalty at $53,088 per violating email, with no overall cap. A single bad batch is arithmetic you do not want to do.

EmailPrimary purposeUnsubscribe requiredSuppression list applies
Address verificationTransactionalNoNo
Password resetTransactionalNoNo
Receipt or invoiceTransactionalNoNo
Payment failed, action neededTransactionalNoNo
Usage at 80% of allowanceArguably eitherTreat as yesYes, its own category
New feature announcementCommercialYesYes
Onboarding drip after signupCommercialYesYes

Two implementation consequences follow from that table.

First, one global emailOptOut boolean is the wrong shape. A user who unsubscribes from feature announcements has not asked to stop receiving invoices, and if your flag suppresses both you have broken billing to satisfy a marketing preference. Preferences belong per category, with the transactional ones shown as locked and a line explaining that they carry your receipts.

Second, the unsubscribe has to be one click. RFC 8058 defines a List-Unsubscribe-Post: List-Unsubscribe=One-Click header alongside List-Unsubscribe, which lets Gmail or Yahoo unsubscribe the recipient by POSTing to your endpoint directly. Both providers have required it since February 2024 for senders above 5,000 messages a day, and the request must be honoured within 48 hours.

That endpoint is unauthenticated by construction. The mail client POSTs on the recipient’s behalf with no session. So the URL carries a signed, single-purpose token scoped to one subscriber and one list, it must be idempotent, and it must not accept a GET that changes state, because scanners and link previewers will fetch it. This is the same signed-payload discipline as webhook signing, pointed the other way.

Region matters too. CAN-SPAM is opt-out: you may email until told to stop. GDPR is opt-in: you need consent before the first marketing message, and the burden of proving you had it is yours. Canada’s CASL is opt-in as well. If you sell to Europe, the consent record is part of the signup flow, not a compliance task for later.

Where the terms of service and privacy policy come in

Count what the previous three sections added to the signup form without ever being about it: a tax identity, a billing address, a marketing consent, and a record of what the customer agreed to pay for. That is personal data, collected for stated purposes, some of it under a legal obligation to keep. The terms of service and the privacy policy are the documents that describe all of it, which is why they arrive here rather than at the start.

I am not a lawyer and this is not legal advice. There is still an engineering part, and it is the part that gets skipped.

The engineering part is the record of assent. Courts enforce clickwrap agreements because there is evidence the user was shown the terms and took an affirmative action; browsewrap, where the terms sit behind a footer link and consent is implied by use, fails far more often for exactly the lack of that evidence. Which means the schema matters:

// Not enough: proves nothing once the document has been revised.
acceptedTerms: boolean

// What you actually need on the user record.
type TermsAcceptance = {
  documentSlug: 'terms-of-service' | 'privacy-policy' | 'dpa'
  version: string      // the exact published revision
  acceptedAt: number
  method: 'signup-checkbox' | 'reacceptance-modal'
  ipAddress: string
}

Version the documents, keep every published revision addressable, and store which one the user clicked. When you materially change the terms you need re-acceptance, and re-acceptance is a migration: an existing user hits a blocking modal on next login, and you need to know who has and has not passed through it.

The privacy policy carries a maintenance obligation. GDPR Article 13 sets out the disclosures a policy has to make, and “we may share data with trusted partners” does not satisfy the transparency requirement. Your subprocessors have to be named. For an AI product that list is long and it moves: the model provider, the hosting platform, the payment processor, the email sender, the error tracker, the analytics tool. Every time you switch model provider you have changed who processes customer data, which means the policy is now wrong until you update it and, under most business data processing agreements, customers are entitled to notice before the change takes effect.

So the subprocessor list belongs in version control next to the code, not in a document someone edited once. Adding a vendor and updating the list should be the same pull request.

How much should the admin panel be able to do?

Less than you will want it to at 2am, and everything it does should leave a record.

This is the section the other four produce. Each of them fails in a way the customer can see and cannot fix, and none of those failures has a self-service path. A payment succeeded and the entitlement did not apply. A customer bought credits on the wrong account. A TIN was entered wrong and the invoice was rejected. Someone unsubscribed and needs their invoices back. Without an admin panel, the answer to each is a database query written by hand at speed, against production, with no audit trail. That is worse than the panel you did not want to build.

ActionIn the panelWhy
Read any account’s state and balanceYesThe first question in every ticket
Grant credits, with a reason fieldYesThe most common fix, and it is money
Change plan or entitlementYesWebhook failures leave this out of sync
Cancel or refundLink to StripeStripe is the source of truth, do not fork it
Resend a transactional emailYesCheap, safe, resolves a lot of tickets
Impersonate a userYes, but logged and time-boxedSometimes the only way to see the bug
Edit usage historyNoIt is the billing record, adjust with a credit
Delete an accountYes, and it must actually deleteThis is a GDPR obligation, not a nice to have

What the panel should own comes down to which system holds the source of truth. Subscription state, invoices and refunds live in Stripe, and the panel should deep link there rather than reimplement them, because two systems that both think they own a subscription will disagree and the customer will find out first. Credit balances, entitlements and email preferences are mine, so the panel owns those outright.

Two things make the difference between a panel that helps and a panel that becomes its own incident. Every mutating action writes an audit entry naming the admin, the target, the before and after value and a required free-text reason. And impersonation is a session that expires and is visibly logged, not a switch that leaves you inside someone’s account without either of you knowing.

Should you use Clerk?

Clerk answers the authentication and organisation half of this well, and if you are building B2B where accounts have teams and roles, that half is bigger than it looks. Clerk Billing also handles the subscription UI and entitlement checks, and it connects to your own Stripe account rather than sitting in front of it, at roughly 0.7% on top of Stripe’s own fees.

Two facts decide it for a lot of products, and neither is about developer experience. Clerk Billing does not yet support tax or VAT, which is the hardest problem in the whole billing section. And it is unavailable in several countries, including Malaysia, Brazil, India, Mexico, Singapore and Thailand, which rules it out for me on billing regardless of how good it is.

My read: use it for authentication and organisations, where the alternative is months of work you will not do better. Be more careful about the billing layer, because plans, credits and entitlements are where your product logic actually lives, and a percentage on top of every transaction to host that logic elsewhere is a decision you should make deliberately rather than by default.

What this actually costs

Everything above is undifferentiated. SaaS billing, tax, consent and support tooling have roughly the same shape in every product, and no customer chose mine because of the credit drawdown order or the unsubscribe header. It is the licence to charge money at all, and it is the reason the gap between “the demo works” and “I can invoice someone” is measured in weeks.

My mistake was reading that chain as a checklist. Four separate items to work through in whatever order, at the end, once the product was done. They are not four items, they are one sequence, and each link narrows the one after it. The credit model constrained the pricing page. The invoice format constrained the signup form. The subprocessor list constrained which model provider I could switch to. The admin panel needed an audit table that should have been in the first migration.

Four of those land in the schema: the credit ledger, the tax profile, the terms acceptance record and the audit log. That is the part worth pulling forward. Design those on day one, where they cost a migration you have not written yet, and the rest can wait until there is someone to charge.

None of this argues for building it all up front. It argues for knowing it exists, and for putting it on the plan as four systems with real cost rather than as a checklist item called “billing”.

Sources

Common questions

Should SaaS credits expire monthly or roll over?

Monthly expiring credits give you predictable revenue and a support queue full of people who lost balance they paid for. Non-expiring packs are easier to defend and turn deferred revenue into a liability that sits on your books indefinitely. Most products end up with both: a monthly allowance that expires, and purchased top-up packs that do not.

Does Stripe handle sales tax and VAT for you?

No. With Stripe you are the merchant of record, which means you are the legal seller and you register, file and remit tax in every jurisdiction where you have an obligation. Stripe Tax calculates and collects, and it will apply the reverse charge when a valid business tax ID is present, but the liability stays with you. A merchant of record platform such as Paddle takes that liability instead, and charges more for it.

Which emails legally need an unsubscribe link?

Any email whose primary purpose is commercial. Under CAN-SPAM the test is content, not the label you put on it, so a password reset with a plan upsell above the reset link is a commercial message. Genuinely transactional mail such as receipts, verification and password resets is exempt. GDPR is stricter in the other direction: it requires opt-in consent before marketing mail at all.

What is RFC 8058 one-click unsubscribe and who has to implement it?

It is a List-Unsubscribe header paired with List-Unsubscribe-Post, which lets a mail client unsubscribe a recipient with a single HTTPS POST instead of sending them to a landing page. Gmail and Yahoo have required it since February 2024 for senders above 5,000 messages a day, and the request must be honoured within 48 hours.

What should you record when a user accepts your terms of service?

The version of the document they accepted, the timestamp, and the affirmative action they took. Courts enforce clickwrap because there is a record of assent and browsewrap usually fails for the lack of one. A boolean acceptedTerms column proves nothing once the document has been revised twice.

Should you use Clerk for authentication and billing in a SaaS?

Clerk is a reasonable choice for authentication, organisations and the subscription UI, and its billing connects to your own Stripe account rather than replacing it, at roughly 0.7% on top of Stripe fees. Two things decide it: Clerk Billing does not yet handle tax or VAT, and it is unavailable in several countries including Malaysia, Brazil, India, Mexico, Singapore and Thailand.

Written by Elson Tan, Head of Technology and co-founder at Nedex Group, working on AI harness and agent infrastructure.

AboutRSS
  • 5 min read

    Metering tokens when the bill is the product

    Billing per message is easy and wrong: one user sends a sentence, another uploads a report. Metering the tokens you actually spend is harder, and the hard parts are idempotency, allowance checks and what to do mid-conversation.

  • 4 min read

    Route cheap models to orchestration, not to the work

    Most agent platforms pick one model and use it everywhere. Splitting model selection by role rather than by task is where the cost curve actually bends.

  • 5 min read

    Crawling a knowledge base without a crawler service

    Rendering every page in a headless browser is the expensive way to import a website. Discovery through robots.txt and sitemaps costs almost nothing, and most pages never need a browser at all.

Get in touch

Tell me who you are and what you are working on.

Your details are used only to reply to this message.