Licensing API
The endpoints the Klippster app and the website call. Everything here is on api.klippster.app; the customer portal and the admin are HTML on their own hostnames and are not part of this contract.
Base URL: https://api.klippster.app
Content type: every JSON response is
application/json; charset=utf-8 with Cache-Control: no-store. A few failure
modes are deliberately text/plain — they are listed under
Errors.
Authentication: none of these endpoints takes an API key. Activation authenticates with the licence credential in its own body; the revocation list is signed, so its authenticity does not depend on who asked for it.
Surfaces: the service answers three hostnames from one process, and a route
belongs to exactly one of them. Matching on the path alone would put the admin pages on the customer
portal with nothing but an unguessed URL in the way. Only the api. surface is documented
here.
CORS: exactly two routes accept cross-origin calls, and only from the site's own
origin — see Cross-origin requests. A request with no
Origin header at all (the native app, curl, server-to-server) is allowed
through.
Bodies are capped at 256 KiB.
#Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /healthz | Liveness, including database reachability |
POST | /v1/licenses/activate | Bind a Mac and mint its licence |
GET | /v1/licenses/by-session | The claim link for a just-paid order |
GET | /v1/revocations | The signed revocation list |
POST | /v1/checkout | Start a Stripe Checkout session |
POST | /v1/stripe/webhook | Stripe only — not a public API |
/healthz answers on any hostname. Everything else is api. only; a request
for one of these paths on another host gets 404 Not found.
#POST /v1/licenses/activate
Exchanges a licence credential plus a device code for a signed, device-bound licence. This is the only licence-related network call the app ever makes, and it makes it once.
Supply either activationKey or claimCode.
If both are present, activationKey is used.
O reads as
0, I and L read as 1.klippster-license://claim link.
Single use, valid 14 days, and at most one is live per licence — this call
consumes it.{
"activationKey": "K7X2M-9QP4R-3BTVW-6HZND",
"deviceCode": "0ABC-1DEF-2GHJ-3KM4",
"appVersion": "0.5.0",
"osVersion": "14.5",
"label": "MacBook Pro"
}#200 — activated
{
"license": "{\n \"v\": 1,\n \"payload\": \"eyJwcm9kdWN0Ijoi…\",\n \"sig\": \"MEUCIQC…\"\n}",
"licenseId": "KL-1A2B-3C4D",
"kind": "personal",
"seats": { "used": 2, "total": 3 }
}licenseis a string: the complete contents of the.klippsterlicensefile, pretty-printed. Write it to disk as-is.seatshere is an object —{ used, total }. Note that theseatsfield inside the licence payload is an integer. They are not the same thing.kindis one ofpersonal,family,team,press,complimentary.
A Mac that is already bound gets the byte-identical licence back and consumes no second seat. Minting is deterministic for a given set of inputs, which is what makes every delivery path safe to retry — a dropped connection is an ordinary case, not an error.
#Errors
| Status | error | Meaning |
|---|---|---|
400 | invalid_device_code | Wrong length, illegal character, or the check symbol did not match. |
404 | unknown_credential | No credential supplied, an unknown activation key, or a claim code already used or expired. |
403 | license_revoked | The licence has been revoked. |
403 | license_expired | The licence has an expiry and it has passed. |
409 | seat_limit | Every seat is in use. Carries detail.seats, the licence's total. |
500 | activation_unavailable | Anything else. The real cause is logged, never returned. |
Every error except the last also carries a message written for a person, which the app
can show directly:
{
"error": "seat_limit",
"message": "This licence is already active on all of its Macs. Release one first, then try again.",
"detail": { "seats": 3 }
}unknown_credential deliberately does not distinguish between "no such key" and "that
claim code was already used". Both are answered the same way, and the customer's route forward is
the same: use the permanent activation key, or mint a fresh link from the licence page.
#GET /v1/licenses/by-session
What the success page calls immediately after checkout. It returns a claim link, never a licence file — a licence names the Mac it runs on, and a browser cannot say which Mac that is. The link can, because it opens the app.
cs_test_… or
cs_live_…, at most 255 characters.#200 — ready
{
"status": "ready",
"licenseId": "KL-1A2B-3C4D",
"kind": "personal",
"seats": 3,
"email": "buyer@example.com",
"claimURL": "klippster-license://claim?code=ABC123DEF456",
"expiresAt": "2026-08-19T10:00:00.000Z"
}expiresAt is the claim code's expiry, 14 days out — not the licence's.
seats is an integer here, unlike the object returned by activation.
Each call mints a fresh claim code and retires any previous live one, so reloading the success page is safe and always hands out a working link.
#Other responses
| Status | Body | Meaning |
|---|---|---|
202 | {"status":"pending"} | No licence yet. The payment may be real with the webhook still in flight, so this is deliberately not a 404 — poll. |
400 | {"error":"invalid_order"} | session_id missing or malformed. |
403 | {"error":"forbidden"} | An Origin header naming an origin that is not the site. |
410 | {"error":"revoked"} | The licence for that order has been revoked. |
#GET /v1/revocations
The signed list of revoked licences. Public and unauthenticated on purpose: it is signed, so its authenticity does not depend on who asked for it, and the ids in it are derived from order ids and name nobody.
{
"v": 1,
"payload": "eyJwcm9kdWN0Ijoia2xpcHBzdGVyLnBybyI…",
"sig": "MEUCIQCx8s…"
}Same envelope as a licence token. payload is base64 of the exact signed bytes; verify
sig over those bytes with the pinned Ed25519 public key before decoding. Decoded:
{
"product": "klippster.pro",
"serial": 7,
"generatedAt": "2026-08-05T10:00:00Z",
"revoked": ["KL-1A2B-3C4D", "KL-9F8E-7D6C"]
}serial is monotonic and inside the signed bytes, so an older list
cannot be replayed over a newer one. revoked is sorted.
404 {"error":"no_list_published"} when no list has ever been published.
A client must treat an absent, stale or unverifiable list as honour the licence. A revocation check that fails closed can take Pro away from paying customers on a bad deploy; one that fails open costs a revoked licence one more update cycle. A false reject costs a paying customer and a support ticket; a false accept costs €19.99.
#POST /v1/checkout
Turns a tier id into a Stripe Checkout Session URL. Called by the pricing page.
personal or family. The admin-only kinds
(team, press, complimentary) are not buyable.The client never sends an amount. The price is resolved server-side from the tier id, so a tampered request can pick a different product but not a different price.
| Status | Body | Meaning |
|---|---|---|
200 | {"url":"https://checkout.stripe.com/…"} | Redirect the browser here. |
400 | {"error":"unknown_tier"} | Not a sellable tier id. |
403 | {"error":"forbidden"} | Disallowed Origin. |
502 | {"error":"checkout_unavailable"} | Stripe failed. The provider error is logged, never returned. |
503 | {"error":"commerce_disabled"} | Selling is switched off server-side, regardless of what any page shows. |
#GET /healthz
200 {"ok":true}, or 503 {"ok":false,"error":"database_unavailable"} when
the database does not answer. It runs a real query rather than reporting that the process is up,
because a service that cannot reach its database is not healthy in any useful sense.
#The licence payload
The contract between the service and the macOS app. The file is:
{
"v": 1,
"payload": "<base64 of the payload JSON bytes>",
"sig": "<base64 Ed25519 signature over those exact bytes>"
}The signature covers the bytes, not a re-serialised object, which is what lets both sides skip canonical JSON. Decoded, the payload is:
{
"product": "klippster.pro",
"email": "buyer@example.com",
"orderId": "cs_live_a1B2c3D4",
"licenseId": "KL-1A2B-3C4D",
"issuedAt": "2026-08-05T10:00:00Z",
"kind": "personal",
"seats": 3,
"name": "Buyer",
"deviceHash": "0ABC1DEF2GHJ3KM4",
"expiresAt": "2027-08-05T10:00:00Z"
}| Field | Type | Notes |
|---|---|---|
product | string | Always klippster.pro |
email | string | The buyer's address at mint time |
orderId | string | The Stripe session id, or the licence id for an admin-issued licence |
licenseId | string | KL-XXXX-XXXX |
issuedAt | string | ISO 8601 UTC, second precision |
kind | string | The tier |
seats | integer | ≥ 1. An integer here — not the {used,total} object activation returns |
name | string · optional | Omitted when unknown |
deviceHash | string · optional | The canonical 16-character device code. Optional in the format; in practice always present, because nothing hands out an unbound licence |
expiresAt | string · optional | Omitted on a perpetual licence |
The first five fields keep their positions so a build that predates device binding can still
read a current token. Because the signature covers the serialised bytes,
insertion order is load-bearing: a new field must be appended at the end, never
inserted. Optional fields are omitted entirely rather than sent as null.
#Device codes
| Property | Value |
|---|---|
| Length | 16 characters — 15 data plus 1 check symbol |
| Entropy | 75 bits of a salted hash of a macOS hardware identifier |
| Alphabet | Crockford base32: 0123456789ABCDEFGHJKMNPQRSTVWXYZ |
| Check symbol | May additionally be one of * ~ $ = U, which never appear in data positions |
| Canonical form | Upper case, no separators |
| Display form | XXXX-XXXX-XXXX-XXXX |
Parsing tolerates lower case, spaces, hyphens and underscores, and applies the Crockford aliases
O→0, I/L→1 before verifying the check
symbol. The serial number is never sent and never stored; 75 bits of a salted hash is
not reversible.
#The claim URL scheme
klippster-license://claim?code=ABC123DEF456The code is 12 Crockford characters and is the only thing the link
carries — no email, no licence id, no order id. The code alone identifies the licence server-side,
which is what makes the link safe to appear in a browser history or a support screenshot.
A client registering this scheme should, on open, derive the device code locally and
POST both to /v1/licenses/activate,
then write the returned license string to disk.
#Cross-origin requests
Two routes send CORS headers, and only for the site's own origin:
| Route | Methods advertised |
|---|---|
/v1/checkout | POST, OPTIONS |
/v1/licenses/by-session | GET, OPTIONS |
Preflights are answered 204 with an empty body. An Origin that is not the
site is answered 403 {"error":"forbidden"} — including on the preflight, so a browser
cannot learn anything from it. Credentials are never allowed, so cookies do not travel
cross-origin.
Everything else — activation, revocations, the webhook, /healthz — sends no CORS
headers at all and is not callable from a web page. Activation is a native-app call.
#Errors
Route-level errors are JSON: {"error":"code"}, sometimes with a human
message. A few conditions are detected before a route is reached and are returned as
text/plain, where the body is the code:
| Status | Body | Meaning |
|---|---|---|
400 | invalid_json | A body was sent and is not valid JSON. An empty body reads as {}. |
413 | payload_too_large | Over 256 KiB. |
404 | Not found | No route for that hostname, method and path. |
500 | Internal error | An unhandled failure. |
A client should branch on the HTTP status first and treat a non-JSON body as an opaque failure, rather than assuming every error parses.
#The webhook
POST /v1/stripe/webhook exists on this surface but is not a public API. It consumes the
raw body and verifies the Stripe signature before parsing anything, and it answers
500 on a fulfilment failure on purpose, so that Stripe retries — the mail
provider's idempotency key is what stops the retry sending a second licence email.
It handles checkout.session.completed,
checkout.session.async_payment_succeeded, charge.refunded,
charge.dispute.created and charge.dispute.closed. Everything else is
acknowledged and ignored.
Refunds and disputes set a flag for a human to look at. Nothing revokes a licence automatically — see Revocation.