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_ONLY | Activate, validate, deactivate, check, heartbeat, checkout/checkin, record usage, validate entitlements |
CREATE_LICENSE | All of VALIDATE_ONLY + create, revoke, renew licenses + list products + order lookup |
READ_ONLY | All of VALIDATE_ONLY + read licenses and products |
FULL_ADMIN | Full 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 |
|---|---|
PERPETUAL | Never expires. Valid forever once issued. |
SUBSCRIPTION | Expires on a set date. Must be renewed. |
TRIAL | Time-limited evaluation license. |
BETA | Pre-release access license. |
FLOATING | Concurrent seat-based license (see Section 5). |
License Statuses:
| Status | Description |
|---|---|
ACTIVE | License is valid and usable |
EXPIRED | Past the validUntil date |
SUSPENDED | Temporarily disabled by admin |
REVOKED | Permanently disabled (e.g., refund) |
License Tiers:
| Tier | Description |
|---|---|
PERSONAL | Individual use |
PROFESSIONAL | Professional/small team use |
BUSINESS | Business/team use |
ENTERPRISE | Enterprise/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 |
|---|---|---|---|
productId | string | Yes | Vault product ID (max 30 chars) |
customerEmail | string | Yes | Customer email address (max 255 chars) |
customerName | string | No | Customer display name (max 255 chars) |
type | enum | No | PERPETUAL, SUBSCRIPTION, TRIAL, BETA, FLOATING (default: product default) |
tier | enum | No | PERSONAL, PROFESSIONAL, BUSINESS, ENTERPRISE (default: PERSONAL) |
maxActivations | number | No | 1–1000 (default: product default) |
validUntil | datetime | No | ISO 8601 expiration date (null = perpetual) |
orderId | string | No | External order reference (max 255 chars) |
metadata | object | No | Custom 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
keyfield 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_CREATEDwebhook 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 |
|---|---|---|---|
licenseKey | string | Yes | The license key (10–100 chars) |
machineId | string | Yes | Unique hardware fingerprint (8–255 chars) |
machineName | string | No | Friendly machine name (max 255 chars) |
osName | string | No | Operating system name (max 100 chars) |
osVersion | string | No | OS version (max 100 chars) |
appVersion | string | No | Application 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
maxActivationsis reached, the error code isACTIVATION_FAILEDwith 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 |
|---|---|---|---|
licenseKey | string | Yes | The license key (10–100 chars) |
machineId | string | Yes | Hardware fingerprint to check (8–255 chars) |
appVersion | string | No | Current 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 periodversionAllowed— present when version restrictions applyfloating— seat info for floating licensesusage— 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
- A policy is configured in the admin dashboard with a
heartbeatInterval(in minutes) and aheartbeatCullStrategy. - The policy is linked to a product or individual licenses.
- Your app sends heartbeat pings at the configured interval.
- A background job on the server checks for stale activations every 5 minutes.
- If a machine misses its heartbeat window, the configured cull strategy is applied.
Cull Strategies
| Strategy | Behavior |
|---|---|
DEACTIVATE_DEAD | Deactivates the stale activation, freeing the slot for reuse |
SUSPEND_LICENSE | Suspends the entire license (requires admin intervention to reactivate) |
NO_ACTION | Logs 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_FOUND | License key does not exist |
LICENSE_INACTIVE | License is not in ACTIVE status |
NOT_ACTIVATED | License 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 |
|---|---|---|---|
key | string | Yes | License key (10–100 chars) |
machineId | string | Yes | Machine identifier (8–255 chars) |
machineName | string | No | Friendly machine name (max 255 chars) |
leaseDuration | number | No | Lease duration in seconds (60 to 2,592,000). Defaults to policy setting. |
metadata | object | No | Optional 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
FLOATINGtype licenses support checkout/checkin. Other types return error codeNOT_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 |
|---|---|---|---|
key | string | Yes | License key (10–100 chars) |
feature | string | Yes | Feature code identifying the resource (1–100 chars) |
quantity | number | No | Units consumed (1–10,000, default: 1) |
metadata | object | No | Optional 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_RECORDED | Every time usage is recorded |
USAGE_WARNING | When usage crosses 80% of the limit |
USAGE_EXHAUSTED | When usage reaches 100% of the limit |
USAGE_RESET | When 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_version | Yes | Your app's current semver version (e.g., 1.2.0) |
channel | No | Release 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-Type | application/octet-stream |
Content-Disposition | Filename for the download |
Content-Length | File size in bytes |
X-Bundle-SHA256 | SHA-256 hash of the file (hex) |
X-Bundle-Signature | Ed25519 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 / Manifest | 60 requests per 15 minutes per IP |
| Download | 10 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_CREATED | New license generated |
LICENSE_ACTIVATED | License activated on a machine |
LICENSE_DEACTIVATED | Activation removed from a machine |
LICENSE_VALIDATED | License validation check performed |
LICENSE_EXPIRED | License reached expiration date |
LICENSE_RENEWED | License expiration extended |
LICENSE_SUSPENDED | License temporarily disabled |
LICENSE_REVOKED | License permanently revoked |
ACTIVATION_LIMIT_REACHED | Activation blocked (all slots used) |
VALIDATION_FAILED | Validation returned invalid |
ABUSE_FLAGGED | Suspicious usage detected |
ABUSE_CLEARED | Abuse flag resolved |
KEY_REPLACED | Compromised key replaced |
HONEYPOT_TRIGGERED | Honeypot/canary key used |
HEARTBEAT_MISSED | Machine missed heartbeat window |
SEAT_CHECKED_OUT | Floating seat claimed |
SEAT_CHECKED_IN | Floating seat released |
LEASE_EXPIRED | Floating seat lease expired (auto-released) |
USAGE_RECORDED | Usage metering event recorded |
USAGE_WARNING | Usage crossed 80% threshold |
USAGE_EXHAUSTED | Usage reached 100% of limit |
USAGE_RESET | Usage 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-Type | application/json |
X-Webhook-Signature | sha256=<hex_digest> — HMAC-SHA256 of the raw body |
X-Webhook-Event | Event name (e.g., LICENSE_ACTIVATED) |
X-Webhook-Attempt | Delivery attempt number (1, 2, or 3) |
User-Agent | TwelveTake-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 |
|---|---|
| 1 | Immediate |
| 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 |
|---|---|---|
| Stripe | HMAC-SHA256 with timestamp (120s replay window) | checkout.session.completed, payment_intent.succeeded |
| Square | HMAC-SHA256 with URL+body | payment.completed |
| Generic | HMAC-SHA256 via X-Webhook-Signature or X-Signature header | Any event containing completed, purchase, or payment |
| PayPal | Certificate-based | Not yet implemented |
Setup
- Create an inbound webhook configuration in the admin dashboard (Integrations page).
- Select the payment processor and enter your webhook signing secret.
- Map external product IDs (e.g., Stripe price IDs) to Vault products, specifying license type, tier, and max activations.
- 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
- Customer completes payment on your checkout page.
- Payment processor sends a webhook to Vault.
- Vault verifies the signature, parses the event, and matches line items to your product mappings.
- Licenses are created automatically with idempotency (duplicate webhook deliveries are safe).
- A license key email is sent to the customer if SMTP is configured.
Product Mapping Fields
| Field | Description |
|---|---|
externalProductId | Product/price ID from the payment processor (e.g., Stripe's price_xxx) |
productId | The Vault product to create a license for |
licenseType | License type: PERPETUAL, SUBSCRIPTION, TRIAL, BETA |
licenseTier | License tier: PERSONAL, PROFESSIONAL, BUSINESS, ENTERPRISE |
maxActivations | Maximum device activations |
durationDays | License 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_tokencookie (set automatically by the browser after verify) - The
X-Requested-With: XMLHttpRequestheader (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_REQUIRED | 401 | Missing X-API-Key header |
INVALID_API_KEY | 401 | Key not found or invalid |
API_KEY_REVOKED | 401 | Key has been revoked |
API_KEY_EXPIRED | 401 | Key has passed its expiration date |
IP_NOT_ALLOWED | 403 | Request IP not in the key's allowlist |
INSUFFICIENT_SCOPE | 403 | API key lacks the required permission scope |
VALIDATION_ERROR | 400 | Invalid request body (missing/malformed fields) |
ACTIVATION_FAILED | 400 | License cannot be activated (expired, suspended, revoked, or limit reached) |
DEACTIVATION_FAILED | 400 | No active activation found for this machine |
LICENSE_EXPIRED | 400 | License has expired |
LICENSE_SUSPENDED | 400 | License is temporarily disabled |
LICENSE_REVOKED | 400 | License is permanently revoked |
INVALID_KEY | 400 | License key format is invalid |
INVALID_LICENSE | 401 | Invalid or inactive license key (update endpoints) |
NOT_FOUND | 404 | License or resource does not exist |
NOT_FLOATING | 400 | Checkout/checkin on a non-floating license |
NOT_CHECKED_OUT | 400 | Checkin without an active checkout |
SEAT_LIMIT_REACHED | 400 | All floating seats are in use |
USAGE_LIMIT_EXCEEDED | 400 | Usage limit has been reached |
NOT_ACTIVATED | 400 | Heartbeat for a machine that is not activated |
LICENSE_INACTIVE | 400 | Heartbeat for an inactive license |
RATE_LIMITED | 429 | Too many requests |
SERVER_ERROR | 500 | Unexpected server error |
Rate Limits Summary
| Endpoint Category | Limit | Keyed By |
|---|---|---|
| Validation API (read: validate, check) | 60 / 15 min | IP |
| Validation API (write: create, activate, deactivate, revoke, renew, checkout, checkin, record-usage) | 15 / 15 min | API key |
| Heartbeat | 120 / min | API key |
| Update check / manifest | 60 / 15 min | IP |
| Update download | 10 / hour | IP |
| Inbound webhooks | 60 / min | IP |
| Customer portal (data) | 60 / min | IP |
| Customer portal (auth) | 10 / 15 min | IP |
Rate Limit Response Headers:
| Header | Description |
|---|---|
RateLimit-Limit | Maximum requests in the current window |
RateLimit-Remaining | Requests remaining in the current window |
RateLimit-Reset | Seconds 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:
- Read the
RateLimit-Resetheader to determine wait time. - Use exponential backoff with jitter for retries.
- Limit retries to 3–5 attempts.
- Track
RateLimit-Remainingto proactively slow down before hitting limits. - 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_ONLYfor client apps,CREATE_LICENSEonly 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_ADMINscope 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:
- Compute SHA-256 of the downloaded file.
- Compare with the
X-Bundle-SHA256response header. - Verify the Ed25519 signature over the SHA-256 hash using the embedded public key.
- 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/activate | API Key | VALIDATE_ONLY | Activate license on machine |
POST | /api/v1/license/validate | API Key | VALIDATE_ONLY | Check license validity |
POST | /api/v1/license/deactivate | API Key | VALIDATE_ONLY | Remove machine activation |
GET | /api/v1/license/check/:key | API Key | VALIDATE_ONLY | Quick status check |
POST | /api/v1/license/heartbeat | API Key | VALIDATE_ONLY | Machine heartbeat ping |
POST | /api/v1/license/checkout | API Key | VALIDATE_ONLY | Floating seat checkout |
POST | /api/v1/license/checkin | API Key | VALIDATE_ONLY | Floating seat checkin |
POST | /api/v1/license/record-usage | API Key | VALIDATE_ONLY | Record usage metering |
POST | /api/v1/license/validate-entitlement | API Key | VALIDATE_ONLY | Check feature entitlement |
POST | /api/v1/license/create | API Key | CREATE_LICENSE | Create a new license |
POST | /api/v1/license/create-bulk | API Key | CREATE_LICENSE | Bulk create (max 20) |
GET | /api/v1/license/by-order/:orderId | API Key | CREATE_LICENSE | Lookup by order ID |
POST | /api/v1/license/revoke | API Key | CREATE_LICENSE | Revoke a license |
POST | /api/v1/license/renew | API Key | CREATE_LICENSE | Renew/extend a license |
GET | /api/v1/products | API Key | CREATE_LICENSE | List products |
GET | /api/v1/updates/check | License Key | — | Check for updates |
GET | /api/v1/updates/download | License Key | — | Download release bundle |
GET | /api/v1/updates/manifest | License Key | — | Get release manifest |
GET | /api/v1/updates/public-key | None | — | Get signing public key |
POST | /api/v1/webhooks/inbound/:configId | Signature | — | Receive payment webhooks |
GET | /api/health | None | — | Health check |
Customer Portal Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/customer/login | None | Request magic link |
POST | /api/customer/verify | None | Verify magic link token |
GET | /api/customer/me | Cookie | Get customer info |
PATCH | /api/customer/profile | Cookie | Update profile |
GET | /api/customer/licenses | Cookie | List licenses |
GET | /api/customer/licenses/:id | Cookie | License detail |
POST | /api/customer/licenses/:id/activate | Cookie | Self-service activate |
POST | /api/customer/licenses/:id/deactivate | Cookie | Self-service deactivate |
POST | /api/customer/logout | Cookie | Logout current session |
POST | /api/customer/logout-all | Cookie | Logout all sessions |