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.

  1. Sign up at keystack.dev/signup (or your self-hosted URL).
  2. From the dashboard, click Applications → New application. Give it a name (e.g. Acme Studio) and a slug.
  3. 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.
  4. Open the API keys tab and create a key. You'll see the raw ak_live_… public id and a sk_live_… secret. Copy the secret now — it is never shown again.
  5. 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/v1

2. Sign your requests (HMAC-SHA256)

Every public API call sends three headers in addition to its JSON body:

HeaderValue
AuthorizationBearer {publicId}
X-KeyStack-TimestampUnix timestamp (seconds or milliseconds)
X-KeyStack-Signaturehex(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 returns 403 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:

  • duration accepts "lifetime", an ISO-8601 expiry like "2027-12-31T00:00:00Z", or a relative "{n}d" like "365d".
  • maxActivations and duration fall back to the plan's defaults when omitted.
  • Store the returned key and 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:

CodeMeaningWhat to do
API_KEY_INVALIDBad Authorization header or unknown public idCheck rotation / re-copy from dashboard
API_KEY_REVOKEDKey was revoked or expiredRotate
API_SIGNATURE_INVALIDSignature doesn't match timestamp + bodyRe-check your signing code
API_TIMESTAMP_INVALIDHeader missing / not a number / over 5min skewSync your clock
API_TIMESTAMP_REPLAYThis (key, signature) was already used in the last 10 minGenerate a new request
RATE_LIMITEDYou hit the per-key sliding-window rate limitBack off; the Retry-After header tells you when
LICENSE_NOT_FOUNDKey doesn't existCheck the key
LICENSE_QUOTA_EXCEEDEDmaxActivations reached on /activateHave the user deactivate another device
VALIDATION_ERRORBody failed Zod validationInspect 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 offlineGraceUntil rolling 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 in API_SIGNATURE_INVALID usually 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.