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

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

json
{
  "activationKey": "K7X2M-9QP4R-3BTVW-6HZND",
  "deviceCode": "0ABC-1DEF-2GHJ-3KM4",
  "appVersion": "0.5.0",
  "osVersion": "14.5",
  "label": "MacBook Pro"
}

#200 — activated

Response

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

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

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

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

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

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

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

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

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

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

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.

Something wrong or missing on this page? Tell us