Documentation

The Bouncer's Playbook

License and sell your app with KeyBouncer. Use the built-in storefront to take payments and auto-deliver licenses, or wire the API into your own flow. No backend required either way.

Overview

There are two ways to use KeyBouncer. Most apps use both.

A · Sell with built-in plans

Connect your Stripe, create a Plan, and share a hosted buy link. On payment, KeyBouncer mints a license, emails it, and shows it on a branded success page. You run nothing.

B · License manually / your own flow

Create licenses in the dashboard or via the management API (your own checkout, resellers, promos), then validate them from your app with a publishable key.

Your client app always talks to KeyBouncer directly with a publishable key, there is no proxy or server for you to host.

Key types: publishable vs secret

KeyBouncer has two API key classes. Never ship a secret key in a client, anyone could extract it and revoke or read your customers. Embed a publishable key instead; it is hard-limited (server-side) to the validate set.

Publishablekb_pub_live_ / kb_pub_test_

Where: Ship inside client apps (safe to embed)

Can: validate, activate, deactivate (own device), check, recover, fetch public key

Cannot: List/search licenses, read customer PII, create / update / revoke

Secretkb_live_ / kb_test_

Where: Server-side only, never ship in a client

Can: Everything, gated by operation scopes

Cannot: Nothing is hard-blocked; limited only by its scopes

Create keys in Dashboard → API Keys. Choose a class; for secret keys, pick the operation scopes it needs (leave all unselected for a full-access key):

licenses:readList & search licenses, read customer info
licenses:writeCreate, update, revoke, suspend, renew, reactivate
licenses:validateValidate / check licenses
customers:readRead customer details
billing:manageManage billing-related resources
webhooks:manageManage webhook endpoints
bash
# Client app, publishable key, validate only
curl -X POST https://your-domain.com/api/v1/licenses/validate \
  -H "Authorization: Bearer kb_pub_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{"license_key": "XXXX-XXXX-XXXX-XXXX"}'

# Your server, secret key, full management
curl -X POST https://your-domain.com/api/v1/licenses \
  -H "Authorization: Bearer kb_live_xxx" \
  -H "Idempotency-Key: order-1234" \
  -H "Content-Type: application/json" \
  -d '{"application_id": "app_123", "email": "buyer@example.com"}'

Quickstart: sell + license in ~10 minutes

  1. 1

    Create an application

    Dashboard → Applications → New. This is your product; it gets an RSA keypair for offline licenses automatically.

  2. 2

    Connect Stripe

    Dashboard → Payments → Connect Stripe. Complete Stripe-hosted onboarding. When "Charges Enabled" shows green you can sell.

  3. 3

    Create a plan

    Dashboard → Plans → New Plan. Pick the app, set a price and device count. KeyBouncer creates the Stripe product/price on your connected account.

  4. 4

    Brand it (optional)

    Plans → Appearance: set the page title, sub-description, theme (dark/light), and brand colors for your storefront and emails.

  5. 5

    Grab keys

    Dashboard → API Keys: make a publishable key for your app, and a secret key if you will use the management API.

  6. 6

    Ship the link

    Plans → Storefront links: copy your buy link (/a/{slug}) and recover link. Put the buy link on your site or in-app "Buy" button.

On purchase the buyer is charged on your Stripe account, gets the license by email, and sees it on the success page. You can then validate it from your app (next sections).

Selling with Stripe (Connect)

KeyBouncer uses Stripe Connect (Standard) with hosted onboarding. Buyers are charged directly on your own Stripe account (direct charges, no platform fee), KeyBouncer never holds your money, and you are the merchant of record.

Onboarding

  1. Payments → Connect Stripe sends you to Stripe-hosted onboarding (you can sign into an existing Stripe account there).
  2. When you return, the page polls until Charges Enabled turns on, then plans become buyable.
  3. Disconnect any time; existing licenses keep working and the account is kept so you can reconnect.

What happens on a purchase

Stripe eventKeyBouncer does
checkout.session.completedMint a license from the plan template, link the buyer email, fire LICENSE_CREATED, email the key
charge.refundedRevoke the license (per the plan refund policy) and fire LICENSE_REVOKED
account.updatedRefresh your charges/payouts readiness

Every step is idempotent (deduped by Stripe event id and checkout session), so retries never mint duplicates.

Subscriptions

Plans can be one-time or subscription (monthly/yearly, optional free trial). For subscriptions, the license expires_at tracks the Stripe billing period plus a short grace window, and the lifecycle reconciles automatically:

invoice.paid (renewal)Extend the license to the new period; LICENSE_RENEWED
invoice.payment_failedDunning email; license stays active through grace; PAYMENT_FAILED
upgrade / downgradeRe-map tier/features/devices; LICENSE_UPDATED
canceled / unpaid / endedExpire the license; LICENSE_EXPIRED

Buyers self-serve on the hosted /a/{slug}/manage page: they sign in with a one-time code emailed to them (no password, no key needed), then view their licenses, remove devices to free a seat, and open the Stripe billing portal for subscriptions (update card, change plan, cancel).

Promo codes & tax: toggle Allow promo codes and Collect tax (Stripe Tax) per storefront under Plans → Appearance. Tax requires Stripe Tax enabled on your connected account.

Your logo on Checkout: the final Stripe-hosted Checkout page shows your business name and logo, pulled from your Stripe account branding (Stripe Dashboard → Settings → Branding), since you are the merchant of record. Set your icon and brand color there. If it's blank or shows a fallback (common in Stripe test/sandbox before branding is set), add your logo in Stripe. KeyBouncer can't send a logo to Checkout, that page is Stripe's. (The KeyBouncer storefront at /a/{slug} is branded separately under Plans → Appearance.)

Bundles (multi-app licenses)

A Bundle groups several applications so a single license key validates across all of them, an “all-access pass.” Individual per-app licenses keep working unchanged; an app can be sold both ways.

  1. Dashboard → Bundles → New: name it and pick the member apps.
  2. Create a Plan and choose the bundle as the target (sell it one-time or as a subscription, same as an app).
  3. The bundle gets its own hosted storefront at /b/{slug} and its own recover/manage pages.

For your client app, nothing changes: validate with the app's publishable key. A bundle key is accepted because the app is a member, and the response carries that app's entitlements:

bash
# From App A (a bundle member), same call as always
curl -X POST https://your-domain.com/api/v1/licenses/validate \
  -H "Authorization: Bearer kb_pub_live_APP_A_KEY" \
  -d '{"license_key": "BUNDLE-KEY", "product_id": "app_A_id"}'
# -> valid; features resolved for App A. A non-member app gets PRODUCT_MISMATCH.

Selling through Plans is optional, you can mint bundle licenses from your own backend (your own payments), exactly like single-app licenses, by passing bundle_id instead of application_id:

bash
curl -X POST https://your-domain.com/api/v1/licenses \
  -H "Authorization: Bearer kb_live_xxx" \
  -H "Idempotency-Key: order-1234" \
  -d '{"bundle_id": "bun_123", "email": "buyer@example.com"}'
# (use an account-level secret key, not an app-scoped one)

Offline: a bundle .lic is signed by the bundle's key and carries an applications[] array. Member apps fetch the bundle key from /api/v1/bundles/{id}/public-key and check their own app id is in the list.

Hosted pages & branding

KeyBouncer hosts a small, branded page set per app at /a/{slug}. You build no front-end.

/a/{slug}Pricing / buy page, link this from your site
/a/{slug}/successShows the key after payment (Stripe redirects here)
/a/{slug}/cancelFriendly “no charge made”
/a/{slug}/recoverEmail field to resend a lost key
/a/{slug}/manageCustomer self-service (one-time code): licenses, remove devices, billing portal

Set branding in Plans → Appearance. It applies to every hosted page and your transactional emails:

  • Page title and sub-description
  • Theme, dark (default) or light
  • Primary & accent colors (buttons, accents, glow)
  • Support email, website URL, download URL

Custom logos and custom domains are on the roadmap; for now pages live under your KeyBouncer domain.

Customers & license management API

Create and manage licenses from your own server with a secret key. Pass an Idempotency-Key header on creates so retries never duplicate. Customers are tracked by the email on each license; look them up with the list endpoint.

POST/api/v1/licensessecret · licenses:write

Mint a license programmatically. Honors the Idempotency-Key header.

Request Body

json
{
  "application_id": "app_123",   // exactly one of application_id OR bundle_id
  "bundle_id": "bndl_123",       // (bundle_id mints one key valid across the bundle)
  "email": "buyer@example.com",
  "name": "Jane Doe",
  "tier_id": "tier_pro",        // optional (applications only)
  "license_type": "PERPETUAL",  // PERPETUAL | SUBSCRIPTION | TRIAL
  "max_activations": 3,
  "expires_at": "2027-01-01T00:00:00Z", // optional
  "features": { "export": true },        // optional overrides
  "metadata": { "order": "1234" }        // optional
}

Response

json
{
  "success": true,
  "data": {
    "id": "lic_abc",
    "key": "XXXX-XXXX-XXXX-XXXX",  // full key, shown once
    "key_prefix": "XXXX-XXX",
    "status": "ACTIVE",
    "type": "PERPETUAL",
    "max_activations": 3,
    "customer_email": "buyer@example.com",
    "source": "api"
  }
}
GET/api/v1/licenses?email=&application_id=&status=&page=secret · licenses:read

List / search licenses. Never returns full keys.

Parameters

json
Query params: email, application_id, status, page

Response

json
{
  "success": true,
  "data": [ { "id": "lic_abc", "key_prefix": "XXXX-XXX", "status": "ACTIVE", "customer_email": "buyer@example.com" } ],
  "pagination": { "page": 1, "pageSize": 50, "total": 1, "totalPages": 1 }
}
POST/api/v1/licenses/{id}secret · licenses:write

Update mutable fields (status, expiry, devices, tier, features, customer).

Request Body

json
{
  "status": "ACTIVE",
  "expires_at": "2027-06-01T00:00:00Z",
  "max_activations": 5,
  "features": { "export": true }
}

Response

json
{ "success": true, "data": { "id": "lic_abc", "status": "ACTIVE", "max_activations": 5 } }
POST/api/v1/licenses/{id}/{revoke|suspend|renew|reactivate}secret · licenses:write

Lifecycle transitions. renew requires { "expires_at": "..." } in the body.

Request Body

json
// revoke / suspend / reactivate: empty body
// renew:
{ "expires_at": "2027-06-01T00:00:00Z" }

Response

json
{ "success": true, "data": { "id": "lic_abc", "status": "REVOKED" } }

Validation API

The endpoints your app calls. A publishable key is allowed on all of these; a secret key needs licenses:validate (validate/check) or licenses:write (activate/deactivate).

POST/api/v1/licenses/validatepublishable or secret

Check a license at the door. Is this guest legit?

Request Body

json
{
  "license_key": "XXXX-XXXX-XXXX-XXXX",
  "hardware_id": "optional-device-id",
  "product_id": "optional-app-id"
}

Response

json
{
  "valid": true,
  "license": {
    "type": "PERPETUAL",
    "status": "ACTIVE",
    "expires_at": null,
    "features": { "export": true },
    "tier": "Pro",
    "activations": 1,
    "max_activations": 3
  }
}
POST/api/v1/licenses/activatepublishable or secret

Stamp a device as approved. Returns an activation_token and a fresh offline .lic.

Request Body

json
{
  "license_key": "XXXX-XXXX-XXXX-XXXX",
  "hardware_id": "device-unique-id",
  "device_name": "Jane's MacBook Pro",
  "device_metadata": { "os": "macOS", "arch": "arm64" }
}

Response

json
{
  "activated": true,
  "license": { "status": "ACTIVE", "activations": 1, "max_activations": 3 },
  "activation": { "id": "...", "hardware_id": "...", "device_name": "..." },
  "activation_token": "kbact_...",   // keep this to deactivate later
  "offline_license": "<header>.<payload>.<signature>",
  "key_id": "a1b2c3d4e5f60718"
}
POST/api/v1/licenses/deactivatepublishable or secret

Free a device seat. Publishable keys MUST pass the device activation_token.

Request Body

json
{
  "license_key": "XXXX-XXXX-XXXX-XXXX",
  "hardware_id": "device-unique-id",
  "activation_token": "kbact_..."   // required for publishable keys
}

Response

json
{ "deactivated": true }
GET/api/v1/licenses/checkpublishable or secret

Quick read of the guest list. No side effects.

Parameters

json
Query params: ?license_key=XXXX-XXXX&hardware_id=device-id

Response

json
{ "valid": true, "status": "ACTIVE", "expires_at": null, "features": { "export": true } }
GET/api/v1/applications/{id}/public-keypublishable or secret

Fetch the app RSA public key + key_id for verifying offline licenses.

Parameters

json
No body.

Response

json
{ "application_id": "app_123", "public_key": "-----BEGIN PUBLIC KEY-----...", "key_id": "a1b2c3d4e5f60718", "algorithm": "RS256" }
POST/api/v1/licenses/recoverpublishable or secret

Email a buyer their key(s). Always returns 202 (enumeration-safe).

Request Body

json
{ "email": "buyer@example.com", "application_id": "app_123" }

Response

json
{ "accepted": true }   // HTTP 202

Devices & activations

Each license has a device limit (max_activations). When a device activates, the response includes an opaque activation_token for that device. Store it locally.

Why the token matters: a publishable key ships in every copy of your app. To free a seat with a publishable key, the caller must present the device's activation_token, so a leaked public key plus a guessed hardware id can't deactivate someone else's machine. (Secret keys may deactivate by hardware_id alone.)

Self-service: customers can also view and remove their own devices on the hosted /a/{slug}/manage page by signing in with a one-time email code, no support ticket needed.

Offline licenses

A successful activate returns a hardware-bound, RSA-signed .lic token in offline_license (on plans that include offline validation; you can also generate one from the dashboard). Verify it locally with the app public key, no network needed.

Format

A JWT-like string: three base64url parts joined by dots, header.payload.signature. The signature is RSA-SHA256 over header.payload.

json
// decoded payload
{
  "version": 1,
  "keyId": "a1b2c3d4e5f60718",      // matches the public-key endpoint
  "licenseId": "lic_abc",
  "applicationId": "app_123",
  "hardwareId": "device-unique-id",  // null if not device-bound
  "tier": "Pro",
  "features": { "export": true },
  "licenseType": "PERPETUAL",
  "status": "ACTIVE",
  "maxActivations": 3,
  "expiresAt": "2027-01-01T00:00:00Z",
  "graceUntil": null,                // honored past expiry if set
  "issuedAt": "2026-06-26T12:00:00Z"
}

Verification order

  1. Verify the RSA-SHA256 signature over header.payload with the public key.
  2. Check keyId matches your embedded key (supports rotation, fetch the current key from the public-key endpoint).
  3. Check expiresAt; if expired, allow until graceUntil if present.
  4. Check hardwareId matches this device (when bound).
javascript
const crypto = require('crypto');

// The app's public key, fetch from /api/v1/applications/{id}/public-key and embed it.
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----`;

function verifyOfflineLicense(token, hardwareId) {
  const [header, payload, signature] = token.split('.');
  if (!header || !payload || !signature) return { valid: false, error: 'INVALID_FORMAT' };

  const ok = crypto.createVerify('RSA-SHA256')
    .update(`${header}.${payload}`)
    .verify(PUBLIC_KEY, signature, 'base64url');
  if (!ok) return { valid: false, error: 'INVALID_SIGNATURE' };

  const lic = JSON.parse(Buffer.from(payload, 'base64url').toString());

  const now = new Date();
  if (lic.expiresAt && new Date(lic.expiresAt) < now) {
    const graceOk = lic.graceUntil && new Date(lic.graceUntil) >= now;
    if (!graceOk) return { valid: false, error: 'LICENSE_EXPIRED' };
  }
  if (lic.hardwareId && lic.hardwareId !== hardwareId) {
    return { valid: false, error: 'HARDWARE_MISMATCH' };
  }
  return { valid: true, license: lic };
}

Webhooks

Subscribe in Dashboard → Webhooks to react in your own systems. Deliveries are signed and retried with backoff; logs and manual retry are in the dashboard.

LICENSE_CREATEDA license was minted (purchase, API, or dashboard)
LICENSE_ACTIVATEDA device was activated
LICENSE_DEACTIVATEDA device seat was freed
LICENSE_UPDATEDTier / features / limits changed
LICENSE_RENEWEDExpiry extended / reactivated
LICENSE_SUSPENDEDLicense suspended
LICENSE_REVOKEDLicense permanently revoked (e.g. refund)
LICENSE_EXPIREDLicense passed its expiry
ACTIVATION_LIMIT_REACHEDA device tried to activate past the limit
VALIDATION_FAILEDA validation attempt failed

Payloads carry keyPrefix only, never the full key. Fetch the full key with a secret key if you need it.

json
// POST to your endpoint
{
  "event": "LICENSE_CREATED",
  "data": { "license": { "id": "lic_abc", "keyPrefix": "XXXX-XXX", "status": "ACTIVE", "licenseType": "PERPETUAL", "application": "Quill", "customerEmail": "buyer@example.com" } },
  "timestamp": "2026-06-26T12:00:00.000Z"
}

Verify the signature. Sign {timestamp}.{raw body} with your endpoint secret (HMAC-SHA256) and compare to the header:

javascript
const crypto = require('crypto');

function verify(rawBody, headers, secret) {
  const signature = headers['x-keybouncer-signature'];
  const timestamp = headers['x-keybouncer-timestamp'];
  const expected = crypto.createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// Headers: X-KeyBouncer-Signature, X-KeyBouncer-Timestamp, X-KeyBouncer-Event, X-KeyBouncer-Retry

Desktop client integration

Recommended pattern: embed a publishable key, activate once (store the returned activation_token and offline_license), then verify the offline token on launch and only re-validate online occasionally.

Electron, license manager

javascript
// license.js (main process)
const { machineIdSync } = require('node-machine-id');
const Store = require('electron-store');
const store = new Store({ encryptionKey: 'your-encryption-key' });

const API_URL = 'https://your-domain.com/api/v1';
const PUB_KEY = 'kb_pub_live_your_publishable_key'; // safe to ship

class LicenseManager {
  constructor() { this.hardwareId = machineIdSync(true); }

  async activate(licenseKey) {
    const res = await fetch(`${API_URL}/licenses/activate`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${PUB_KEY}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        license_key: licenseKey,
        hardware_id: this.hardwareId,
        device_name: require('os').hostname(),
        device_metadata: { platform: process.platform, arch: process.arch },
      }),
    });
    const data = await res.json();
    if (data.activated) {
      store.set('license', {
        key: licenseKey,
        token: data.activation_token,   // needed to deactivate later
        offline: data.offline_license,  // verify locally on launch
        keyId: data.key_id,
      });
    }
    return data;
  }

  async validate(licenseKey) {
    const res = await fetch(`${API_URL}/licenses/validate`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${PUB_KEY}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ license_key: licenseKey, hardware_id: this.hardwareId }),
    });
    return res.json();
  }

  async deactivate(licenseKey) {
    const saved = store.get('license') || {};
    return fetch(`${API_URL}/licenses/deactivate`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${PUB_KEY}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        license_key: licenseKey,
        hardware_id: this.hardwareId,
        activation_token: saved.token, // required for publishable keys
      }),
    }).then((r) => r.json());
  }
}

module.exports = new LicenseManager();

Tauri (Rust), activate

rust
// src-tauri/src/license.rs
use serde::{Deserialize, Serialize};
use reqwest::Client;

const API_URL: &str = "https://your-domain.com/api/v1";
const PUB_KEY: &str = "kb_pub_live_your_publishable_key"; // safe to ship

#[derive(Serialize)]
struct ActivateRequest { license_key: String, hardware_id: String, device_name: String }

#[derive(Deserialize)]
pub struct ActivateResponse {
    pub activated: bool,
    pub activation_token: Option<String>,
    pub offline_license: Option<String>,
    pub key_id: Option<String>,
    pub error: Option<String>,
}

pub fn get_hardware_id() -> String {
    machine_uid::get().unwrap_or_else(|_| "unknown".to_string())
}

pub async fn activate(license_key: &str) -> Result<ActivateResponse, String> {
    let device_name = hostname::get().map(|h| h.to_string_lossy().to_string())
        .unwrap_or_else(|_| "Unknown".to_string());
    Client::new()
        .post(format!("{}/licenses/activate", API_URL))
        .header("Authorization", format!("Bearer {}", PUB_KEY))
        .json(&ActivateRequest { license_key: license_key.into(), hardware_id: get_hardware_id(), device_name })
        .send().await.map_err(|e| e.to_string())?
        .json::<ActivateResponse>().await.map_err(|e| e.to_string())
}

Python / cURL

bash
# Activate (returns activation_token + offline_license)
curl -X POST https://your-domain.com/api/v1/licenses/activate \
  -H "Authorization: Bearer kb_pub_live_your_publishable_key" \
  -H "Content-Type: application/json" \
  -d '{"license_key": "XXXX-XXXX-XXXX-XXXX", "hardware_id": "device-123", "device_name": "My PC"}'

# Validate
curl -X POST https://your-domain.com/api/v1/licenses/validate \
  -H "Authorization: Bearer kb_pub_live_your_publishable_key" \
  -H "Content-Type: application/json" \
  -d '{"license_key": "XXXX-XXXX-XXXX-XXXX", "hardware_id": "device-123"}'

Error codes

Failed validations return an error field with one of these codes:

CodeMeaning
LICENSE_NOT_FOUNDLicense key not found
LICENSE_EXPIREDLicense has passed its expiry date
LICENSE_SUSPENDEDLicense is suspended
LICENSE_REVOKEDLicense has been permanently revoked
LICENSE_INACTIVELicense is not active (cannot activate)
ACTIVATION_LIMIT_REACHEDMaximum device activations reached
NOT_ACTIVATEDThis device is not activated for the license
NOT_AUTHORIZEDActivation token did not match this device
PRODUCT_MISMATCHLicense is not valid for this product_id
VALIDATION_LIMIT_EXCEEDEDMonthly validation quota reached (HTTP 429)
json
{ "valid": false, "error": "ACTIVATION_LIMIT_REACHED", "message": "Maximum activations reached (3)" }

Rate limits

Limits are per-IP (publishable keys ship widely, so they are throttled by IP). Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset.

Validation API (/api/v1/*)1,000 / minute
Reads (GET dashboard APIs)200 / 15 min
Writes (POST/PUT/DELETE)30 / 15 min
Auth & recovery10 / 15 min

Ready to lock it down?

Create a free account and start selling licenses in minutes.

Get Started