Selling your product with Sawabona¶
You have an application. You want to sell licences for it, collect the money in your own payment account, and have the licence stop working when someone stops paying. This is what that takes.
Every number below is measured against two production integrations rather than estimated.
What you get, and what you still write¶
Sawabona is the licensing engine: it mints keys, validates them, counts machines, and cuts a licence off when a subscription ends. It is the only thing that talks to your payment provider.
Your gateway is a small web service that owns your customers. It knows who they are; the engine deliberately does not.
your marketing site ──► your gateway ──► Sawabona engine ──► Stripe
│ │
your customers licences, keys,
(you own these) device counts
What you write: about 70 lines. Four things, and each is genuinely yours:
| You write | Size | Why nobody can write it for you |
|---|---|---|
provision_tenant |
~40 lines | Only you know how one of your customers comes into existence |
purchasable_plans |
~12 lines | Which of your plans are sold self-serve |
tenant_billing |
~8 lines | Your ORM model and session factory |
build_billing_routers |
~10 lines | One call |
What you get for it: hosted checkout, licence delivered by email, receipts, plan changes applied to the licence, a billing portal your customer opens from your site or from inside your product, a contact form, and the machine-count enforcement that makes a seat cap mean something.
The half-day¶
| Step | Where | Time |
|---|---|---|
| Create your products and prices | Your Stripe dashboard | ~1 h |
| Give the engine your Stripe key | One curl |
2 min |
| Map each price to a plan | One curl per price |
~5 min |
| Set six environment variables | Your deploy | ~10 min |
| Write the four functions above | Your repo | ~1 h |
| Postgres + deploy | Your infrastructure | as usual |
Most of it is in Stripe, creating the things you sell. Very little is in code.
Mapping a price to a plan¶
Creating a price at Stripe does not put it on sale. The engine has to know which of your plans it buys, and — for a period sold outright — how long the licence then lasts.
Do this on the console's Pricing page. It lists what each product is currently on sale as, with the amount your provider will really charge, and names the plans that are on sale nowhere. Paste the price identifier, say whether it renews or is paid once, done.
It refuses rather than overwrites: a plan already sold at a price keeps it, and the page says so. Replacing a tariff retires a price customers may be subscribed at, which is a deliberate act and not a re-run of a setup script.
The same thing is a REST call, for anyone scripting a catalogue:
curl -X PUT https://<engine>/api/v1/admin/products/<your-product>/prices \
-H "X-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"plan":"pro","price_id":"price_1Abc…","billing_interval":"month"}'
Giving the engine your Stripe key¶
curl -X PUT https://<engine>/api/v1/admin/payments/tenants/<your-tenant>/stripe \
-H "X-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"api_key":"sk_live_…"}'
Before storing anything, this proves the key works — a key that is wrong, revoked or from the wrong mode is fixable in the minute you paste it and unfixable once a customer is at a checkout that will not open — and registers the webhook endpoint on your own Stripe account, keeping the signing secret Stripe returns.
You do not have to know a URL. If the response says webhook_registered: false
(a restricted key that cannot create endpoints), it names the webhook_url to
add yourself. That is the only manual step that survives.
Your customers' money settles in your account. Nothing passes through the engine owner's.
The six variables¶
With env_prefix="ACME_SAAS" the foundation reads them itself:
| Variable | What it is |
|---|---|
ACME_SAAS_ENGINE_EVENTS_SECRET |
signs the engine's callbacks to you |
ACME_SAAS_PORTAL_LINK_SECRET |
signs the mailed "manage my subscription" link |
ACME_SAAS_PUBLIC_API_URL |
your gateway's public base |
ACME_SAAS_PORTAL_BASE_URL |
where the portal sends a customer back |
ACME_SAAS_CONTACT_TO |
where a reply to a receipt goes |
ACME_SAAS_SMTP_* |
your own mail credentials |
The SMTP prefix is yours, not the framework's SAAS_SMTP_*, on purpose: two
products sharing one mail account is how a licence for one arrives signed by the
other.
The four functions¶
from lisaba.saas.billing import (
build_product_billing_routers,
purchasable_plans_from_registry,
sqlalchemy_tenant_billing, # or sync_tenant_billing
)
def _tenant_billing():
return sqlalchemy_tenant_billing(
model=TenantModel, # needs `id` and `contact_email`
session_factory=get_sessionmaker(),
provision=_provision_tenant,
)
def _plans():
return purchasable_plans_from_registry(
registry_get=get_plan_registry().get,
codes=(PlanCode.PRISM, PlanCode.ALCHEMY, PlanCode.HARMONY),
price_env_prefix="ACME_SAAS_STRIPE_PRICE",
)
def build_billing_routers():
return build_product_billing_routers(
product="acme-saas",
env_prefix="ACME_SAAS",
engine_url=settings.sawabona_url,
engine_api_key=settings.sawabona_admin_api_key,
public_plans=_plans,
tenants=_tenant_billing(),
)
provision_tenant is yours to write: an async (email, name, slug) -> tenant_id.
It differs for real reasons. One integration's database role has no INSERT on
tenants at all and goes through a SECURITY DEFINER function; another inserts
directly and carries a plan column. A helper with a flag for each shape would be
worse than two clear functions.
What this assumes about your stack¶
Be clear-eyed about this before you start.
- Python, FastAPI, SQLAlchemy. The routers are
APIRouters and the tenant lookups use SQLAlchemy. - A tenant model with
idandcontact_email. Structural, not inheritance — any model with those two attributes works. InheritingBaseTenantModelgives you the column and the mixin for free. - A plan registry whose entries carry
code,display_name,description,price_monthly_eur,price_annual_eur.
If any of that is wrong for you, the engine is a plain REST API and none of the above is required — see "Without the Python foundation" below.
Without the Python foundation¶
The engine speaks HTTP and knows nothing about Python. What the foundation gives you is the plumbing, not the permission.
| You use | You get | You write |
|---|---|---|
| the Python foundation | everything, ~70 lines | the four functions |
| a client SDK | licence validation only | checkout, delivery, portal, receipts |
| REST directly | everything the engine does | all of the gateway |
Client SDKs exist for nine languages — Rust, Python, TypeScript, Go, Java,
C#, C, C++ and Ada — all binding the same proof kernel, so a proof computed in
any of them is byte-identical. See docs/SDKS.md.
They cover the consumer side: an application validating its own key and reading a signed entitlement. They carry no web framework and no ORM. They do not cover the seller side — checkout, delivery, portal, receipts — which is what the Python foundation adds.
So a gateway written in Go or C# is REST plus that language's SDK for the validation half, and hand-written for the rest. The engine is the same either way.
The engine endpoints a gateway actually needs are few:
| Endpoint | For |
|---|---|
POST /api/v1/checkout |
open a hosted checkout |
POST /api/v1/billing-portal |
open the customer's billing portal |
GET /api/v1/licenses/{id}/pending-key |
claim the plaintext key, once |
POST /api/v1/licenses/key/lookup |
resolve a key to its tenant |
your endpoint, receiving license.created etc. |
deliver, receipt, announce |
The outgoing events are signed t=<unix>,v1=<hmac-sha256 of "{t}.{body}"> —
Stripe's convention, so most languages already have a verifier.
Bring your own payment account — for any provider¶
Your customers pay you, not us. Store your own credentials and the checkout, the webhook intake and the licences that follow all run on your account; no funds ever pass through ours.
curl -X PUT https://admin.sawabona.dev/api/v1/admin/payments/tenants/<your-tenant>/flutterwave \
-H "X-API-Key: $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"api_key": "FLWSECK-…", "webhook_secret": "…"}'
{provider} is any of stripe, flutterwave, paystack, paddle, mollie,
and you can hold a different account at several at once — cards through one rail,
wallets through another.
This is the case the product exists for. An ISV in Lagos or Nairobi pays for Sawabona with an international card, and sells to their own market through a rail we would never hold an account with ourselves. Settlement at those providers is to a bank account in the merchant's own country, which is exactly why it has to be their account and not ours.
Three things happen when you store a key:
- It is proved before it is stored — where the provider offers a way.
Stripe, Paddle and Mollie do. The response says
"key_verified": falsefor the others, meaning the key was taken on trust and will fail at the first checkout if it is wrong — said now rather than left to be the discovery. - The intake is registered on your account where the provider's API allows
it — Stripe and Paddle. Mollie needs no registration at all: it is told the
URL on every payment. Where neither applies, the response says
"webhook_registered": falseand gives you the exact URL to add yourself — one human step, said at the moment you can act on it.
You do not have to know our URL scheme: the engine fills notification_url
with its own intake unless you state one.
3. The key is encrypted at rest before the repository ever sees it.
DELETE on the same path forgets it, and your products settle into the engine
owner's account again. That is an offboarding step, not a cleanup.
What works with which payment provider¶
Sawabona supports five. They are not equal, and the difference is not cosmetic.
Licensing works with all five. Issue, validate, revoke, count devices, cut off on cancellation — Stripe, Paddle, PayStack, Flutterwave and Mollie all implement the webhook verification and subscription handling this needs.
A fifth, Square, was removed. It never had a checkout, and it published cancellation only inside an event the engine does not parse — so a licence sold through it could not be cut off when the customer stopped paying. A provider that looks supported and silently fails at the kill-switch is worse than one that is absent.
Self-serve commerce is Stripe-only today:
| Capability | Stripe | Paddle | PayStack | Flutterwave | Mollie |
|---|---|---|---|---|---|
| Licensing, validation, kill-switch | ✅ | ✅ | ✅ | ✅ | n/a¹ |
| Refund / chargeback revokes the licence | 🟡 | 🟡 | 🟡 | 🟡³ | 🟡 |
| Plan change applied to the licence | ✅ | ✅ | ✅ | ✅ | n/a¹ |
| Renewal extends the licence | ✅ | ✅ | ✅ | ✅ | n/a¹ |
| Hosted checkout | ✅ | 🟡 | 🟡 | 🟡 | 🟡 |
| Prepaid period sold outright | ✅ | 🟡 | 🟡 | 🟡 | 🟡 |
| Billing portal | ✅ | 🟡 | 🟡⁵ | ✗ | ✗ |
| Receipts | ✅ | MoR² | 🟡 | 🟡 | 🟡 |
| Auto-registering the webhook endpoint | ✅ | 🟡 | ✗⁶ | ✗⁶ | n/a⁴ |
| Key proved before it is stored | ✅ | 🟡 | 🟡 | 🟡 | 🟡 |
| Mobile money | ✗ | ✗ | 🟡 | 🟡 | ✗ |
| Merchant of record (handles VAT) | ✗ | ✅ | ✗ | ✗ | ✗ |
¹ Mollie sells one-off periods only here. A Mollie subscription needs a mandate obtained from a completed first payment — a two-step flow this engine does not implement — so opening one is refused rather than half-sold. Nothing recurring exists to renew, change or cut off. This is the opposite of the Square situation that got that provider removed: there, subscriptions were sold and could not be cut off.
² Paddle is the merchant of record: it is the legal seller and invoices the buyer itself, so the engine deliberately does not send a second receipt.
⁴ Mollie keeps no list of webhook destinations at all — webhookUrl travels on
every payment, which the engine sends from your stored configuration. There is
nothing to register, so there is no human step either; what the engine checks
instead is that the stored URL is the one it expects, because a configuration
pointing elsewhere would take money and report it to another engine.
⁵ PayStack's page is keyed on the SUBSCRIPTION, not the customer: it exists to repair the card behind one, and also lets the customer cancel. Nothing there to change plan.
⁶ Both keep webhook registration in their dashboard with no API. PayStack has
one URL for the whole account, so an ISV already pointing it elsewhere
cannot simply add ours — they have to fan the events out themselves. Flutterwave
has two, live and test, configured separately; filling in only one gives a
rail that works in exactly one mode and says nothing about the other. The
response to storing a key carries webhook_setup_url with the exact page.
³ Flutterwave sends refund webhooks only if their support has enabled them on your account — they are off by default. Until you ask, a refund there is silent and the licence has to be revoked by hand. Nothing warns you.
Only a FULL reversal revokes. A partial refund leaves the licence alone: a goodwill gesture is not the sale coming apart. Where the payload does not state enough to tell the two apart, nothing happens and the operator is told.
🟡 = written and tested against a mock, not yet against the live API. Say so plainly rather than shipping a ✅ nobody has exercised. Flutterwave's hosted checkout and prepaid period have since been exercised with real money; the marks here are conservative until each row is re-checked one at a time.
Two rows are worth reading carefully. Cards only on the provider most people assume reaches a wallet — it does not, for anything recurring. And a wallet cannot be tokenised at all, so a subscription is impossible there by construction: mobile money is reachable only as a period bought outright.
A provider that is told an amount needs one. Where a price object exists,
the amount lives at the provider and is read back. Where it does not, the price
mapping carries amount_minor and currency — and a checkout opened without
them is refused rather than charging a figure nobody chose.
A plan change applies everywhere, because knowing which plan a customer moved to does not require knowing when they are paid through. Gating it behind the paid-through date would drop every upgrade and extension from a provider that states none — the customer paying the new price and keeping the old plan.
Renewals extend the licence on every provider that sells a subscription, from two sources in that order:
- The payload's own date, where the provider states one — Paddle's
current_billing_period.ends_at, PayStack'snext_payment_date, Stripe's invoice line period. Exact, down to proration and trials. - The
billing_intervalpinned on the price mapping, where it does not. One provider publishes no paid-through field on any payload; without this its customers would watch a licence expire while paying every month.
The second is configuration, not a guess: you declared the cadence when you mapped the price, and the engine applies it. An unmapped interval extends nothing and says so — assuming "monthly" would cut an annual customer off after a month, which is worse than the year it replaces.
What remains Stripe-only is the self-serve commerce surface: hosted
the billing portal and endpoint registration. Hosted checkout is no longer on
that list — Paddle, PayStack and Mollie each open one now, and each does it in
its own shape: Paddle names a price it owns, PayStack is told minor units as an
integer, Mollie a decimal string. The parts that are the same for all of them
live in sawabona_core::services::payments::hosted. All four are opt-in trait methods that default to "not supported",
so adding a provider is additive and cannot break the others.
Nothing here is a redesign. Every one is a method on
PaymentProviderTrait with a default that says it is unsupported, so adding a
provider is additive and cannot break the others.
Selling a period outright¶
Recurring billing needs a payment method the provider can charge again by itself, which in practice means a card that can be tokenised. Two groups of customers cannot give you one:
- Anyone paying from a mobile-money wallet. It cannot be tokenised at all, so a subscription is not merely inconvenient for them — it is impossible.
- Cardholders whose bank blocks or caps international e-commerce. This is the norm, not the exception, in several countries.
A prepaid period reaches both. They pay once, hold a licence for a fixed length of time, and nothing renews it and nothing cuts it off early. It expires, and they buy again.
On the console's Pricing page, choose "A period paid once, that does not renew" and give it a length. Or, scripted:
curl -X PUT https://<engine>/api/v1/admin/products/<your-product>/prices \
-H "X-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"plan":"pro","price_id":"price_1Xyz…","one_off":true,"duration_days":90}'
duration_days is any positive number of days — 90, 30, 14, 365. A subscription
has a cadence the provider restates at every renewal; a prepaid period has no
cadence at all, so this column is the only thing that says how long the licence
lives. A one-off mapping without it is refused rather than defaulted, because
defaulting hands somebody who bought three months a year.
Your gateway needs no configuration for any of this. It asks the engine what your product is on sale as:
and gets each price back with its length and the amount your provider will
actually charge. The Python foundation does this for you: pass
catalogue_product="<your-product>" and every plan in your public catalogue
carries its prepaid periods.
That is deliberate, and it is the one design decision here worth stating. A prepaid period's length and price exist nowhere in your plan registry — the price is not the monthly rate times three — so the alternative would be declaring both in your own environment, next to a payment provider that already holds the authoritative amount and will not let you edit it. Two copies of a price is how a page comes to advertise a figure the checkout does not charge.
Which provider can collect one is not a given. Ask before you offer:
Every capability defaults to false, so a provider states what it supports and one that says nothing supports nothing. The checkout refuses on the same answer — a buyer must never be the one who discovers the gap.
Stripe takes cards only. A prepaid offer on Stripe therefore solves the blocked-or-capped-card problem and not the mobile-money one; reaching a wallet means a provider that speaks to the mobile operators.
Two things that will bite you if nobody says them¶
A seat cap without a transfer budget is not a cap. A holder releases a
machine and activates another, so max_devices alone means unlimited. Set both
on every plan.
A downgrade does not cut a machine immediately, and must not. The licence
runs over its ceiling for a grace period (3 days, SAWABONA_OVER_CAP_GRACE_DAYS)
and is then brought back — oldest-seen machine first, so the one being used
survives. Cutting instantly deactivates a machine somebody is working on to
satisfy an accounting change made thirty seconds earlier.
How many machines a licence allows¶
Two numbers, and neither is Sawabona's to choose:
seats comes from the purchase — the quantity on the subscription line, or
metadata.seats for a checkout session, which carries no line items. A payload
that states no quantity means ONE seat, never "unlimited".
plan.max_devices is the product's per-seat allowance, not a ceiling. A seat is
a person, and a person has a workstation and a CI runner, so an allowance of 1
locks a developer out the first time their pipeline validates.
Sawabona enforces this policy; it does not author it. A plan that states no
allowance grants ONE machine per seat and logs an error naming the plan — a
floor, not a decision. An engine-side default is the product owner's decision
taken away from them silently and disguised as data, which is exactly what a
hard-coded unwrap_or(3) did: every licence granted three activations
regardless of how many seats had been paid for, so a five-person team was
blocked on its fourth machine while a one-seat buyer got the same three.
Changing machines¶
A holder frees their own seat with nothing but their licence key:
POST /api/v1/licenses/self/devices/release
{ "license_key": "saw_...", "device_fingerprint": "..." }
Self-authenticating like /validate and /heartbeat — whoever holds the key
can already activate a machine, a strictly larger power than releasing one of
their own. It answers the same for a machine already released and one that was
never there, so it cannot be used to test whether a fingerprint belongs to
someone else.
Without this, freeing a seat needed an admin key and a machine swap became a support ticket. A tight per-seat allowance is only humane if the holder can move machines themselves — which is why the market pairs one with the other (LicenseSpring's device transfers, Keygen's overage tolerance).
The budget is the plan's: metadata.max_transfers_per_month. Counted from the
audit trail rather than a separate tally, so the number cannot disagree with the
record of what happened, and a REFUSED release does not consume budget — else
hitting the limit once would lock you out permanently, each rejection paying for
the next. A plan that states no budget allows the release and logs the omission:
unlimited free transfers defeat a seat cap, but inventing the limit here would
be the product owner's decision taken silently.
The seat CEILING at checkout (MAX_SEATS) is different in kind: it guards
against a typo charging someone for 100 000 seats, and says nothing about how
large a team may be.