TWELVETAKE VAULT

Developer Guide

A comprehensive guide for integrating TwelveTake Vault license management into your desktop, web, or server application.

1. Getting Started

What is TwelveTake Vault?

TwelveTake Vault is a license key management system that handles license creation, activation, validation, and revocation for your software products. It supports multiple license types (perpetual, subscription, trial, floating), machine-locked activations, heartbeat monitoring, usage metering, and auto-update distribution.

Base URL

All API requests are made against your Vault instance:

https://vault.example.com

Authentication

Vault uses two authentication methods depending on the context:

API Key Authentication (Server-to-Server and Desktop Apps)

For server-side integrations (e.g., your backend creating licenses after a purchase) and desktop app validation, pass the API key via the X-API-Key header:

X-API-Key: tvk_your_api_key_here

API keys are created in the Vault admin dashboard under Settings > API Keys. Each key has a scope that controls what it can do:

Scope Access
VALIDATE_ONLYActivate, validate, deactivate, check, heartbeat, checkout/checkin, record usage, validate entitlements
CREATE_LICENSEAll of VALIDATE_ONLY + create, revoke, renew licenses + list products + order lookup
READ_ONLYAll of VALIDATE_ONLY + read licenses and products
FULL_ADMINFull CRUD access to all resources

Important: The raw API key is shown only once at creation time. Store it securely.

API keys can optionally be restricted by:

  • IP allowlist — limit which server IPs can use the key
  • Expiration date — auto-expire after a set date

License Key Authentication (Update Distribution)

For the auto-update system, your desktop app authenticates using the end-user's license key via the X-License-Key header:

X-License-Key: XX-ABCD-EFGH-IJKL-MNOP

This is used exclusively for update check and download endpoints (see Section 7).

Request Format

All POST/PATCH requests use JSON:

Content-Type: application/json

Response Format

All responses follow a consistent structure:

{
  "success": true,
  "message": "Human-readable description",
  "license": { ... },
  "activation": { ... }
}

Error responses:

{
  "success": false,
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable description"
  }
}

2. License Lifecycle

Key Concepts

License Types:

Type Description
PERPETUALNever expires. Valid forever once issued.
SUBSCRIPTIONExpires on a set date. Must be renewed.
TRIALTime-limited evaluation license.
BETAPre-release access license.
FLOATINGConcurrent seat-based license (see Section 5).

License Statuses:

Status Description
ACTIVELicense is valid and usable
EXPIREDPast the validUntil date
SUSPENDEDTemporarily disabled by admin
REVOKEDPermanently disabled (e.g., refund)

License Tiers:

Tier Description
PERSONALIndividual use
PROFESSIONALProfessional/small team use
BUSINESSBusiness/team use
ENTERPRISEEnterprise/unlimited use

Machine Fingerprinting:

Every activation is bound to a machineId — a stable, unique identifier for the device. The ID must be 8–255 characters. Common approaches:

  • Desktop apps: Hardware UUID, motherboard serial, or a combination of hardware identifiers
  • Web apps: User account UUID (1 key = 1 account)
  • Plugins: Host application instance ID

The ID should remain consistent across app restarts and updates.

The License Lifecycle Flow

Create --> Activate --> Validate --> Deactivate
   |                       |
   |                    Renew (subscription)
   |
Revoke (refund)

Create a License

Create licenses via your server after a purchase. Requires an API key with CREATE_LICENSE scope.

curl:

curl -X POST https://vault.example.com/api/v1/license/create \
  -H "Content-Type: application/json" \
  -H "X-API-Key: tvk_your_api_key" \
  -d '{
    "productId": "prod_myapp",
    "customerEmail": "jane@example.com",
    "customerName": "Jane Smith",
    "type": "PERPETUAL",
    "tier": "PERSONAL",
    "maxActivations": 2,
    "orderId": "ORD-2026-0042",
    "metadata": {
      "source": "web-store"
    }
  }'

TypeScript:

const response = await fetch('https://vault.example.com/api/v1/license/create', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': process.env.VAULT_API_KEY!,
  },
  body: JSON.stringify({
    productId: 'prod_myapp',
    customerEmail: 'jane@example.com',
    customerName: 'Jane Smith',
    type: 'PERPETUAL',
    tier: 'PERSONAL',
    maxActivations: 2,
    orderId: order.id,
  }),
});

const data = await response.json();
// data.license.key = "XX-ABCD-EFGH-IJKL-MNOP" -- display this to the customer

Request fields:

Field Type Required Description
productIdstringYesVault product ID (max 30 chars)
customerEmailstringYesCustomer email address (max 255 chars)
customerNamestringNoCustomer display name (max 255 chars)
typeenumNoPERPETUAL, SUBSCRIPTION, TRIAL, BETA, FLOATING (default: product default)
tierenumNoPERSONAL, PROFESSIONAL, BUSINESS, ENTERPRISE (default: PERSONAL)
maxActivationsnumberNo1–1000 (default: product default)
validUntildatetimeNoISO 8601 expiration date (null = perpetual)
orderIdstringNoExternal order reference (max 255 chars)
metadataobjectNoCustom key-value data (max 20 keys, key max 100 chars, value max 1000 chars)

Response (201):

{
  "success": true,
  "license": {
    "id": "clx123...",
    "key": "XX-ABCD-EFGH-IJKL-MNOP",
    "productId": "prod_myapp",
    "type": "PERPETUAL",
    "tier": "PERSONAL",
    "status": "ACTIVE",
    "maxActivations": 2,
    "validUntil": null,
    "createdAt": "2026-03-08T15:00:00.000Z"
  }
}

Notes:

  • The key field is the actual license key string — store it and display it to the customer.
  • If the customer email already exists, the existing customer record is used. Otherwise, a new one is created.
  • A license key email is sent to the customer if SMTP is configured on the tenant.
  • Fires the LICENSE_CREATED webhook event.

Idempotency: Include an X-Idempotency-Key header to prevent duplicate licenses on retry:

X-Idempotency-Key: ORD-2026-0042:prod_myapp

If a license already exists with this idempotency key, the existing license is returned with "idempotent": true and HTTP 200 (instead of 201).

Bulk Create

Create up to 20 licenses in a single request:

const response = await fetch('https://vault.example.com/api/v1/license/create-bulk', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': process.env.VAULT_API_KEY!,
    'X-Idempotency-Key': `order-${order.id}`,
  },
  body: JSON.stringify({
    licenses: [
      {
        productId: 'prod_editor',
        customerEmail: 'jane@example.com',
        type: 'PERPETUAL',
        orderId: order.id,
      },
      {
        productId: 'prod_plugins',
        customerEmail: 'jane@example.com',
        type: 'SUBSCRIPTION',
        validUntil: '2027-03-08T00:00:00.000Z',
        orderId: order.id,
      },
    ],
  }),
});

const data = await response.json();
// data.results[0].license.key, data.results[1].license.key

Returns 201 if all succeed, 207 Multi-Status if some fail. Results are in the same order as the input array. For bulk creates, the idempotency key is appended with the item index (:0, :1, etc.) automatically.

Activate a License

Activate a license on the user's machine. Creates an activation record binding the license to this device.

curl:

curl -X POST https://vault.example.com/api/v1/license/activate \
  -H "Content-Type: application/json" \
  -H "X-API-Key: tvk_your_api_key" \
  -d '{
    "licenseKey": "XX-ABCD-EFGH-IJKL-MNOP",
    "machineId": "a1b2c3d4e5f6g7h8",
    "machineName": "Jane'\''s Laptop",
    "osName": "Windows",
    "osVersion": "11",
    "appVersion": "1.2.0"
  }'

TypeScript:

const result = await fetch('https://vault.example.com/api/v1/license/activate', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': API_KEY,
  },
  body: JSON.stringify({
    licenseKey: licenseKey,
    machineId: getMachineFingerprint(),
    machineName: os.hostname(),
    osName: os.platform(),
    osVersion: os.release(),
    appVersion: APP_VERSION,
  }),
});

const data = await result.json();

if (data.success) {
  console.log('Activated:', data.activation.machineId);
  console.log('Slots used:', data.license.activationsUsed, '/', data.license.maxActivations);
} else {
  console.error(data.error.code, data.error.message);
}

Request fields:

Field Type Required Description
licenseKeystringYesThe license key (10–100 chars)
machineIdstringYesUnique hardware fingerprint (8–255 chars)
machineNamestringNoFriendly machine name (max 255 chars)
osNamestringNoOperating system name (max 100 chars)
osVersionstringNoOS version (max 100 chars)
appVersionstringNoApplication version (max 50 chars)

Success Response (200):

{
  "success": true,
  "message": "License activated successfully",
  "license": {
    "key": "XX-ABCD-EFGH-IJKL-MNOP",
    "product": "MyApp",
    "tier": "PERSONAL",
    "type": "PERPETUAL",
    "validUntil": null,
    "activationsUsed": 1,
    "maxActivations": 2
  },
  "activation": {
    "machineId": "a1b2c3d4e5f6g7h8",
    "machineName": "Jane's Laptop",
    "activatedAt": "2026-03-08T14:30:00.000Z"
  }
}

Notes:

  • If the machine is already activated, the existing activation is returned (idempotent).
  • Expired, suspended, or revoked licenses cannot be activated.
  • When maxActivations is reached, the error code is ACTIVATION_FAILED with a descriptive message.

Validate a License

Check if a license is valid for a specific machine. Read-only — does not create or modify anything.

curl:

curl -X POST https://vault.example.com/api/v1/license/validate \
  -H "Content-Type: application/json" \
  -H "X-API-Key: tvk_your_api_key" \
  -d '{
    "licenseKey": "XX-ABCD-EFGH-IJKL-MNOP",
    "machineId": "a1b2c3d4e5f6g7h8"
  }'

TypeScript:

const result = await fetch('https://vault.example.com/api/v1/license/validate', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': API_KEY,
  },
  body: JSON.stringify({
    licenseKey: licenseKey,
    machineId: getMachineFingerprint(),
  }),
});

const data = await result.json();

if (data.valid) {
  // License is valid on this machine
  const tier = data.license.tier;
  const features = data.license.features;
} else {
  // License invalid -- data.message explains why
  handleInvalidLicense(data.status, data.message);
}

Request fields:

Field Type Required Description
licenseKeystringYesThe license key (10–100 chars)
machineIdstringYesHardware fingerprint to check (8–255 chars)
appVersionstringNoCurrent app version (max 50 chars)

Response (200):

{
  "success": true,
  "valid": true,
  "status": "ACTIVE",
  "message": "License is valid and activated on this machine",
  "license": {
    "key": "XX-ABCD-EFGH-IJKL-MNOP",
    "product": "MyApp",
    "tier": "PERSONAL",
    "type": "PERPETUAL",
    "validUntil": null,
    "activationsUsed": 1,
    "maxActivations": 2
  },
  "activation": {
    "machineId": "a1b2c3d4e5f6g7h8",
    "machineName": "Jane's Laptop",
    "activatedAt": "2026-03-08T14:30:00.000Z"
  }
}

Important: The validate endpoint always returns HTTP 200. Use the valid field (not the HTTP status code) to determine whether to grant access.

The response may include additional fields for specific license types:

  • grace / graceExpiresAt — present when the license is in a grace period
  • versionAllowed — present when version restrictions apply
  • floating — seat info for floating licenses
  • usage — usage info for metered licenses

Deactivate a License

Remove a license activation from a machine, freeing the activation slot for use elsewhere.

curl:

curl -X POST https://vault.example.com/api/v1/license/deactivate \
  -H "Content-Type: application/json" \
  -H "X-API-Key: tvk_your_api_key" \
  -d '{
    "licenseKey": "XX-ABCD-EFGH-IJKL-MNOP",
    "machineId": "a1b2c3d4e5f6g7h8"
  }'

Response (200):

{
  "success": true,
  "message": "License deactivated from this machine"
}

Quick Status Check

Check a license's status without a machine ID. Useful for lightweight checks.

curl:

curl https://vault.example.com/api/v1/license/check/XX-ABCD-EFGH-IJKL-MNOP \
  -H "X-API-Key: tvk_your_api_key"

Response:

{
  "success": true,
  "status": "ACTIVE",
  "type": "PERPETUAL",
  "tier": "PERSONAL",
  "maxVersion": null,
  "features": null,
  "licensingMode": "STANDARD"
}

The licensingMode is either "STANDARD" (full enforcement) or "HONOR_SYSTEM" (client SDK skips enforcement). Set per product in the admin dashboard.

Renew a License

Extend a subscription license's expiration date. Requires CREATE_LICENSE scope.

const result = await fetch('https://vault.example.com/api/v1/license/renew', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': process.env.VAULT_API_KEY!,
  },
  body: JSON.stringify({
    licenseKey: 'XX-ABCD-EFGH-IJKL-MNOP',
    validUntil: '2027-03-08T00:00:00.000Z',
  }),
});

You can pass either licenseId or licenseKey (one is required). Expired licenses are automatically set back to ACTIVE. Revoked licenses cannot be renewed (returns 400).

Revoke a License

Permanently revoke a license (e.g., after a refund). All active machine activations are deactivated immediately. Requires CREATE_LICENSE scope.

const result = await fetch('https://vault.example.com/api/v1/license/revoke', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': process.env.VAULT_API_KEY!,
  },
  body: JSON.stringify({
    licenseKey: 'XX-ABCD-EFGH-IJKL-MNOP',
    reason: 'Order refunded',
  }),
});

Lookup by Order ID

Retrieve all licenses associated with an external order reference. Requires CREATE_LICENSE scope.

curl https://vault.example.com/api/v1/license/by-order/ORD-2026-0042 \
  -H "X-API-Key: tvk_your_api_key"

Returns an array of licenses (up to 100) or an empty array if no match.

Validate Entitlements

Check if a specific feature/entitlement is available on a license. Entitlements can come from either the license's features JSON field or the policy's entitlement set.

const result = await fetch('https://vault.example.com/api/v1/license/validate-entitlement', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': API_KEY,
  },
  body: JSON.stringify({
    key: 'XX-ABCD-EFGH-IJKL-MNOP',
    entitlement: 'export_pdf',
  }),
});

const data = await result.json();
if (data.entitled) {
  // Feature is available
  const featureValue = data.value; // May contain a configuration value (e.g., max resolution)
}

Response:

{
  "valid": true,
  "entitled": true,
  "value": null
}

The value field may contain an associated value for the entitlement (e.g., a numeric limit or configuration string). valid indicates whether the license itself is active; entitled indicates whether the specific entitlement is present.


3. Desktop App Integration

This section provides a complete pattern for integrating Vault into a desktop application (Electron, Tauri, native, etc.).

Integration Architecture

[Desktop App]
     |
     +--> First Launch: Prompt for license key --> Activate
     |
     +--> Every Launch: Validate license (with offline fallback)
     |
     +--> Periodic: Revalidate every 4-24 hours
     |
     +--> Uninstall: Deactivate license

Complete Integration Module

import os from 'os';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';

const VAULT_BASE_URL = 'https://vault.example.com';
const VAULT_API_KEY = 'tvk_your_embedded_key'; // VALIDATE_ONLY scope
const APP_VERSION = '1.2.0';
const OFFLINE_GRACE_DAYS = 14;
const REVALIDATION_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours

// ---------------------------------------------------------------------------
// Machine Fingerprint
// ---------------------------------------------------------------------------

/**
 * Generate a stable machine fingerprint.
 * Combine multiple hardware identifiers for resilience.
 */
function getMachineFingerprint(): string {
  const components = [
    os.hostname(),
    os.cpus()[0]?.model || '',
    os.totalmem().toString(),
    os.platform(),
    os.arch(),
    // Add your own hardware ID sources (e.g., disk serial, MAC address)
  ];

  return crypto
    .createHash('sha256')
    .update(components.join('|'))
    .digest('hex')
    .substring(0, 32);
}

// ---------------------------------------------------------------------------
// Local Cache (encrypted on disk)
// ---------------------------------------------------------------------------

interface ValidationCache {
  valid: boolean;
  tier: string;
  type: string;
  features: Record<string, unknown> | null;
  checkedAt: number; // Unix timestamp (ms)
}

const CACHE_PATH = path.join(os.homedir(), '.myapp', 'license.cache');

function saveCache(cache: ValidationCache): void {
  fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
  // In production, encrypt this with a machine-specific key
  fs.writeFileSync(CACHE_PATH, JSON.stringify(cache), 'utf-8');
}

function loadCache(): ValidationCache | null {
  try {
    const raw = fs.readFileSync(CACHE_PATH, 'utf-8');
    return JSON.parse(raw);
  } catch {
    return null;
  }
}

function clearCache(): void {
  try { fs.unlinkSync(CACHE_PATH); } catch { /* ok */ }
}

// ---------------------------------------------------------------------------
// API Calls
// ---------------------------------------------------------------------------

interface LicenseResult {
  authorized: boolean;
  tier?: string;
  type?: string;
  features?: Record<string, unknown> | null;
  reason?: string;
  grace?: boolean;
}

async function activateLicense(licenseKey: string): Promise<LicenseResult> {
  const machineId = getMachineFingerprint();

  const response = await fetch(`${VAULT_BASE_URL}/api/v1/license/activate`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': VAULT_API_KEY,
    },
    body: JSON.stringify({
      licenseKey,
      machineId,
      machineName: os.hostname(),
      osName: os.platform(),
      osVersion: os.release(),
      appVersion: APP_VERSION,
    }),
  });

  const data = await response.json();

  if (data.success) {
    const cache: ValidationCache = {
      valid: true,
      tier: data.license.tier,
      type: data.license.type,
      features: data.license.features ?? null,
      checkedAt: Date.now(),
    };
    saveCache(cache);
    return { authorized: true, tier: cache.tier, type: cache.type, features: cache.features };
  }

  return {
    authorized: false,
    reason: data.error?.message || 'Activation failed',
  };
}

async function validateLicense(licenseKey: string): Promise<LicenseResult> {
  const machineId = getMachineFingerprint();

  try {
    const response = await fetch(`${VAULT_BASE_URL}/api/v1/license/validate`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': VAULT_API_KEY,
      },
      body: JSON.stringify({
        licenseKey,
        machineId,
        appVersion: APP_VERSION,
      }),
    });

    const data = await response.json();

    if (data.valid) {
      const cache: ValidationCache = {
        valid: true,
        tier: data.license.tier,
        type: data.license.type,
        features: data.license.features ?? null,
        checkedAt: Date.now(),
      };
      saveCache(cache);
      return {
        authorized: true,
        tier: cache.tier,
        type: cache.type,
        features: cache.features,
        grace: data.grace,
      };
    }

    clearCache();
    return { authorized: false, reason: data.message };
  } catch (error) {
    // Network failure -- fall back to offline cache
    return handleOfflineFallback();
  }
}

function handleOfflineFallback(): LicenseResult {
  const cache = loadCache();

  if (!cache || !cache.valid) {
    return { authorized: false, reason: 'No cached validation. Internet required.' };
  }

  const daysSinceCheck = (Date.now() - cache.checkedAt) / (1000 * 60 * 60 * 24);

  if (daysSinceCheck > OFFLINE_GRACE_DAYS) {
    return {
      authorized: false,
      reason: `Offline for ${Math.floor(daysSinceCheck)} days. Connect to the internet to revalidate.`,
    };
  }

  // Still within grace period
  return {
    authorized: true,
    tier: cache.tier,
    type: cache.type,
    features: cache.features,
    grace: true,
  };
}

async function deactivateLicense(licenseKey: string): Promise<boolean> {
  const machineId = getMachineFingerprint();

  try {
    const response = await fetch(`${VAULT_BASE_URL}/api/v1/license/deactivate`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': VAULT_API_KEY,
      },
      body: JSON.stringify({ licenseKey, machineId }),
    });

    const data = await response.json();
    clearCache();
    return data.success;
  } catch {
    return false;
  }
}

// ---------------------------------------------------------------------------
// App Lifecycle
// ---------------------------------------------------------------------------

let revalidationTimer: ReturnType<typeof setInterval> | null = null;

/**
 * Call on first launch when the user enters their license key.
 */
async function onFirstLaunch(licenseKey: string): Promise<LicenseResult> {
  return activateLicense(licenseKey);
}

/**
 * Call on every subsequent app launch.
 */
async function onAppStart(licenseKey: string): Promise<LicenseResult> {
  const result = await validateLicense(licenseKey);

  if (result.authorized) {
    startPeriodicRevalidation(licenseKey);
  }

  return result;
}

/**
 * Revalidate periodically while the app is running.
 */
function startPeriodicRevalidation(licenseKey: string): void {
  if (revalidationTimer) return;

  revalidationTimer = setInterval(async () => {
    const result = await validateLicense(licenseKey);
    if (!result.authorized) {
      onLicenseInvalidated(result.reason || 'License no longer valid');
    }
  }, REVALIDATION_INTERVAL_MS);
}

/**
 * Call on uninstall or when the user wants to move the license.
 */
async function onUninstall(licenseKey: string): Promise<void> {
  if (revalidationTimer) clearInterval(revalidationTimer);
  await deactivateLicense(licenseKey);
}

function onLicenseInvalidated(reason: string): void {
  // Show a dialog to the user, disable premium features, etc.
  console.warn('License invalidated:', reason);
}

Handling Activation Limit Exceeded

When a user has used all activation slots:

async function handleActivation(licenseKey: string): Promise<void> {
  const result = await activateLicense(licenseKey);

  if (!result.authorized && result.reason?.includes('Maximum activations reached')) {
    // Show dialog: "You've used all activation slots. Deactivate another machine
    // from your account page, or contact support."
    showActivationLimitDialog();
  }
}

4. Heartbeat System

Heartbeats allow your application to continuously signal that it is running on an activated machine. If heartbeats stop (e.g., the machine is powered off or the app crashes), Vault can automatically reclaim the activation slot.

How It Works

  1. A policy is configured in the admin dashboard with a heartbeatInterval (in minutes) and a heartbeatCullStrategy.
  2. The policy is linked to a product or individual licenses.
  3. Your app sends heartbeat pings at the configured interval.
  4. A background job on the server checks for stale activations every 5 minutes.
  5. If a machine misses its heartbeat window, the configured cull strategy is applied.

Cull Strategies

Strategy Behavior
DEACTIVATE_DEADDeactivates the stale activation, freeing the slot for reuse
SUSPEND_LICENSESuspends the entire license (requires admin intervention to reactivate)
NO_ACTIONLogs the event only (useful for monitoring before enforcing)

Sending Heartbeats

curl:

curl -X POST https://vault.example.com/api/v1/license/heartbeat \
  -H "Content-Type: application/json" \
  -H "X-API-Key: tvk_your_api_key" \
  -d '{
    "key": "XX-ABCD-EFGH-IJKL-MNOP",
    "machineId": "a1b2c3d4e5f6g7h8"
  }'

Response:

{
  "success": true,
  "nextHeartbeatAt": "2026-03-08T15:15:00.000Z"
}

The nextHeartbeatAt field tells your app when the next heartbeat should be sent. If no heartbeat policy is configured for this license, the field is omitted.

Error Conditions

Error Code Meaning
NOT_FOUNDLicense key does not exist
LICENSE_INACTIVELicense is not in ACTIVE status
NOT_ACTIVATEDLicense is not activated on this machine

Integration Example

class HeartbeatManager {
  private timer: ReturnType<typeof setTimeout> | null = null;
  private licenseKey: string;
  private machineId: string;
  private defaultIntervalMs: number;

  constructor(licenseKey: string, machineId: string, defaultIntervalMinutes = 5) {
    this.licenseKey = licenseKey;
    this.machineId = machineId;
    this.defaultIntervalMs = defaultIntervalMinutes * 60 * 1000;
  }

  start(): void {
    this.sendHeartbeat();
  }

  stop(): void {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
  }

  private async sendHeartbeat(): Promise<void> {
    try {
      const response = await fetch(`${VAULT_BASE_URL}/api/v1/license/heartbeat`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': VAULT_API_KEY,
        },
        body: JSON.stringify({
          key: this.licenseKey,
          machineId: this.machineId,
        }),
      });

      const data = await response.json();

      if (!data.success) {
        // License may have been revoked or machine deactivated
        this.onHeartbeatFailed(data.error?.code, data.error?.message);
        return;
      }

      // Schedule next heartbeat based on server's recommendation
      let nextMs = this.defaultIntervalMs;
      if (data.nextHeartbeatAt) {
        const serverNext = new Date(data.nextHeartbeatAt).getTime() - Date.now();
        // Send 30s before the server's deadline to avoid being culled
        nextMs = Math.max(serverNext - 30_000, 30_000);
      }

      this.timer = setTimeout(() => this.sendHeartbeat(), nextMs);
    } catch (error) {
      // Network failure -- retry after a shorter interval
      this.timer = setTimeout(() => this.sendHeartbeat(), 60_000); // Retry in 1 minute
    }
  }

  private onHeartbeatFailed(code?: string, message?: string): void {
    if (code === 'NOT_ACTIVATED') {
      // Machine was deactivated (possibly by heartbeat cull)
      // Prompt user to re-activate or exit
    } else if (code === 'LICENSE_INACTIVE') {
      // License was suspended or revoked
    }
  }
}

// Usage:
const heartbeat = new HeartbeatManager(licenseKey, getMachineFingerprint());
heartbeat.start();

// On app exit:
heartbeat.stop();

Rate Limits

Heartbeat requests have a dedicated rate limit: 120 requests per minute per API key. This is significantly higher than other endpoints to accommodate frequent heartbeats from many machines.

Webhook Events

When the heartbeat cull job detects a stale activation, it fires a HEARTBEAT_MISSED webhook with details about the affected machine, the applied strategy, and the customer's email.


5. Floating Licenses

Floating licenses allow a limited number of concurrent users rather than fixed machine activations. When a user opens the app, they "check out" a seat. When they close it, they "check in" the seat. If they forget to check in (crash, power loss), a lease expiry mechanism automatically reclaims the seat.

Concepts

  • Seat: One concurrent usage slot. A 5-seat floating license means up to 5 machines can run the software simultaneously.
  • Checkout: Claim a seat when the app starts.
  • Checkin: Release the seat when the app closes.
  • Lease: A time-limited hold on a seat. If not renewed, it expires automatically.
  • Lease Duration: How long a checkout is valid. Configurable from 60 seconds to 30 days. Default comes from the license policy's defaultLeaseDuration.

Checkout a Seat

curl:

curl -X POST https://vault.example.com/api/v1/license/checkout \
  -H "Content-Type: application/json" \
  -H "X-API-Key: tvk_your_api_key" \
  -d '{
    "key": "XX-ABCD-EFGH-IJKL-MNOP",
    "machineId": "a1b2c3d4e5f6g7h8",
    "machineName": "Jane'\''s Laptop",
    "leaseDuration": 3600
  }'

TypeScript:

const response = await fetch(`${VAULT_BASE_URL}/api/v1/license/checkout`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': VAULT_API_KEY,
  },
  body: JSON.stringify({
    key: licenseKey,
    machineId: getMachineFingerprint(),
    machineName: os.hostname(),
    leaseDuration: 3600, // 1 hour -- omit to use the policy default
  }),
});

const data = await response.json();

if (data.success) {
  console.log('Seat checked out');
  console.log('Lease expires at:', data.activation.leaseExpiresAt);
  console.log(`Seats: ${data.seatsUsed}/${data.seatsTotal} used, ${data.seatsAvailable} available`);
} else if (data.error?.code === 'SEAT_LIMIT_REACHED') {
  console.log(`All ${data.seatsTotal} seats are in use. Try again later.`);
}

Request fields:

Field Type Required Description
keystringYesLicense key (10–100 chars)
machineIdstringYesMachine identifier (8–255 chars)
machineNamestringNoFriendly machine name (max 255 chars)
leaseDurationnumberNoLease duration in seconds (60 to 2,592,000). Defaults to policy setting.
metadataobjectNoOptional metadata to attach to the checkout

Response:

{
  "success": true,
  "message": "Seat checked out",
  "activation": {
    "id": "act_abc123",
    "machineId": "a1b2c3d4e5f6g7h8",
    "leaseExpiresAt": "2026-03-08T16:00:00.000Z"
  },
  "seatsUsed": 3,
  "seatsTotal": 5,
  "seatsAvailable": 2
}

Notes:

  • If the same machine already has an active checkout, the lease is extended (idempotent).
  • Only FLOATING type licenses support checkout/checkin. Other types return error code NOT_FLOATING.

Checkin a Seat

Release the seat when the app closes:

const response = await fetch(`${VAULT_BASE_URL}/api/v1/license/checkin`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': VAULT_API_KEY,
  },
  body: JSON.stringify({
    key: licenseKey,
    machineId: getMachineFingerprint(),
  }),
});

const data = await response.json();
// data.seatsAvailable now reflects the freed seat

Lease Renewal and Automatic Expiry

For long-running sessions, periodically re-checkout to extend the lease before it expires:

class FloatingLicenseManager {
  private renewTimer: ReturnType<typeof setTimeout> | null = null;
  private licenseKey: string;
  private machineId: string;
  private leaseDurationSeconds: number;

  constructor(licenseKey: string, machineId: string, leaseDurationSeconds = 3600) {
    this.licenseKey = licenseKey;
    this.machineId = machineId;
    this.leaseDurationSeconds = leaseDurationSeconds;
  }

  async checkout(): Promise<boolean> {
    const response = await fetch(`${VAULT_BASE_URL}/api/v1/license/checkout`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': VAULT_API_KEY,
      },
      body: JSON.stringify({
        key: this.licenseKey,
        machineId: this.machineId,
        leaseDuration: this.leaseDurationSeconds,
      }),
    });

    const data = await response.json();

    if (data.success) {
      // Renew at 75% of lease duration to avoid expiry
      const renewMs = this.leaseDurationSeconds * 750; // 75% in ms
      this.renewTimer = setTimeout(() => this.checkout(), renewMs);
      return true;
    }

    return false;
  }

  async checkin(): Promise<void> {
    if (this.renewTimer) {
      clearTimeout(this.renewTimer);
      this.renewTimer = null;
    }

    await fetch(`${VAULT_BASE_URL}/api/v1/license/checkin`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': VAULT_API_KEY,
      },
      body: JSON.stringify({
        key: this.licenseKey,
        machineId: this.machineId,
      }),
    }).catch(() => {}); // Best-effort on shutdown
  }
}

// Usage:
const floating = new FloatingLicenseManager(licenseKey, getMachineFingerprint(), 3600);
await floating.checkout();

// On app close (e.g., beforeunload, SIGTERM):
await floating.checkin();

A background job runs every minute on the server to release expired leases. If a machine crashes without checking in, the seat is automatically freed once the lease expires. The LEASE_EXPIRED webhook event is fired for each expired lease.


6. Usage Metering

Track consumption-based usage against a license — API calls, renders, exports, credits, or any countable resource.

Recording Usage

curl:

curl -X POST https://vault.example.com/api/v1/license/record-usage \
  -H "Content-Type: application/json" \
  -H "X-API-Key: tvk_your_api_key" \
  -d '{
    "key": "XX-ABCD-EFGH-IJKL-MNOP",
    "feature": "api_calls",
    "quantity": 1
  }'

TypeScript:

const response = await fetch(`${VAULT_BASE_URL}/api/v1/license/record-usage`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': VAULT_API_KEY,
  },
  body: JSON.stringify({
    key: licenseKey,
    feature: 'pdf_export',    // Feature code -- identifies what was consumed
    quantity: 1,               // How many units (default: 1, max: 10,000)
    metadata: {                // Optional context
      documentId: 'doc_123',
      pages: 15,
    },
  }),
});

const data = await response.json();

if (data.success) {
  console.log(`Usage: ${data.usage.current}/${data.usage.max}`);
  console.log(`Remaining: ${data.usage.remaining}`);
  console.log(`${data.usage.percentUsed}% used`);
} else if (data.error?.code === 'USAGE_LIMIT_EXCEEDED') {
  console.log(`Usage limit reached: ${data.usage.current}/${data.usage.max}`);
}

Request fields:

Field Type Required Description
keystringYesLicense key (10–100 chars)
featurestringYesFeature code identifying the resource (1–100 chars)
quantitynumberNoUnits consumed (1–10,000, default: 1)
metadataobjectNoOptional context for the usage event

Response:

{
  "success": true,
  "message": "Usage recorded",
  "usage": {
    "current": 451,
    "max": 1000,
    "remaining": 549,
    "percentUsed": 45.1
  }
}

When max is null, usage is unlimited — tracked but not enforced. In that case, remaining and percentUsed are omitted.

Usage Limits

Usage limits are set on the license itself via the maxUses field. The currentUses counter is atomically incremented in a transaction to prevent race conditions. When the limit is reached, further record-usage calls return error code USAGE_LIMIT_EXCEEDED.

Usage Events

Vault fires webhook events at usage milestones:

Event Trigger
USAGE_RECORDEDEvery time usage is recorded
USAGE_WARNINGWhen usage crosses 80% of the limit
USAGE_EXHAUSTEDWhen usage reaches 100% of the limit
USAGE_RESETWhen the usage counter is reset by an admin

Feature-Based Metering

Use different feature codes to track consumption across multiple resources on a single license:

await recordUsage(licenseKey, 'api_calls', 1);
await recordUsage(licenseKey, 'pdf_exports', 1);
await recordUsage(licenseKey, 'storage_mb', 50);

The feature code is stored with each usage record and included in webhook payloads. The global currentUses/maxUses on the license tracks total aggregate usage. Feature-level limits can be managed through entitlements (see Validate Entitlements).


7. Auto-Update Integration

Vault includes a complete update distribution system with Ed25519 signature verification. Your desktop app can check for updates, download signed release bundles, and verify their authenticity.

Authentication

Update endpoints use license key authentication via the X-License-Key header (not X-API-Key). This ensures only licensed users can download updates. The license must be in ACTIVE status and belong to the same product as the release.

Check for Updates

curl:

curl "https://vault.example.com/api/v1/updates/check?current_version=1.2.0&channel=stable" \
  -H "X-License-Key: XX-ABCD-EFGH-IJKL-MNOP"

TypeScript:

const response = await fetch(
  `${VAULT_BASE_URL}/api/v1/updates/check?current_version=${APP_VERSION}&channel=stable`,
  {
    headers: {
      'X-License-Key': licenseKey,
    },
  },
);

const data = await response.json();

if (data.success && data.update_available) {
  console.log(`Update available: ${data.version}`);
  console.log(`Changelog: ${data.changelog}`);
  console.log(`Critical: ${data.is_critical}`);
  console.log(`Release ID: ${data.release_id}`);

  if (data.is_critical) {
    // Force update -- block app usage until updated
    await downloadAndApplyUpdate(data.release_id);
  } else {
    // Optional update -- show notification
    showUpdateNotification(data.version, data.changelog);
  }
} else {
  console.log('No updates available');
}

Query Parameters:

Parameter Required Description
current_versionYesYour app's current semver version (e.g., 1.2.0)
channelNoRelease channel: stable, beta, alpha, canary, nightly. Default: stable.

Get Release Manifest

Fetch detailed metadata about a specific release before downloading:

const response = await fetch(
  `${VAULT_BASE_URL}/api/v1/updates/manifest?release_id=${releaseId}`,
  {
    headers: { 'X-License-Key': licenseKey },
  },
);

const manifest = await response.json();

Response:

{
  "success": true,
  "version": "1.3.0",
  "filename": "myapp-1.3.0-win64.zip",
  "size": 52428800,
  "sha256": "a1b2c3d4...",
  "signature": "base64-encoded-ed25519-signature",
  "channel": "stable",
  "is_critical": false,
  "requires_migration": false,
  "published_at": "2026-03-08T12:00:00.000Z"
}

Download a Release

const response = await fetch(
  `${VAULT_BASE_URL}/api/v1/updates/download?release_id=${releaseId}&from_version=${APP_VERSION}`,
  {
    headers: { 'X-License-Key': licenseKey },
  },
);

// The response body is the binary file
const buffer = await response.arrayBuffer();

// Verification data is in the response headers
const sha256 = response.headers.get('X-Bundle-SHA256')!;
const signature = response.headers.get('X-Bundle-Signature')!;

Response headers:

Header Description
Content-Typeapplication/octet-stream
Content-DispositionFilename for the download
Content-LengthFile size in bytes
X-Bundle-SHA256SHA-256 hash of the file (hex)
X-Bundle-SignatureEd25519 signature (base64)

Verifying Downloads

Step 1: Verify SHA-256 integrity

import crypto from 'crypto';

function verifySha256(data: Buffer, expectedHash: string): boolean {
  const actualHash = crypto.createHash('sha256').update(data).digest('hex');
  return actualHash === expectedHash;
}

Step 2: Verify Ed25519 signature

The signature is computed over the SHA-256 hash (as a hex string), not over the raw file bytes.

/**
 * Verify an Ed25519 signature on a release bundle.
 */
function verifyUpdateSignature(
  sha256Hash: string,
  signature: string,
  publicKeyPem: string,
): boolean {
  return crypto.verify(
    null,                                    // Ed25519 doesn't use a separate digest
    Buffer.from(sha256Hash, 'hex'),          // Signed data: the hex SHA-256 hash
    publicKeyPem,                            // PEM-encoded Ed25519 public key
    Buffer.from(signature, 'base64'),        // Base64-encoded signature
  );
}

Fetching the Public Key

The Ed25519 public key for your product can be retrieved without authentication:

curl "https://vault.example.com/api/v1/updates/public-key?product=myapp&tenant=acme"

Response:

{
  "success": true,
  "public_key": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----"
}

Important: In production, embed the public key in your application binary at build time. Fetching it from the server at runtime creates a trust-on-first-use (TOFU) vulnerability — if an attacker controls DNS, they could serve a malicious key. The endpoint is primarily useful for initial setup and key rotation workflows.

Complete Auto-Update Flow

import crypto from 'crypto';
import fs from 'fs';

// Public key embedded at build time
const SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...your_key_here...
-----END PUBLIC KEY-----`;

async function checkAndApplyUpdate(
  licenseKey: string,
  currentVersion: string,
): Promise<boolean> {
  // 1. Check for update
  const checkResponse = await fetch(
    `${VAULT_BASE_URL}/api/v1/updates/check?current_version=${currentVersion}&channel=stable`,
    { headers: { 'X-License-Key': licenseKey } },
  );
  const checkData = await checkResponse.json();

  if (!checkData.success || !checkData.update_available) {
    return false; // No update available
  }

  // 2. Download the release
  const downloadResponse = await fetch(
    `${VAULT_BASE_URL}/api/v1/updates/download?release_id=${checkData.release_id}&from_version=${currentVersion}`,
    { headers: { 'X-License-Key': licenseKey } },
  );

  if (!downloadResponse.ok) {
    throw new Error(`Download failed: ${downloadResponse.status}`);
  }

  const bundleBuffer = Buffer.from(await downloadResponse.arrayBuffer());
  const expectedSha256 = downloadResponse.headers.get('X-Bundle-SHA256')!;
  const signature = downloadResponse.headers.get('X-Bundle-Signature')!;

  // 3. Verify SHA-256 integrity
  const actualSha256 = crypto.createHash('sha256').update(bundleBuffer).digest('hex');
  if (actualSha256 !== expectedSha256) {
    throw new Error('SHA-256 hash mismatch -- download corrupted');
  }

  // 4. Verify Ed25519 signature
  const signatureValid = crypto.verify(
    null,
    Buffer.from(actualSha256, 'hex'),
    SIGNING_PUBLIC_KEY,
    Buffer.from(signature, 'base64'),
  );

  if (!signatureValid) {
    throw new Error('Signature verification failed -- update may be tampered');
  }

  // 5. Save and apply the update
  const updatePath = '/tmp/myapp-update.bin';
  fs.writeFileSync(updatePath, bundleBuffer);
  applyUpdate(updatePath); // Your platform-specific update logic

  return true;
}

Rate Limits

Endpoint Limit
Check / Manifest60 requests per 15 minutes per IP
Download10 requests per hour per IP

8. Webhooks

Vault supports both outbound webhooks (Vault notifying your server) and inbound webhooks (payment processors notifying Vault).

Outbound Webhooks

Configure in the admin dashboard under Settings > Webhooks. Vault will POST to your URL when license events occur.

Event Types

Event Description
LICENSE_CREATEDNew license generated
LICENSE_ACTIVATEDLicense activated on a machine
LICENSE_DEACTIVATEDActivation removed from a machine
LICENSE_VALIDATEDLicense validation check performed
LICENSE_EXPIREDLicense reached expiration date
LICENSE_RENEWEDLicense expiration extended
LICENSE_SUSPENDEDLicense temporarily disabled
LICENSE_REVOKEDLicense permanently revoked
ACTIVATION_LIMIT_REACHEDActivation blocked (all slots used)
VALIDATION_FAILEDValidation returned invalid
ABUSE_FLAGGEDSuspicious usage detected
ABUSE_CLEAREDAbuse flag resolved
KEY_REPLACEDCompromised key replaced
HONEYPOT_TRIGGEREDHoneypot/canary key used
HEARTBEAT_MISSEDMachine missed heartbeat window
SEAT_CHECKED_OUTFloating seat claimed
SEAT_CHECKED_INFloating seat released
LEASE_EXPIREDFloating seat lease expired (auto-released)
USAGE_RECORDEDUsage metering event recorded
USAGE_WARNINGUsage crossed 80% threshold
USAGE_EXHAUSTEDUsage reached 100% of limit
USAGE_RESETUsage counter reset by admin

Payload Format

{
  "event": "LICENSE_ACTIVATED",
  "timestamp": "2026-03-08T14:30:00.000Z",
  "data": {
    "licenseId": "clx1234567890",
    "licenseKey": "XX-ABCD-EFGH-IJKL-MNOP",
    "customerEmail": "jane@example.com",
    "customerName": "Jane Smith",
    "status": "ACTIVE",
    "product": {
      "name": "MyApp",
      "slug": "myapp"
    },
    "details": {
      "machineId": "a1b2c3d4e5f6g7h8",
      "machineName": "Jane's Laptop"
    },
    "ipAddress": "203.0.113.42"
  }
}

Request Headers

Header Description
Content-Typeapplication/json
X-Webhook-Signaturesha256=<hex_digest> — HMAC-SHA256 of the raw body
X-Webhook-EventEvent name (e.g., LICENSE_ACTIVATED)
X-Webhook-AttemptDelivery attempt number (1, 2, or 3)
User-AgentTwelveTake-Vault/1.0

Verifying Webhook Signatures

Always verify signatures to ensure the request came from Vault and was not forged.

import crypto from 'crypto';
import express from 'express';

const WEBHOOK_SECRET = process.env.VAULT_WEBHOOK_SECRET!;

function verifyWebhookSignature(
  rawBody: string,
  signature: string,
  secret: string,
): boolean {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  const sigHex = signature.replace('sha256=', '');
  try {
    return crypto.timingSafeEqual(Buffer.from(sigHex), Buffer.from(expected));
  } catch {
    return false;
  }
}

const app = express();

// IMPORTANT: Use raw body for signature verification
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;
  const rawBody = req.body.toString();

  if (!verifyWebhookSignature(rawBody, signature, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const payload = JSON.parse(rawBody);
  const event = payload.event;

  switch (event) {
    case 'LICENSE_CREATED':
      handleNewLicense(payload.data);
      break;
    case 'LICENSE_REVOKED':
      handleRevocation(payload.data);
      break;
    case 'USAGE_WARNING':
      sendUsageWarningEmail(payload.data);
      break;
    // ... handle other events
  }

  // Respond with 200 quickly to avoid timeout
  res.status(200).send('OK');
});

Retry Policy

Attempt Delay
1Immediate
2~5 seconds
3~15 seconds
  • Only 5xx and network errors trigger retries. 4xx responses are not retried.
  • Each delivery attempt has a 10-second timeout.
  • After 20 consecutive failures across all events, the webhook is automatically disabled. Re-enable it in the admin dashboard after fixing the issue.
  • Webhook URLs are validated against SSRF protections (private IPs and DNS rebinding are blocked).

Inbound Webhooks (Payment Processors)

Vault can receive webhooks from payment processors to automatically create licenses when purchases complete. This eliminates the need for custom cart integration code.

Supported Processors

Processor Signature Verification Supported Events
StripeHMAC-SHA256 with timestamp (120s replay window)checkout.session.completed, payment_intent.succeeded
SquareHMAC-SHA256 with URL+bodypayment.completed
GenericHMAC-SHA256 via X-Webhook-Signature or X-Signature headerAny event containing completed, purchase, or payment
PayPalCertificate-basedNot yet implemented

Setup

  1. Create an inbound webhook configuration in the admin dashboard (Integrations page).
  2. Select the payment processor and enter your webhook signing secret.
  3. Map external product IDs (e.g., Stripe price IDs) to Vault products, specifying license type, tier, and max activations.
  4. Point your payment processor's webhook URL to:
    POST https://vault.example.com/api/v1/webhooks/inbound/:configId

No API key is required — authentication is via the processor's webhook signature.

How It Works

  1. Customer completes payment on your checkout page.
  2. Payment processor sends a webhook to Vault.
  3. Vault verifies the signature, parses the event, and matches line items to your product mappings.
  4. Licenses are created automatically with idempotency (duplicate webhook deliveries are safe).
  5. A license key email is sent to the customer if SMTP is configured.

Product Mapping Fields

Field Description
externalProductIdProduct/price ID from the payment processor (e.g., Stripe's price_xxx)
productIdThe Vault product to create a license for
licenseTypeLicense type: PERPETUAL, SUBSCRIPTION, TRIAL, BETA
licenseTierLicense tier: PERSONAL, PROFESSIONAL, BUSINESS, ENTERPRISE
maxActivationsMaximum device activations
durationDaysLicense validity in days (null for perpetual)

Idempotency

Events are tracked by processorEventId. If a payment processor retries the same event, Vault returns success without creating duplicate licenses.

Rate Limit

60 requests per minute per IP. Returns 200 OK on all responses (including signature failures) to prevent processor retry storms.


9. Customer Portal

Vault provides a customer self-service portal where end-users can view their licenses, manage machine activations, and update their profile. The portal uses a magic link authentication flow (no passwords).

Authentication Flow

1. Customer enters email
2. Vault sends a magic link email (valid 15 minutes)
3. Customer clicks link --> token verified --> session created
4. HttpOnly cookie set (vault_customer_token, 24-hour expiry)
5. All subsequent requests authenticated via cookie

Request a Magic Link

const response = await fetch('https://vault.example.com/api/customer/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'jane@example.com' }),
});

const data = await response.json();
// data.message = "If this email has an account, a sign-in link has been sent."
// Response is intentionally vague to prevent email enumeration

Verify Token

After the customer clicks the magic link in their email:

const response = await fetch('https://vault.example.com/api/customer/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token: tokenFromUrl }), // 64-char hex token from the URL
});

const data = await response.json();
// data.customer = { id, email, name, tenant: { name, brandName, primaryColor, logoUrl } }
// The vault_customer_token cookie is now set automatically

Authenticated Requests

All authenticated customer portal requests require:

  • The vault_customer_token cookie (set automatically by the browser after verify)
  • The X-Requested-With: XMLHttpRequest header (CSRF protection for cookie-authenticated requests)

Note: The CSRF header is only enforced on state-changing methods (POST, PATCH, DELETE). GET requests do not require it.

Available Endpoints

Get Current Customer

GET /api/customer/me

Returns customer info with tenant branding (name, brand name, logo, colors).

Update Profile

PATCH /api/customer/profile
{ "name": "Jane Smith-Doe" }

Only the customer's display name can be updated. Email is identity and cannot be changed.

List Licenses

GET /api/customer/licenses

Returns all licenses belonging to the customer, including product info and active activation count.

Response:

{
  "success": true,
  "licenses": [
    {
      "id": "clx...",
      "key": "XX-ABCD-EFGH-IJKL-MNOP",
      "product": { "id": "...", "name": "MyApp", "type": "DESKTOP" },
      "type": "PERPETUAL",
      "tier": "PERSONAL",
      "status": "ACTIVE",
      "activations": 1,
      "maxActivations": 2,
      "validFrom": "2026-01-01T00:00:00.000Z",
      "validUntil": null,
      "features": null,
      "createdAt": "2026-01-01T00:00:00.000Z"
    }
  ],
  "total": 1
}

Get License Detail

GET /api/customer/licenses/:id

Returns full license details including all active activations with machine names, OS info, and last-seen timestamps.

Self-Service Activation

POST /api/customer/licenses/:id/activate
{
  "machineId": "a1b2c3d4e5f6g7h8",
  "machineName": "Jane's New Laptop",
  "osName": "macOS",
  "osVersion": "15.0",
  "appVersion": "1.3.0"
}

Self-Service Deactivation

POST /api/customer/licenses/:id/deactivate
{ "machineId": "a1b2c3d4e5f6g7h8" }

Customers can only deactivate machines that belong to their own licenses.

Logout

POST /api/customer/logout      # Current session only
POST /api/customer/logout-all  # All sessions on all devices

Session Limits

  • Maximum 5 concurrent sessions per customer.
  • When a 6th session is created, the oldest session is automatically terminated.
  • Sessions expire after 24 hours.

10. Error Handling

Error Response Format

All errors follow a consistent structure:

{
  "success": false,
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable description"
  }
}

Common Error Codes

Code HTTP Status Description
API_KEY_REQUIRED401Missing X-API-Key header
INVALID_API_KEY401Key not found or invalid
API_KEY_REVOKED401Key has been revoked
API_KEY_EXPIRED401Key has passed its expiration date
IP_NOT_ALLOWED403Request IP not in the key's allowlist
INSUFFICIENT_SCOPE403API key lacks the required permission scope
VALIDATION_ERROR400Invalid request body (missing/malformed fields)
ACTIVATION_FAILED400License cannot be activated (expired, suspended, revoked, or limit reached)
DEACTIVATION_FAILED400No active activation found for this machine
LICENSE_EXPIRED400License has expired
LICENSE_SUSPENDED400License is temporarily disabled
LICENSE_REVOKED400License is permanently revoked
INVALID_KEY400License key format is invalid
INVALID_LICENSE401Invalid or inactive license key (update endpoints)
NOT_FOUND404License or resource does not exist
NOT_FLOATING400Checkout/checkin on a non-floating license
NOT_CHECKED_OUT400Checkin without an active checkout
SEAT_LIMIT_REACHED400All floating seats are in use
USAGE_LIMIT_EXCEEDED400Usage limit has been reached
NOT_ACTIVATED400Heartbeat for a machine that is not activated
LICENSE_INACTIVE400Heartbeat for an inactive license
RATE_LIMITED429Too many requests
SERVER_ERROR500Unexpected server error

Rate Limits Summary

Endpoint Category Limit Keyed By
Validation API (read: validate, check)60 / 15 minIP
Validation API (write: create, activate, deactivate, revoke, renew, checkout, checkin, record-usage)15 / 15 minAPI key
Heartbeat120 / minAPI key
Update check / manifest60 / 15 minIP
Update download10 / hourIP
Inbound webhooks60 / minIP
Customer portal (data)60 / minIP
Customer portal (auth)10 / 15 minIP

Rate Limit Response Headers:

Header Description
RateLimit-LimitMaximum requests in the current window
RateLimit-RemainingRequests remaining in the current window
RateLimit-ResetSeconds until the window resets

Recommended Retry Strategy

async function fetchWithRetry(
  url: string,
  init: RequestInit,
  maxRetries = 3,
): Promise<Response> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, init);

    // Don't retry client errors (4xx except 429) or success
    if (response.status !== 429 && response.status < 500) {
      return response;
    }

    if (attempt === maxRetries) return response; // Give up

    // Respect the RateLimit-Reset header if available
    const resetSeconds = parseInt(response.headers.get('RateLimit-Reset') ?? '0', 10);
    const backoff = resetSeconds > 0
      ? resetSeconds * 1000
      : Math.min(1000 * Math.pow(2, attempt), 60_000);

    // Add jitter to prevent thundering herd
    const jitter = Math.random() * 500;

    await new Promise(resolve => setTimeout(resolve, backoff + jitter));
  }

  throw new Error('Unreachable');
}

Best practices:

  1. Read the RateLimit-Reset header to determine wait time.
  2. Use exponential backoff with jitter for retries.
  3. Limit retries to 3–5 attempts.
  4. Track RateLimit-Remaining to proactively slow down before hitting limits.
  5. Never retry indefinitely — surface the error after max retries.

11. Security Best Practices

API Key Storage

Do:

  • Store API keys in environment variables or a secrets manager.
  • Use the most restrictive scope possible (VALIDATE_ONLY for client apps, CREATE_LICENSE only for server integrations).
  • Set IP allowlists on server-side API keys to restrict usage to known IPs.
  • Rotate API keys periodically via the admin dashboard.

Do not:

  • Hardcode API keys in source code.
  • Commit API keys to version control.
  • Log API keys in plaintext.
  • Use FULL_ADMIN scope when a narrower scope would suffice.
// Correct -- from environment
const API_KEY = process.env.VAULT_API_KEY!;

// Wrong -- hardcoded, will leak in version control
const API_KEY = 'tvk_abc123...';

License Key Handling

Never log full license keys. Mask them:

function maskKey(key: string): string {
  return key.slice(0, 8) + '...' + key.slice(-4);
}
console.log(`Validating ${maskKey(licenseKey)}`);
// Output: "Validating XX-ABCD-...MNOP"
  • Store the license key securely on the user's machine (OS keychain, encrypted config file — not plaintext).
  • Never transmit license keys in URL query parameters (they appear in server logs, browser history, and Referer headers). Use headers or POST bodies.
  • Treat license keys as secrets — they are the user's proof of purchase.

TLS Requirements

  • All API requests must use HTTPS in production.
  • TLS 1.2 or higher is required.
  • Never disable certificate validation in your HTTP client.

Webhook Signature Verification

Always verify webhook signatures. Never process a webhook payload without checking the X-Webhook-Signature header. Use timing-safe comparison:

import crypto from 'crypto';

function verifyWebhookSignature(
  rawBody: string,
  signature: string,
  secret: string,
): boolean {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const provided = signature.replace('sha256=', '');
  try {
    return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
  } catch {
    return false;
  }
}

Important: Verify against the raw request body string, not a re-serialized JSON object. Re-serialization can change whitespace and field ordering, causing the signature to fail.

Update Signature Verification

Always verify Ed25519 signatures on downloaded update bundles before applying them:

  1. Compute SHA-256 of the downloaded file.
  2. Compare with the X-Bundle-SHA256 response header.
  3. Verify the Ed25519 signature over the SHA-256 hash using the embedded public key.
  4. Only apply the update if all checks pass.

Embed the public key in your application binary at build time. Do not rely solely on fetching it from the server at runtime.

Desktop App Considerations

  • Obfuscate validation logic. An attacker with access to the binary should not be able to trivially patch out the license check. Consider code obfuscation and anti-tamper techniques.
  • Use a compiled language or package your app in a way that makes reverse engineering harder.
  • Validate on the server. Client-side checks can always be bypassed. Server validation is the authoritative check; local checks are for UX (offline fallback, reducing unnecessary network calls).
  • Cache validation results locally for offline use, but enforce a grace period (7–30 days) after which an online check is required.
  • Deactivate on uninstall. Call the deactivate endpoint during your uninstaller to free the activation slot. This is a best-effort operation — if the network is unavailable, the user can deactivate from the customer portal.
  • Generate stable machine fingerprints. If the fingerprint changes on minor hardware or OS updates, users will lose their activation unnecessarily. Combine multiple identifiers and use the ones most stable across updates.

Web App Considerations

  • Never expose API keys in client-side JavaScript. Proxy all Vault calls through your backend.
  • For web apps, use the user's account UUID as the machineId (one key = one account, not one machine).
  • Validate on your backend before granting access to premium features.

Quick Reference

All Public API Endpoints

Method Path Auth Scope Description
POST/api/v1/license/activateAPI KeyVALIDATE_ONLYActivate license on machine
POST/api/v1/license/validateAPI KeyVALIDATE_ONLYCheck license validity
POST/api/v1/license/deactivateAPI KeyVALIDATE_ONLYRemove machine activation
GET/api/v1/license/check/:keyAPI KeyVALIDATE_ONLYQuick status check
POST/api/v1/license/heartbeatAPI KeyVALIDATE_ONLYMachine heartbeat ping
POST/api/v1/license/checkoutAPI KeyVALIDATE_ONLYFloating seat checkout
POST/api/v1/license/checkinAPI KeyVALIDATE_ONLYFloating seat checkin
POST/api/v1/license/record-usageAPI KeyVALIDATE_ONLYRecord usage metering
POST/api/v1/license/validate-entitlementAPI KeyVALIDATE_ONLYCheck feature entitlement
POST/api/v1/license/createAPI KeyCREATE_LICENSECreate a new license
POST/api/v1/license/create-bulkAPI KeyCREATE_LICENSEBulk create (max 20)
GET/api/v1/license/by-order/:orderIdAPI KeyCREATE_LICENSELookup by order ID
POST/api/v1/license/revokeAPI KeyCREATE_LICENSERevoke a license
POST/api/v1/license/renewAPI KeyCREATE_LICENSERenew/extend a license
GET/api/v1/productsAPI KeyCREATE_LICENSEList products
GET/api/v1/updates/checkLicense KeyCheck for updates
GET/api/v1/updates/downloadLicense KeyDownload release bundle
GET/api/v1/updates/manifestLicense KeyGet release manifest
GET/api/v1/updates/public-keyNoneGet signing public key
POST/api/v1/webhooks/inbound/:configIdSignatureReceive payment webhooks
GET/api/healthNoneHealth check

Customer Portal Endpoints

Method Path Auth Description
POST/api/customer/loginNoneRequest magic link
POST/api/customer/verifyNoneVerify magic link token
GET/api/customer/meCookieGet customer info
PATCH/api/customer/profileCookieUpdate profile
GET/api/customer/licensesCookieList licenses
GET/api/customer/licenses/:idCookieLicense detail
POST/api/customer/licenses/:id/activateCookieSelf-service activate
POST/api/customer/licenses/:id/deactivateCookieSelf-service deactivate
POST/api/customer/logoutCookieLogout current session
POST/api/customer/logout-allCookieLogout all sessions