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
POST https://api.klippster.app/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.
| Field | Type | Required | Description |
|---|---|---|---|
activationKey |
string | one of two | The permanent 20-character key from the purchase email. Case, spaces, hyphens and underscores are ignored, and the Crockford aliases apply — O reads as 0, I and L read as 1. |
claimCode |
string | one of two | The 12-character code from a klippster-license://claim link. Single use, valid 14 days, and at most one is live per licence — this call consumes it. |
deviceCode |
string | required | The 16-character device code derived on the Mac. Hyphens, spaces and case are tolerated. |
appVersion |
string | optional | Recorded on the device row, for support. |
osVersion |
string | optional | Recorded on the device row, for support. |
label |
string | optional | A human name for the Mac, shown on the customer's licence page so they can tell their machines apart when releasing one. |
Request
{
"activationKey": "K7X2M-9QP4R-3BTVW-6HZND",
"deviceCode": "0ABC-1DEF-2GHJ-3KM4",
"appVersion": "0.5.0",
"osVersion": "14.5",
"label": "MacBook Pro"
}#200 — activated
Response
{
"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:
409
{
"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
GET https://api.klippster.app/v1/licenses/by-session?session_id=…
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.
| Field | Type | Required | Description |
|---|---|---|---|
session_id |
query · string | required | A Stripe Checkout Session id — cs_test_… or cs_live_…, at most 255 characters. |
#200 — ready
Response
{
"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
GET https://api.klippster.app/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.
200
{
"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:
Decoded payload
{
"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
POST https://api.klippster.app/v1/checkout
Turns a tier id into a Stripe Checkout Session URL. Called by the pricing page.
| Field | Type | Required | Description |
|---|---|---|---|
tier |
string | default personal |
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
GET /healthz — any host
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:
klippster-pro.klippsterlicense
{
"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:
Decoded payload
{
"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.