Documentation
API integration guide
This is the single document you need to wire any application — a desktop app, a CLI, a mobile app, a server, a Discord bot, a game — to the KeyStack public API. It covers everything from creating the API key on the dashboard, through HMAC signing, to handling refunds, offline behaviour, and rotation.
If you are using TypeScript / JavaScript, the official @keystack/client SDK does all of the signing, retries, and error mapping for you in 6 lines of code. For any other language, follow the steps below.
All KeyStack public endpoints are served from
https://api.keystack.dev/v1/*. Self-hosters substitute their own base URL.
1. Set up your application
KeyStack is multi-tenant. You and your customers live in organizations; an organization contains applications; an application owns its own license plans, API keys, customers, and licenses.
- Sign up at keystack.dev/signup (or your self-hosted URL).
- From the dashboard, click Applications → New application. Give it a name (e.g.
Acme Studio) and a slug. - Open the new application's Plans tab and create at least one license plan (e.g.
basic,pro). A plan defines the default duration, max activations, and price. - Open the API keys tab and create a key. You'll see the raw
ak_live_…public id and ask_live_…secret. Copy the secret now — it is never shown again. - Choose a scope for the key:
FULL— everything (recommended for trusted backends).ISSUE_ONLY— can mint licenses but cannot list or modify.VALIDATE_ONLY— can validate / activate / heartbeat but cannot mint.READ_ONLY— can list but cannot mutate.
You now have:
Public id: ak_live_4f3c…
Secret: sk_live_8b9a… ← keep secret
Base URL: https://api.keystack.dev/v12. Sign your requests (HMAC-SHA256)
Every public API call sends three headers in addition to its JSON body:
| Header | Value |
|---|---|
Authorization | Bearer {publicId} |
X-KeyStack-Timestamp | Unix timestamp (seconds or milliseconds) |
X-KeyStack-Signature | hex(HMAC-SHA256(secret, "{timestamp}.{body}")) |
The signature is computed over the literal string {timestamp}.{body} — i.e. the unix timestamp, a dot, and the exact bytes of the request body. For payload-less requests use an empty body string.
Rules:
- Server tolerates a 5-minute clock skew (
HMAC_REPLAY_WINDOW_MS = 300_000). - Each
(public id, signature)pair is one-shot — re-using a signature within 10 minutes returns403 API_TIMESTAMP_REPLAY(Redis-backed nonce store). - The signature is hex-encoded lowercase SHA-256.
- Both seconds and millisecond timestamps are accepted; we recommend seconds for portability.
2.1 — cURL (good for testing)
SECRET="sk_live_…"
PUBLIC="ak_live_…"
BODY='{"key":"KS-7F3K9-9XYLM-4N2A1-Q7C9F-WT2K","fingerprint":"machine-uuid"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex \
| awk '{print $2}')
curl https://api.keystack.dev/v1/validate \
-H "Authorization: Bearer $PUBLIC" \
-H "X-KeyStack-Timestamp: $TS" \
-H "X-KeyStack-Signature: $SIG" \
-H "Content-Type: application/json" \
-d "$BODY"2.2 — Node.js / TypeScript (without the SDK)
import { createHmac } from 'node:crypto';
async function callKeyStack(path, body) {
const PUBLIC = process.env.KEYSTACK_PUBLIC_KEY;
const SECRET = process.env.KEYSTACK_SECRET_KEY;
const ts = Math.floor(Date.now() / 1000);
const payload = JSON.stringify(body);
const sig = createHmac('sha256', SECRET).update(ts + '.' + payload).digest('hex');
const res = await fetch('https://api.keystack.dev/v1' + path, {
method: 'POST',
headers: {
authorization: 'Bearer ' + PUBLIC,
'x-keystack-timestamp': String(ts),
'x-keystack-signature': sig,
'content-type': 'application/json',
},
body: payload,
});
if (!res.ok) throw new Error('KeyStack ' + path + ' failed: ' + res.status);
return res.json();
}2.3 — Python 3
import hmac
import hashlib
import json
import time
import urllib.request
PUBLIC = "ak_live_..."
SECRET = b"sk_live_..." # bytes
def call(path, body):
ts = str(int(time.time()))
payload = json.dumps(body, separators=(",", ":"))
sig = hmac.new(SECRET, (ts + "." + payload).encode(), hashlib.sha256).hexdigest()
req = urllib.request.Request(
"https://api.keystack.dev/v1" + path,
data=payload.encode(),
headers={
"authorization": "Bearer " + PUBLIC,
"x-keystack-timestamp": ts,
"x-keystack-signature": sig,
"content-type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())2.4 — Go
package keystack
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
func Call(path string, body interface{}) ([]byte, error) {
payload, _ := json.Marshal(body)
ts := strconv.FormatInt(time.Now().Unix(), 10)
mac := hmac.New(sha256.New, []byte("sk_live_..."))
mac.Write([]byte(ts + "." + string(payload)))
sig := hex.EncodeToString(mac.Sum(nil))
req, _ := http.NewRequest("POST", "https://api.keystack.dev/v1"+path, bytes.NewReader(payload))
req.Header.Set("authorization", "Bearer ak_live_...")
req.Header.Set("x-keystack-timestamp", ts)
req.Header.Set("x-keystack-signature", sig)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return nil, fmt.Errorf("keystack %s http %d", path, res.StatusCode)
}
return io.ReadAll(res.Body)
}2.5 — C# / .NET 8
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public sealed class KeyStack
{
private readonly HttpClient http;
private const string Base = "https://api.keystack.dev/v1";
private const string Public = "ak_live_...";
private static readonly byte[] Secret = Encoding.UTF8.GetBytes("sk_live_...");
public KeyStack(HttpClient http) { this.http = http; }
public async Task CallAsync(string path, object body)
{
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var payload = JsonSerializer.Serialize(body);
using var mac = new HMACSHA256(Secret);
var sigBytes = mac.ComputeHash(Encoding.UTF8.GetBytes(ts + "." + payload));
var sig = Convert.ToHexString(sigBytes).ToLowerInvariant();
using var req = new HttpRequestMessage(HttpMethod.Post, Base + path)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
req.Headers.Add("authorization", "Bearer " + Public);
req.Headers.Add("x-keystack-timestamp", ts);
req.Headers.Add("x-keystack-signature", sig);
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
}
}2.6 — PHP 8
<?php
function ks_call($path, $body) {
$publicKey = 'ak_live_...';
$secret = 'sk_live_...';
$ts = (string) time();
$payload = json_encode($body, JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $ts . '.' . $payload, $secret);
$ch = curl_init('https://api.keystack.dev/v1' . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'authorization: Bearer ' . $publicKey,
'x-keystack-timestamp: ' . $ts,
'x-keystack-signature: ' . $sig,
'content-type: application/json',
],
]);
$resp = curl_exec($ch);
if (curl_errno($ch)) throw new RuntimeException(curl_error($ch));
return json_decode($resp, true);
}2.7 — Rust (reqwest + hmac)
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
type HmacSha256 = Hmac<Sha256>;
pub async fn call(path: &str, body: &serde_json::Value) -> reqwest::Result<serde_json::Value> {
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().to_string();
let payload = serde_json::to_string(body).unwrap();
let mut mac = HmacSha256::new_from_slice(b"sk_live_...").unwrap();
mac.update(format!("{ts}.{payload}").as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
reqwest::Client::new()
.post(format!("https://api.keystack.dev/v1{path}"))
.header("authorization", "Bearer ak_live_...")
.header("x-keystack-timestamp", ts)
.header("x-keystack-signature", sig)
.header("content-type", "application/json")
.body(payload)
.send()
.await?
.json()
.await
}3. The five endpoints
All endpoints are POST, accept and return application/json, and are mounted under /v1/.
POST /v1/issue — mint a new license
Use this from your billing pipeline (Stripe webhook, manual order, etc). Requires a key with FULL or ISSUE_ONLY scope.
// Request
{
"planCode": "basic",
"customer": {
"email": "alice@example.com",
"name": "Alice Doe",
"externalId": "stripe_cus_…",
"metadata": { "tier": "early-bird" }
},
"duration": "365d",
"maxActivations": 3,
"notes": "Imported from Stripe order #ABC",
"metadata": { "campaign": "spring-2026" }
}// Response — 201
{
"id": "ckxz1ab2c0000abcd",
"key": "KS-7F3K9-9XYLM-4N2A1-Q7C9F-WT2K",
"status": "ACTIVE",
"expiresAt": "2027-05-15T20:00:00.000Z",
"customer": { "id": "cus_8a3f…", "email": "alice@example.com" }
}Notes:
durationaccepts"lifetime", an ISO-8601 expiry like"2027-12-31T00:00:00Z", or a relative"{n}d"like"365d".maxActivationsanddurationfall back to the plan's defaults when omitted.- Store the returned
keyand email it to the customer. The raw key is never shown again — KeyStack stores only an Argon2 hash + a SHA-256 lookup index.
POST /v1/validate — does this key work?
The hot path your product calls on startup or refresh. Rate-limited to 30 req/sec/key by default and Redis-cached for 15 seconds. Sets Cache-Control: private, max-age=15.
// Request
{
"key": "KS-7F3K9-9XYLM-4N2A1-Q7C9F-WT2K",
"fingerprint": "5c2b...c7e3"
}// Response — 200
{
"valid": true,
"status": "ACTIVE",
"expiresAt": "2027-05-15T20:00:00.000Z",
"plan": { "code": "basic", "name": "Basic", "features": {} },
"customer": { "id": "cus_8a3f…", "email": "alice@example.com" },
"activations": { "used": 1, "max": 3 }
}status is one of ACTIVE, EXPIRED, REVOKED, FROZEN, or INACTIVE. Invalid keys return 200 with "valid": false and a reason field. Do not treat them as 4xx — a missing key is not a 404, it just doesn't validate.
POST /v1/activate — bind this key to this device
Call once on first run after the user enters their key. KeyStack dedupes by (licenseId, fingerprint) so calling twice from the same device is idempotent and won't consume an extra activation slot.
// Request
{
"key": "KS-7F3K9-9XYLM-4N2A1-Q7C9F-WT2K",
"fingerprint": "5c2b...c7e3",
"hostname": "alice-mbp",
"os": "darwin 14.5",
"metadata": { "appVersion": "2.1.0" }
}// Response — 201
{
"activation": {
"id": "act_…",
"fingerprint": "5c2b...c7e3",
"activatedAt": "2026-05-16T10:00:00.000Z"
},
"activations": { "used": 1, "max": 3 }
}If max activations are already used, you'll get 409 LICENSE_QUOTA_EXCEEDED. Tell the user to deactivate another device on their account page.
POST /v1/deactivate — free an activation slot
// By fingerprint
{ "key": "KS-…", "fingerprint": "5c2b...c7e3" }// Or by activationId
{ "key": "KS-…", "activationId": "act_…" }// Response — 200
{ "deactivated": true, "activations": { "used": 0, "max": 3 } }POST /v1/heartbeat — keep the activation fresh + survive offline
Call once a day (or however often makes sense for your product). Refreshes lastSeenAt and extends the offline grace window — clients that lose connectivity can validate locally against the last known good response until the grace window closes.
// Request
{ "key": "KS-…", "fingerprint": "5c2b...c7e3" }// Response — 200
{
"ok": true,
"lastSeenAt": "2026-05-16T11:23:00.000Z",
"offlineGraceUntil": "2026-05-23T11:23:00.000Z"
}4. Error envelope
Every 4xx / 5xx response is a JSON document with this shape:
{
"error": {
"code": "AUTH_TOKEN_EXPIRED",
"message": "Access token expired",
"statusCode": 401,
"requestId": "01J…ABCDEF",
"details": {}
}
}Common codes you'll see on the public API:
| Code | Meaning | What to do |
|---|---|---|
API_KEY_INVALID | Bad Authorization header or unknown public id | Check rotation / re-copy from dashboard |
API_KEY_REVOKED | Key was revoked or expired | Rotate |
API_SIGNATURE_INVALID | Signature doesn't match timestamp + body | Re-check your signing code |
API_TIMESTAMP_INVALID | Header missing / not a number / over 5min skew | Sync your clock |
API_TIMESTAMP_REPLAY | This (key, signature) was already used in the last 10 min | Generate a new request |
RATE_LIMITED | You hit the per-key sliding-window rate limit | Back off; the Retry-After header tells you when |
LICENSE_NOT_FOUND | Key doesn't exist | Check the key |
LICENSE_QUOTA_EXCEEDED | maxActivations reached on /activate | Have the user deactivate another device |
VALIDATION_ERROR | Body failed Zod validation | Inspect details for field-level errors |
Always log requestId — KeyStack's structured logger correlates it across api/web, so support can find the exact trace in seconds.
5. Wire it up to your billing system
KeyStack does not charge your customers — you do. Three common patterns:
A. Stripe checkout → auto-issue license
Drop the KeyStack webhook URL into your Stripe webhook config and KeyStack will mint a license on checkout.session.completed. See Stripe → KeyStack.
B. Manual fulfilment
After your fulfilment script confirms the order, call /v1/issue server-to-server. Email the returned key to the customer.
C. Crypto / NowPayments
Self-hosters can enable NowPayments in /admin/settings. Customers see a "Pay with crypto" tab on /billing. KeyStack provisions the subscription when NowPayments fires its IPN.
6. Build a robust client
Your product's licensing layer should be defensive. Recommended pattern (pseudo-code):
async function ensureLicensed(key, fingerprint) {
try {
const res = await keystack.validate({ key, fingerprint });
cache.write({
validAt: Date.now(),
offlineGraceUntil: res.valid ? Date.now() + 7 * DAY : 0,
payload: res,
});
return res.valid ? 'ok' : 'invalid';
} catch (err) {
const cached = cache.read();
if (cached && cached.offlineGraceUntil > Date.now()) {
return cached.payload.valid ? 'ok-offline' : 'invalid';
}
return 'offline-grace-expired';
}
}Notes:
- Persist the cache to disk so a brief network outage doesn't lock the user out.
- Heartbeat every 24h to keep
offlineGraceUntilrolling forward. - Always verify the key checksum locally (Crockford Base32 + last-block HMAC-SHA256) before even touching the network — this saves a round trip for typos.
import { verifyKey } from '@keystack/shared';
if (!verifyKey(userInput)) {
return 'malformed-key';
}7. Rotating, revoking, monitoring
- Rotate keys in the dashboard's API keys tab. The old key keeps working until you explicitly revoke it, so deploy first then revoke.
- Watch for failures in
/admin/api-log(super-admin) or/apps/{slug}/analytics(org owner). Spikes inAPI_SIGNATURE_INVALIDusually mean a stale secret in production. - Revoke compromised keys immediately — once revoked they fail all calls within the global Redis cache window (≤ 15 s). The dashboard rotates audit logs in the platform audit feed.
8. SDKs
For TypeScript/JavaScript use the official @keystack/client — it handles signing, retries, and typed errors. For any other language follow this guide; the protocol is intentionally simple.
If you build an SDK for another language, open an issue and we'll link it here.