Skip to content

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

cargo install sawabona-cli          # the CLI
cargo add sawabona-proof            # the proof kernel, Apache-2.0

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:

sawabona config validate            # refuses to start on a bad one, and says why
sawabona health check

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.

export ADMIN_KEY=test-operator-key   # config/development.toml ships this fixture

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:

{"name": "My Application", "slug": "my-application", "key_slug": "myapp"}

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 (tier0tier3); 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.

sawabona init provider --provider stripe   # writes the config template

Configure it by environment variable before starting the server — the provider is seeded from the environment at boot:

export SAWABONA_STRIPE_SECRET_KEY=sk_test_…
export SAWABONA_STRIPE_WEBHOOK_SECRET=whsec_…

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:

activations = seats bought  ×  plan.max_devices

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

sawabona <command> [subcommand] [options]
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