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.
There are two ways to use KeyBouncer. Most apps use both.
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.
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.
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.
kb_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
kb_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:read | List & search licenses, read customer info |
licenses:write | Create, update, revoke, suspend, renew, reactivate |
licenses:validate | Validate / check licenses |
customers:read | Read customer details |
billing:manage | Manage billing-related resources |
webhooks:manage | Manage webhook endpoints |
# 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"}'Dashboard → Applications → New. This is your product; it gets an RSA keypair for offline licenses automatically.
Dashboard → Payments → Connect Stripe. Complete Stripe-hosted onboarding. When "Charges Enabled" shows green you can sell.
Dashboard → Plans → New Plan. Pick the app, set a price and device count. KeyBouncer creates the Stripe product/price on your connected account.
Plans → Appearance: set the page title, sub-description, theme (dark/light), and brand colors for your storefront and emails.
Dashboard → API Keys: make a publishable key for your app, and a secret key if you will use the management API.
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).
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.
| Stripe event | KeyBouncer does |
|---|---|
checkout.session.completed | Mint a license from the plan template, link the buyer email, fire LICENSE_CREATED, email the key |
charge.refunded | Revoke the license (per the plan refund policy) and fire LICENSE_REVOKED |
account.updated | Refresh your charges/payouts readiness |
Every step is idempotent (deduped by Stripe event id and checkout session), so retries never mint duplicates.
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_failed | Dunning email; license stays active through grace; PAYMENT_FAILED |
| upgrade / downgrade | Re-map tier/features/devices; LICENSE_UPDATED |
| canceled / unpaid / ended | Expire 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.)
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.
/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:
# 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:
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.
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}/success | Shows the key after payment (Stripe redirects here) |
/a/{slug}/cancel | Friendly “no charge made” |
/a/{slug}/recover | Email field to resend a lost key |
/a/{slug}/manage | Customer 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:
Custom logos and custom domains are on the roadmap; for now pages live under your KeyBouncer domain.
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.
/api/v1/licensessecret · licenses:writeMint a license programmatically. Honors the Idempotency-Key header.
Request Body
{
"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
{
"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"
}
}/api/v1/licenses?email=&application_id=&status=&page=secret · licenses:readList / search licenses. Never returns full keys.
Parameters
Query params: email, application_id, status, pageResponse
{
"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 }
}/api/v1/licenses/{id}secret · licenses:writeUpdate mutable fields (status, expiry, devices, tier, features, customer).
Request Body
{
"status": "ACTIVE",
"expires_at": "2027-06-01T00:00:00Z",
"max_activations": 5,
"features": { "export": true }
}Response
{ "success": true, "data": { "id": "lic_abc", "status": "ACTIVE", "max_activations": 5 } }/api/v1/licenses/{id}/{revoke|suspend|renew|reactivate}secret · licenses:writeLifecycle transitions. renew requires { "expires_at": "..." } in the body.
Request Body
// revoke / suspend / reactivate: empty body
// renew:
{ "expires_at": "2027-06-01T00:00:00Z" }Response
{ "success": true, "data": { "id": "lic_abc", "status": "REVOKED" } }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).
/api/v1/licenses/validatepublishable or secretCheck a license at the door. Is this guest legit?
Request Body
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"hardware_id": "optional-device-id",
"product_id": "optional-app-id"
}Response
{
"valid": true,
"license": {
"type": "PERPETUAL",
"status": "ACTIVE",
"expires_at": null,
"features": { "export": true },
"tier": "Pro",
"activations": 1,
"max_activations": 3
}
}/api/v1/licenses/activatepublishable or secretStamp a device as approved. Returns an activation_token and a fresh offline .lic.
Request Body
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"hardware_id": "device-unique-id",
"device_name": "Jane's MacBook Pro",
"device_metadata": { "os": "macOS", "arch": "arm64" }
}Response
{
"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"
}/api/v1/licenses/deactivatepublishable or secretFree a device seat. Publishable keys MUST pass the device activation_token.
Request Body
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"hardware_id": "device-unique-id",
"activation_token": "kbact_..." // required for publishable keys
}Response
{ "deactivated": true }/api/v1/licenses/checkpublishable or secretQuick read of the guest list. No side effects.
Parameters
Query params: ?license_key=XXXX-XXXX&hardware_id=device-idResponse
{ "valid": true, "status": "ACTIVE", "expires_at": null, "features": { "export": true } }/api/v1/applications/{id}/public-keypublishable or secretFetch the app RSA public key + key_id for verifying offline licenses.
Parameters
No body.Response
{ "application_id": "app_123", "public_key": "-----BEGIN PUBLIC KEY-----...", "key_id": "a1b2c3d4e5f60718", "algorithm": "RS256" }/api/v1/licenses/recoverpublishable or secretEmail a buyer their key(s). Always returns 202 (enumeration-safe).
Request Body
{ "email": "buyer@example.com", "application_id": "app_123" }Response
{ "accepted": true } // HTTP 202Each 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.
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.
A JWT-like string: three base64url parts joined by dots, header.payload.signature. The signature is RSA-SHA256 over header.payload.
// 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"
}header.payload with the public key.keyId matches your embedded key (supports rotation, fetch the current key from the public-key endpoint).expiresAt; if expired, allow until graceUntil if present.hardwareId matches this device (when bound).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 };
}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_CREATED | A license was minted (purchase, API, or dashboard) |
LICENSE_ACTIVATED | A device was activated |
LICENSE_DEACTIVATED | A device seat was freed |
LICENSE_UPDATED | Tier / features / limits changed |
LICENSE_RENEWED | Expiry extended / reactivated |
LICENSE_SUSPENDED | License suspended |
LICENSE_REVOKED | License permanently revoked (e.g. refund) |
LICENSE_EXPIRED | License passed its expiry |
ACTIVATION_LIMIT_REACHED | A device tried to activate past the limit |
VALIDATION_FAILED | A validation attempt failed |
Payloads carry keyPrefix only, never the full key. Fetch the full key with a secret key if you need it.
// 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:
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-RetryRecommended 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.
// 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();// 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())
}# 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"}'Failed validations return an error field with one of these codes:
| Code | Meaning |
|---|---|
LICENSE_NOT_FOUND | License key not found |
LICENSE_EXPIRED | License has passed its expiry date |
LICENSE_SUSPENDED | License is suspended |
LICENSE_REVOKED | License has been permanently revoked |
LICENSE_INACTIVE | License is not active (cannot activate) |
ACTIVATION_LIMIT_REACHED | Maximum device activations reached |
NOT_ACTIVATED | This device is not activated for the license |
NOT_AUTHORIZED | Activation token did not match this device |
PRODUCT_MISMATCH | License is not valid for this product_id |
VALIDATION_LIMIT_EXCEEDED | Monthly validation quota reached (HTTP 429) |
{ "valid": false, "error": "ACTIVATION_LIMIT_REACHED", "message": "Maximum activations reached (3)" }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 & recovery | 10 / 15 min |