Keywarden Docs
Everything shipped so far: quickstart, the SDK API, signed entitlement bundles, the Android SDK, license keys, and the Management API & CLI — copy-paste ready.
Quickstart
From zero to a verified purchase in four steps.
1. Sign in and create an app
Sign in at app.keywarden.dev with a magic link — no password. Create an application and enter its Android package name. Keywarden generates the app's ECDSA P-256 signing key pair and a public API key; both are shown once, on the app's Setup page.
created slug=my-app public key (embed in the app): pk_live_9f2xK1cQ3mZp… signing key (pin in the app): kid=k1 SPKI (base64) MFkwEwYHKoZI…
Prefer the terminal? kw apps create --name "My App" --slug my-app --android-package com.example.app prints the same two values (see CLI).
Note: the Android SDK is available today as source (sdks/android in the repository) — its Maven Central release is still pending, see Install. The Apple SDK is coming soon. Everything the SDKs do is also available over the HTTP API (see SDK API v1).
2. Pin the SPKI in your app
Store the SPKI (base64) as a constant and pass it, together with the public API key, to the SDK config. The SDK verifies every entitlement bundle against this key offline — no server round trip after the first sync.
// app/build.gradle.kts — until the Maven Central release, resolve these through a // composite build or mavenLocal() (see Android SDK → Install) implementation("dev.keywarden:keywarden-core:1.0.0") implementation("dev.keywarden:keywarden-play-billing:1.0.0")
// Application.onCreate Keywarden.configure( Config.Builder(this) .apiKey("pk_live_9f2xK1cQ3mZp…") .baseUrl("https://api.keywarden.dev") .publicKey("k1", "MFkwEwYHKoZI…") // pinned; verifies offline .appSlug("my-app") .build() )
3. Sync the device
Call /v1/devices/sync on every launch. The first call registers the device and returns a signed bundle; later calls are the heartbeat that keeps license seats and gift thresholds alive.
Keywarden.get().sync(object : SyncCallback {
override fun onResult(verdict: Verdict, error: KeywardenException?) {
// main thread · GRANTED, or UNKNOWN (keep what the user has, retry later)
}
})
4. Ask for an entitlement
Every call reports one of three verdicts, never a silent lockout: GRANTED, REJECTED (the store/license said no), UNKNOWN (network problem, server error, rate limit — keep what you already had and retry later). The question "may the user do this?" is answered locally and offline:
if (Keywarden.get().has("pro_unlock")) unlockPro() // synchronous, offline, main-thread safe
Next: what each verdict does to stored rights and wiring Google Play Billing.
Authentication model
Three credentials, three very different trust levels:
| Credential | Visibility | Used for |
|---|---|---|
pk_live_… | Public | Identifies the app. Embed it in the client binary; send it as Authorization: Bearer pk_live_… on every SDK API v1 call. |
sk_live_… | Secret — server/CLI only | The Management API key: create/read apps, entitlements, licenses, devices, audit log. Never ship it inside a client app. |
installId | Secret — per install | A random UUID the client itself generates for this install and keeps private (see below). |
pk_live_… only identifies the app — it grants nothing by itself; security comes from server-authoritative decisions, the signed bundle, Play Integrity, and rate limits.
installId is the device's secret capability: anyone holding pk_live_… plus a device's installId can act as that device — pull its bundle, spend its meters. So the SDK must:
- generate it as a random UUID (never a hardware id, e-mail, ad id, or anything guessable) — at least 8 characters,
- store it in app-private, non-shared storage (Android: private
SharedPreferences/DataStore; Apple: Keychain) and never log it or put it in a URL, - reject any incoming bundle whose
devclaim is not its owninstallId— otherwise another device's bundle could be copied in to grant entitlements here.
SDK API v1
Base URL https://api.keywarden.dev/v1. Every call (except /v1/openapi.json) needs Authorization: Bearer pk_live_…. Contract: GET /v1/openapi.json — no auth required to fetch the schema itself.
| Endpoint | What it does |
|---|---|
POST /v1/store/verify | Verifies a store purchase, records it, returns a signed bundle. The body is a discriminated union: store:"play" takes purchaseToken, store:"apple" takes signedTransaction (the StoreKit 2 JWS). |
POST /v1/devices/sync | Heartbeat + current entitlements. Pass ownedStoreProducts + storeQueryOk:true so purchases the store no longer lists get marked unlisted. |
POST /v1/meters/consume | Consumes (or with peek:true, just reads) a per-device metered feature / trial counter. |
POST /v1/licenses/activate | Activates a license key for this device (takes a seat). |
POST /v1/licenses/validate | Reads a license's status and seats without taking one. |
POST /v1/licenses/deactivate | Releases this device's seat on a license. |
GET /v1/keys | This app's verification keys (JWKS-like: kid, x/y, PEM). |
GET /v1/health | Server version, database reachability, Google Play voided-purchase cache freshness. |
GET /v1/openapi.json | The OpenAPI contract for this API. |
POST /v1/store/verify
curl -X POST https://api.keywarden.dev/v1/store/verify \
-H 'authorization: Bearer pk_live_9f2xK1cQ3mZp' \
-H 'content-type: application/json' \
-d '{
"store": "play", "productId": "pro_unlock",
"purchaseToken": "opaque-token-from-play-billing", "kind": "inapp",
"device": { "installId": "6a1b9c2e-4b7a-4e2b-9c1a-0f2d8e6b7a10", "platform": "android" }
}'
// 200
{ "ok": true,
"purchase": { "state": 0, "productId": "pro_unlock", "expiresAt": null },
"bundle": "eyJ2IjoyLCJhcHAiOi….MEUCIQ…",
"support": { "totalMicros": 0, "currency": "USD", "next": null, "paidTotal": null },
"integrity": null }
POST /v1/devices/sync
curl -X POST https://api.keywarden.dev/v1/devices/sync \
-H 'authorization: Bearer pk_live_9f2xK1cQ3mZp' \
-H 'content-type: application/json' \
-d '{
"device": { "installId": "6a1b9c2e-4b7a-4e2b-9c1a-0f2d8e6b7a10", "platform": "android", "appVersion": "1.4.0" },
"ownedStoreProducts": ["pro_unlock"], "storeQueryOk": true, "kid": "k1"
}'
// 200
{ "ok": true,
"bundle": "eyJ2IjoyLCJhcHAiOi….MEUCIQ…",
"entitlements": [ { "id": "pro_unlock", "src": "store", "pid": "pro_unlock", "exp": null } ],
"support": { "totalMicros": 0, "currency": "USD", "next": null, "paidTotal": null },
"activations": [], "serverTime": 1767225600000 }
POST /v1/meters/consume
curl -X POST https://api.keywarden.dev/v1/meters/consume \
-H 'authorization: Bearer pk_live_9f2xK1cQ3mZp' \
-H 'content-type: application/json' \
-d '{ "feature": "export_pdf", "device": { "installId": "6a1b9c2e-4b7a-4e2b-9c1a-0f2d8e6b7a10" }, "peek": false }'
// 200
{ "ok": true, "feature": "export_pdf", "limit": 3, "used": 1, "left": 2, "allowed": true, "period": "lifetime", "resetsAt": null }
POST /v1/licenses/activate · validate · deactivate
# activate — takes a seat (idempotent per device) curl -X POST https://api.keywarden.dev/v1/licenses/activate \ -H 'authorization: Bearer pk_live_9f2xK1cQ3mZp' -H 'content-type: application/json' \ -d '{ "key": "KW-7F3K-9QRT-2MXP-VBC4", "device": { "installId": "6a1b9c2e-4b7a-4e2b-9c1a-0f2d8e6b7a10" } }' // 200 { "ok": true, "activation": { "id": "act_1a2b3c", "licensePrefix": "KW-7F3K", "expiresAt": null, "seats": { "used": 1, "max": 3 } }, "bundle": "eyJ2IjoyLCJhcHAiOi….MEUCIQ…" } # validate — reads status, never takes a seat curl -X POST https://api.keywarden.dev/v1/licenses/validate \ -H 'authorization: Bearer pk_live_9f2xK1cQ3mZp' -H 'content-type: application/json' \ -d '{ "key": "KW-7F3K-9QRT-2MXP-VBC4" }' // 200 { "ok": true, "license": { "status": "active", "expiresAt": null, "seats": { "used": 1, "max": 3 }, "entitlements": ["pro_unlock"] }, "activated": false, "bundle": null } # deactivate — releases this device's seat curl -X POST https://api.keywarden.dev/v1/licenses/deactivate \ -H 'authorization: Bearer pk_live_9f2xK1cQ3mZp' -H 'content-type: application/json' \ -d '{ "key": "KW-7F3K-9QRT-2MXP-VBC4", "device": { "installId": "6a1b9c2e-4b7a-4e2b-9c1a-0f2d8e6b7a10" } }' // 200 { "ok": true, "released": 1 }
GET /v1/keys · /v1/health
curl https://api.keywarden.dev/v1/keys -H 'authorization: Bearer pk_live_9f2xK1cQ3mZp' // 200 { "ok": true, "keys": [ { "kid": "k1", "alg": "ES256", "kty": "EC", "crv": "P-256", "x": "…", "y": "…", "pem": "-----BEGIN PUBLIC KEY-----…", "status": "active" } ] } curl https://api.keywarden.dev/v1/health -H 'authorization: Bearer pk_live_9f2xK1cQ3mZp' // 200 { "ok": true, "version": "0.3.0", "db": true, "stores": { "play": { "voidedSyncedOk": true, "count": 128, "ageSeconds": 340 } } }
Errors & rate limits
Success looks like { "ok": true, … }. Failure always looks like:
{ "ok": false, "error": { "code": "NO_SEATS_AVAILABLE", "message": "bu lisansin tum koltuklari kullanimda" },
"seats": { "used": 3, "max": 3 } }
(state is added only for PURCHASE_INVALID/VOIDED; seats only for NO_SEATS_AVAILABLE.)
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION | 400 / 413 | Malformed request body (413: body larger than 64 KB). |
UNAUTHORIZED | 401 | Missing or invalid pk_live_… bearer token. |
PURCHASE_INVALID | 402 | The store didn't recognize the purchase token. |
VOIDED | 402 | The purchase was refunded/revoked. |
INTEGRITY_FAILED | 403 | Play Integrity check denied the request. |
NOT_FOUND | 404 | Unknown metered feature or license key. |
NO_SEATS_AVAILABLE | 409 | All seats on this license are in use. |
DEVICE_LIMIT_REACHED | 409 | This purchase token is already active on too many devices. Nothing is written; existing devices keep their entitlements. |
LICENSE_REVOKED | 410 | Activation refused: the license was revoked. |
LICENSE_EXPIRED | 410 | Activation refused: the license's expiry passed. |
LICENSE_SUSPENDED | 410 | Activation refused: the license is suspended. |
RATE_LIMITED | 429 | Too many requests; response carries Retry-After. |
INTERNAL | 500 | Unexpected server error (e.g. the app has no active signing key) — treat as unknown. |
STORE_UNREACHABLE | 502 | Google Play (or the Play Integrity check) couldn't be reached — treat as unknown, keep the last good state. |
Only 402 PURCHASE_INVALID/VOIDED and 410 LICENSE_* mean "no". Every other failure — including 409 — means "couldn't tell" and must never remove a right the device already holds. The Android SDK maps these exactly; see Verdicts.
Rate limits: 60 requests/min per IP, 600/min per app (across all its devices), and — on licenses/activate|validate|deactivate only — 10 attempts/min per license key and IP (keyed by a hash of the key, not the plaintext).
Entitlement bundle (token v2)
Format: base64url(JSON payload) "." base64url(DER ECDSA P-256 / SHA-256 signature) — deliberately no header and no alg field. The algorithm is fixed, so algorithm-confusion attacks are structurally impossible.
{ "v": 2, "app": "keyflow", "dev": "6a1b9c2e-4b7a-4e2b-9c1a-0f2d8e6b7a10", "kid": "k1",
"ents": [ { "id": "pro_unlock", "src": "store", "pid": "pro_unlock", "exp": null } ],
"iat": 1757000000, "exp": 1757604800 }
ents[].src is one of store, license, gift. exp is null for entitlements that don't expire on their own.
Verification rules:
- Pin the SPKI you got at app creation as your first trusted key.
- Look up the bundle's
kidamong your pinned/synced keys (GET /v1/keys). Unknownkid= the bundle is invalid — don't fall back to trying another key. - A valid signature with an
expin the past is not the same as forged — it's just stale. Don't delete it or flag the device; keep it and re-sync. A bundle's lifetime is the app's token TTL (default 7 days), which is the offline window: the Android SDK stops granting from a stale bundle (after a 60-second clock-skew leeway) but keeps it, and the next successful sync replaces it. - Reject any bundle whose
devisn't your owninstallId(see Authentication model).
The Android SDK applies all four rules for you. To verify by hand:
Kotlin (java.security.Signature)
// java.util.Base64 needs API 26+; the Android SDK ships its own codec for minSdk 23
val dot = token.indexOf('.')
val body = token.substring(0, dot).toByteArray(Charsets.US_ASCII)
val sig = Base64.getUrlDecoder().decode(token.substring(dot + 1))
val pub = KeyFactory.getInstance("EC")
.generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(spkiB64)))
val ok = Signature.getInstance("SHA256withECDSA").run {
initVerify(pub); update(body); verify(sig)
}
Swift (CryptoKit)
import CryptoKit
import Foundation
func base64URLDecode(_ s: Substring) -> Data? {
var b64 = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
while b64.count % 4 != 0 { b64 += "=" }
return Data(base64Encoded: b64)
}
let parts = token.split(separator: ".", maxSplits: 1)
let body = Data(parts[0].utf8)
let sigDer = base64URLDecode(parts[1])!
let sig = try P256.Signing.ECDSASignature(derRepresentation: sigDer)
let key = try P256.Signing.PublicKey(derRepresentation: Data(base64Encoded: spkiB64)!)
let ok = key.isValidSignature(sig, for: body)
kw inspect-token <token> --pk pk_live_… decodes and verifies a bundle from the terminal.
License keys
A second entitlement source alongside store purchases. Format KW-XXXX-XXXX-XXXX-XXXX: a 16-character Crockford base32 body (no I/L/O/U — nothing to confuse when read over the phone), 80 bits of randomness.
Issue. Panel: Licenses → Issue licenses (pick entitlements, seat count, floating + heartbeat TTL, expiry, customer e-mail, how many keys). Or the CLI:
kw keys issue --app my-app --ent pro_unlock --seats 3 --count 2 --email [email protected] kw keys issue --app my-app --ent pro_unlock --seats 5 --floating --ttl 600 --expires 2027-01-31
The plaintext key is shown once, at issue time. Afterwards, an owner can retrieve it again from the license's detail page via Reveal key (audited).
Seats. A license can be active on maxActivations devices at once.
- Fixed (default) — a seat is held until the device deactivates or an operator revokes the license.
- Floating — a seat frees automatically after
heartbeatTtlSecondswithout async/validate, either lazily (when the next device needs a seat) or via a background sweep every 10 minutes. - Reactivating from the same device never opens a new seat — it's idempotent, only the heartbeat is refreshed.
- Seats full →
409 NO_SEATS_AVAILABLE(body carriesseats: { used, max }).
Status. active / suspended / revoked, plus expired once expiresAt has passed. Any status but active makes activate return 410, and the device drops its src:"license" entitlement on the next sync — no grace, no partial trust. validate is a query, not a claim: it answers 200 with license.status for every status, including revoked.
Client flow. activate returns a fresh bundle on success (or null if the device ends up with no entitlements) — store it and call sync on every launch afterward. Rights are dropped by the next signed bundle, which no longer lists the license's rows. A 410 is a rejection; a network error is UNKNOWN, never treated as one (three-state model: GRANTED / REJECTED / UNKNOWN). In the Android SDK: License keys in the app.
iOS & macOS
The Apple SDK is a SwiftPM package with three libraries: Keywarden (zero dependencies — Foundation, CryptoKit, Security), KeywardenStoreKit (StoreKit 2 bridge) and KeywardenTesting (a server-free double). iOS 15+ / macOS 12+.
import Keywarden
let configuration = try Configuration(
apiKey: "pk_live_…",
baseURL: URL(string: "https://api.keywarden.dev")!,
publicKeys: ["k1": "<SPKI base64>"],
appSlug: "your-app"
)
let client = Keywarden.configure(configuration)
if client.has("pro_unlock") { /* offline-safe, never blocks */ }
StoreKit 2. StoreKitSync(client:source:) scans currentEntitlements at launch, listens to Transaction.updates, and verifies each signed transaction through POST /v1/store/verify with store:"apple". It calls finish() only after the server accepted the transaction — a network error leaves it replayable instead of destroying a purchase the user paid for.
Semantics match Android exactly: the same pinned-key bundle verification, the same three-state model (GRANTED / REJECTED / UNKNOWN), the same revokeLocal deny-list, the same clock-rollback floor, the same secret installId. Both SDKs are tested against the same fixture tokens generated by the server's own signing code.
Server setup and the full integration guide live in the SDK's own README; the notification URL you paste into App Store Connect is shown on the app's Stores page — see Apple App Store setup.
Apple App Store setup
Open the app's Stores page and fill the Apple App Store card:
- In App Store Connect, create an In-App Purchase key (Users and Access → Integrations → In-App Purchase). Paste its Issuer ID, Key ID and the downloaded
.p8. The key is stored encrypted and is never returned by the API. - Copy the Server Notifications V2 URL the panel shows into App Store Connect (your app → App Information → App Store Server Notifications). Sandbox and Production have separate fields; use the same URL for both.
- Leave Accept Sandbox purchases on until the app is live. App Review tests your production build against the sandbox, so turning it off can get the app rejected.
Keywarden verifies each transaction's JWS against Apple Root CA G3 offline, then confirms it against the App Store Server API when the stored row is new or older than 24 hours. If Apple is unreachable the signed transaction is trusted (the connection's last error is recorded) — except for an already-expired subscription, where the answer genuinely depends on Apple and the server returns 502 STORE_UNREACHABLE rather than revoking a paying user's access.
Refunds, revocations and renewals arrive as App Store Server Notifications V2. Each notification is processed exactly once (notificationUUID) and out-of-order deliveries cannot overwrite a newer event.
Google Play setup
Open the app's Stores page and upload the Google Play Console service account JSON (needs at least the View financial data permission). Use the Test button to confirm the credentials work before relying on them in production.
Keywarden polls Play's voided-purchases list in the background so refunds are caught automatically. If that list becomes unreachable, GET /v1/health reports it (voidedSyncedOk: false, plus the cache's ageSeconds) instead of silently letting refunds through — and paying users are never locked out just because the cache is temporarily stale.
Management API & CLI
sk_live_… keys are created under Organization → API keys in the panel (or POST /api/v1/orgs/{orgId}/api-keys). The full Management API schema is published at api.keywarden.dev/api/v1/openapi.json — organizations, apps, entitlements/products, metered features, spend rules, store connections, devices, licenses, signing keys, and per-app overview/SDK-snippet endpoints.
kw CLI
| Command | What it does |
|---|---|
kw login --key sk_live_… | Authenticates and saves a profile. |
kw apps ls [--org <id|slug>] | Lists the organization's apps. |
kw apps create --name <n> --slug <s> [--android-package <pkg>] | Creates an app; prints its pk_live_… and signing key. |
kw devices ls --app <slug> [--platform …] [--since 7d|30d] | Lists an app's devices. |
kw keys issue --app <slug> --ent <key> […] | Issues one or more license keys. |
kw keys ls --app <slug> [--status …] [--q …] | Lists license keys (prefix only, never the plaintext). |
kw keys revoke --app <slug> <licenseId> | Permanently revokes a license. |
kw selftest --app <slug> --pk pk_live_… [--license-key KW-…] | End-to-end smoke test against the SDK API. |
kw inspect-token <token> [--pk pk_live_…] | Decodes and verifies a v1 or v2 bundle. |