Reference¶
Everything you need to run Sawabona, sell against it, and integrate a client. For the five-minute path, read Getting started first.
Why Sawabona?¶
You sell software and you want three things: the money in your account, the licence to stop when the payment stops, and a check that cannot be faked by replaying a recorded answer.
A licence check here is not a yes/no over the wire. The client and the server each derive a figure from a shared secret and prove they drew the same one. An observer learns neither the figure nor which of three families it came from, and a captured response is worth nothing on the next challenge because the challenge is new each time.
Sawabona does not hold your money. The customer pays into your own payment account and the engine reacts to your provider's webhook.
Install and run¶
From source:
cp .env.example .env # set SAWABONA_DATABASE__URL
sawabona db migrate # create the schema
cargo run --package sawabona-api # serves on :8000
Prerequisites. Rust 1.70+, PostgreSQL 12+. Redis is optional and only used for challenge storage across processes; a single process needs none.
Health and configuration, before anything else:
Walkthrough¶
Product, plan, licence, validation. Management calls need an admin key, sent
as Authorization: ApiKey <key> — X-API-Key is not read, and neither is a
Bearer token, which is for user sessions.
1. A product¶
curl -sX POST localhost:8000/api/v1/products \
-H "Authorization: ApiKey $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"name":"My App","slug":"myapp","version":"1.0.0"}'
slug becomes the product segment of every licence key it mints, so it is
limited to 1–8 characters, lowercase letters, digits and inner hyphens, with
no underscore — a key is six underscore-separated segments and an underscore
here would add a seventh. For a longer name, keep the readable slug and add a
short key_slug:
2. A plan¶
curl -sX POST localhost:8000/api/v1/plans \
-H "Authorization: ApiKey $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"name":"Pro","product_id":"'"$PRODUCT_ID"'","slug":"pro","tier":"pro","price_monthly":4900}'
Prices are in minor units — 4900 is 49.00. A plan carries what the licences under it are allowed to do:
| field | meaning |
|---|---|
features |
feature flags the client can read off a validated entitlement |
max_devices |
machines one seat may activate |
max_api_calls |
monthly call quota, null for none |
price_monthly · price_annual |
minor units; null means contract-priced |
lease_ttl_secs |
floating seats: a device that stops heartbeating is released |
tier is the commercial level (tier0…tier3); slug is the plan. Two
products' plans at the same rank share a tier, so the tier alone never
identifies a plan.
3. A licence¶
curl -sX POST localhost:8000/api/v1/licenses \
-H "Authorization: ApiKey $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"product_id":"'"$PRODUCT_ID"'","plan_id":"'"$PLAN_ID"'","max_activations":5,"duration_days":365}'
The server generates the key — you do not supply one. The plaintext is returned exactly once; only its hash is stored. Copy it then.
saw_default-tenant_myapp_live_a1b2c3d4e5f6a7b8c9d0_f0e1
│ │ │ │ │ └ checksum
│ │ │ │ └ random
│ │ │ └ environment: live | test
│ │ └ the product's key_slug
│ └ tenant
└ prefix
Six underscore-separated segments, read by position. That is why no segment may contain an underscore.
4. Validation¶
Two steps, and every SDK does both for you: fetch a challenge, then submit the proof with the key.
CHALLENGE=$(curl -sX POST localhost:8000/api/v1/geometric-proof/challenge \
-H 'Content-Type: application/json' \
-d '{"license_key":"'"$KEY"'","product_slug":"myapp","client_id":"'"$DEVICE"'"}')
curl -sX POST localhost:8000/api/v1/licenses/validate \
-H 'Content-Type: application/json' \
-d '{"license_key":"'"$KEY"'","product_slug":"myapp","client_id":"'"$DEVICE"'",
"device_fingerprint":"'"$DEVICE"'","proof":{…}}'
Both are unauthenticated by design: the licence key is the credential, and solving the challenge is the proof of holding it.
The challenge does not name the figure family. It is derived from the bootstrap secret on both sides, so nothing on the wire announces which of the three it is.
Selling¶
Choosing a provider¶
Stripe, Paddle, Mollie, Paystack, Flutterwave and others, each a separate crate opted into at build time. Pick by region and by what you sell; the engine's job is the same either way.
Configure it by environment variable before starting the server — the provider is seeded from the environment at boot:
Additional providers can be created, enabled or rotated at runtime through
POST /api/v1/admin/payments/providers.
Seats and activations¶
Two numbers, and neither is Sawabona's to choose:
seats comes from the purchase — the quantity on the subscription line, or
metadata.seats on a checkout session, which carries no line items.
max_devices comes from the plan: a person has a workstation and a CI runner,
so one seat is rarely one machine.
See Selling your product with Sawabona for the full path, including collecting into your own account.
CLI reference¶
| command | subcommands |
|---|---|
db |
init · migrate · reset · seed · status · export-catalog · backup · restore |
license |
create · list · validate · revoke · rotate-key · transfer |
product |
create · list · update · delete · reassign |
plan |
create · list · update · delete |
server |
start · stop · status |
config |
validate · show |
health |
check |
init |
provider |
interactive |
— |
version |
— |
Every command takes --help. Full options: CLI reference.
API¶
Authentication¶
| surface | credential |
|---|---|
| management (products, plans, licences, admin) | Authorization: ApiKey <admin key> |
| validation, challenge, heartbeat, seat release | the licence key itself — no header |
| catalogue, public key | none; they are public on purpose |
There is no open mode. A missing Authorization header on a management route
is a 401 whatever is configured.
Being in SAWABONA_API_KEYS__KEYS gets a caller past the door; management
routes then also require SAWABONA_API_KEYS__ADMIN_KEYS (or
SAWABONA_ADMIN_API_KEY, which is added to both).
Refusals¶
Every refusal carries a reason beside its sentence:
{"error": "bad_request", "message": "This licence expired on 3 March 2027. Renew it to continue.",
"status": 400, "reason": "license_expired"}
Four of the five licence refusals answer HTTP 400, so the status alone does
not say which one it is. Branch on reason, display message, and treat an
absent reason as absent rather than unknown. The full list, and what each one
asks the holder to do: Refusal codes.
Everything else¶
API reference has every endpoint. The
machine-readable surface is the OpenAPI document the running engine serves at
/api-docs/openapi.json; a test in the engine fails when a served route is
missing from it, in either direction.
Client integration¶
Nine SDKs — Python, Rust, TypeScript, Go, Java, C#, C, C++, Ada — all binding
the same sawabona-proof kernel, so a proof computed in any of them is
byte-identical. The native ones go through a shared C ABI; Python uses PyO3 and
TypeScript WebAssembly.
let client = LicenseClient::builder()
.server_url("https://licence.example.com")
.license_key(&key)
.product_slug("myapp")
.build()?;
let result = client.validate().await?;
if result.features.iter().any(|f| f == "advanced-export") { … }
from sawabona_sdk import LicenseClient
client = LicenseClient(server_url=..., license_key=key, product_slug="myapp")
result = client.validate()
Pick the SDK and read its own README: Client SDKs. To embed the engine itself rather than talk to one, see Using sawabona-core as a library.
Offline¶
A validated entitlement is cached and signed. With a trust anchor pinned, a client re-verifies the cached envelope's Ed25519 signature on load, so a forged cache is refused — and the cache is served only when the server is unreachable. An authoritative denial (400/401/403/404) is never retried and never falls back.
Security practices¶
- Pin a trust anchor. A cache-capable client in production without one has no cryptographic check on an offline entitlement; the SDKs refuse to build in that shape unless you opt out explicitly.
- Verify webhook signatures. The engine does; if you proxy them, do not strip the signature header or rewrite the body — the signature covers the exact bytes.
- One key, one privilege. An admin key can create and revoke licences. Do not reuse it as the read key for a storefront.
- Rotate provider secrets through the admin API, not by editing rows.
- Full checklist before taking real money: Production security checklist.
Troubleshooting¶
The server will not start. sawabona config validate first — it refuses a
placeholder secret, a short JWT secret, and a startup migration outside
development, and each refusal names the variable.
Database connection refused. Check SAWABONA_DATABASE__URL and that
PostgreSQL accepts the credentials: psql "$SAWABONA_DATABASE__URL" -c 'select 1'.
Validation fails. Read reason, not the status. license_not_found is a
typo or the wrong environment — a test key never validates against a live
deployment. license_pending_activation means a replacement key was minted and
not acknowledged, which is one call.
Activation limit reached. activations = seats × max_devices. Release a
seat rather than raising the cap: POST /api/v1/devices/{id}/deactivate, or the
self-service POST /api/v1/licenses/self/devices/release.
A provider is not found. It is opted in at build time and seeded from the environment at boot. Set its variables, then restart — a running server does not pick them up.
Webhooks are not arriving. Confirm the subscription at the provider before
reading the code: an event nobody subscribed to is never sent. The engine's
endpoint is POST /api/v1/webhooks/{provider}, and per-ISV
POST /api/v1/webhooks/{provider}/t/{tenant}.
Questions¶
Can I self-host? Yes — the licence is written for it. See Deployment.
Can I use several payment providers at once? Yes. Each is independent, and a licence records which one paid for it.
How do I back up licences? sawabona db backup. The keys are stored hashed,
so a backup cannot leak them.
Is there a rate limit? Yes, per tenant, from the plan. A refused call
answers 429 with Retry-After.
How do I rotate a licence key? sawabona license rotate-key. The old key
stops validating and the new one is returned once; the holder acknowledges it,
which is what license_pending_activation is waiting for.
What happens when a payment reverses? The engine revokes. The refusal says
license_revoked and deliberately does not say why: on a vendor's licence the
holder may be the vendor's own end user, and a payment dispute is not theirs.
Where to next¶
- Selling your product with Sawabona
- Licence service guide — revoke, rotate, transfer, heartbeats, offline tokens
- Geometric Proof specification — enough to write an independent implementation