KlippsterDocs DownloadmacOS 14+
Reference

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

MethodPathPurpose
GET/healthzLiveness, including database reachability
POST/v1/licenses/activateBind a Mac and mint its licence
GET/v1/licenses/by-sessionThe claim link for a just-paid order
GET/v1/revocationsThe signed revocation list
POST/v1/checkoutStart a Stripe Checkout session
POST/v1/stripe/webhookStripe 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.

activationKey
stringone 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
stringone 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
stringrequired
The 16-character device code derived on the Mac. Hyphens, spaces and case are tolerated.
appVersion
stringoptional
Recorded on the device row, for support.
osVersion
stringoptional
Recorded on the device row, for support.
label
stringoptional
A human name for the Mac, shown on the customer's licence page so they can tell their machines apart when releasing one.
jsonrequest
{
  "activationKey": "K7X2M-9QP4R-3BTVW-6HZND",
  "deviceCode": "0ABC-1DEF-2GHJ-3KM4",
  "appVersion": "0.5.0",
  "osVersion": "14.5",
  "label": "MacBook Pro"
}

#200 — activated

jsonresponse
{
  "license": "{\n  \"v\": 1,\n  \"payload\": \"eyJwcm9kdWN0Ijoi…\",\n  \"sig\": \"MEUCIQC…\"\n}",
  "licenseId": "KL-1A2B-3C4D",
  "kind": "personal",
  "seats": { "used": 2, "total": 3 }
}
  • license is a string: the complete contents of the .klippsterlicense file, pretty-printed. Write it to disk as-is.
  • seats here is an object{ used, total }. Note that the seats field inside the licence payload is an integer. They are not the same thing.
  • kind is one of personal, family, team, press, complimentary.
Re-activating the same Mac is safe

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

StatuserrorMeaning
400invalid_device_codeWrong length, illegal character, or the check symbol did not match.
404unknown_credentialNo credential supplied, an unknown activation key, or a claim code already used or expired.
403license_revokedThe licence has been revoked.
403license_expiredThe licence has an expiry and it has passed.
409seat_limitEvery seat is in use. Carries detail.seats, the licence's total.
500activation_unavailableAnything 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:

json409
{
  "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.

session_id
query · stringrequired
A Stripe Checkout Session id — cs_test_… or cs_live_…, at most 255 characters.

#200 — ready

jsonresponse
{
  "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

StatusBodyMeaning
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.

json200
{
  "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:

jsondecoded 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.

This check fails open, by design

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.

tier
stringdefault 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.

StatusBodyMeaning
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 any host/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:

jsonklippster-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:

jsondecoded 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"
}
FieldTypeNotes
productstringAlways klippster.pro
emailstringThe buyer's address at mint time
orderIdstringThe Stripe session id, or the licence id for an admin-issued licence
licenseIdstringKL-XXXX-XXXX
issuedAtstringISO 8601 UTC, second precision
kindstringThe tier
seatsinteger≥ 1. An integer here — not the {used,total} object activation returns
namestring · optionalOmitted when unknown
deviceHashstring · optionalThe canonical 16-character device code. Optional in the format; in practice always present, because nothing hands out an unbound licence
expiresAtstring · optionalOmitted on a perpetual licence
Field order is append-only

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

PropertyValue
Length16 characters — 15 data plus 1 check symbol
Entropy75 bits of a salted hash of a macOS hardware identifier
AlphabetCrockford base32: 0123456789ABCDEFGHJKMNPQRSTVWXYZ
Check symbolMay additionally be one of * ~ $ = U, which never appear in data positions
Canonical formUpper case, no separators
Display formXXXX-XXXX-XXXX-XXXX

Parsing tolerates lower case, spaces, hyphens and underscores, and applies the Crockford aliases O0, I/L1 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

scheme
klippster-license://claim?code=ABC123DEF456

The 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:

RouteMethods advertised
/v1/checkoutPOST, OPTIONS
/v1/licenses/by-sessionGET, 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:

StatusBodyMeaning
400invalid_jsonA body was sent and is not valid JSON. An empty body reads as {}.
413payload_too_largeOver 256 KiB.
404Not foundNo route for that hostname, method and path.
500Internal errorAn 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.

Something wrong or missing on this page? Tell us