BuyBizz Vendor License API
One standard, authenticated REST contract every third-party product on BuyBizz uses to verify customer license keys. No SDK required — if your stack speaks HTTPS and HMAC-SHA256, you can integrate in under an hour.
Quick start
Five steps from zero to verified licenses.
- Create your product in
/dashboard/vendor/products/newwith delivery type License Key Only and a license policy. - Generate a key pair in API Keys. The secret is shown once — copy it to a secrets manager.
- On your customer's "Enter your license key" screen, call
POST /api/v1/licenses/activatewith the customer key, yourproductSlug, and a stable device fingerprint. - Verify the
X-BuyBizz-Signatureresponse header before trusting the body. - Once a day (or every 30 minutes for premium gates), call
POST /api/v1/licenses/heartbeatto catch refunds and revocations.
Integrating with your product
A platform-agnostic walkthrough of where each call belongs in your app.
Lifecycle: when to call what
Map each event in your app to one of the four endpoints. Skip steps that don't apply (e.g. a server-side SaaS doesn't need device fingerprints).
App launch / server start
POST /heartbeat
Catches anything that happened while the app was off (refunds, admin revokes). Fire and forget; cache the result.
Customer pastes their key in your 'Activate' UI
POST /activate
Idempotent. Consumes a seat from Product.maxActivations on first call; just bumps lastSeenAt on repeats.
Every premium-feature gate (export, paid-tier-only buttons)
POST /validate (cached) or /heartbeat
If your last successful call is < 5 min old, trust the cache. Otherwise re-check before unlocking high-value actions.
Background timer (every 30 minutes while app is active)
POST /heartbeat
Cheapest endpoint. Bounds revoke / refund propagation to ~30 min worst-case.
User signs out / uninstalls
POST /deactivate
Frees the seat so your customer can move their license to a new device without contacting support.
Generating a device fingerprint
A stable hash that identifies one customer install. Must survive reboots and app updates, MUST NOT change for the same machine, and MUST be different across machines. Hash before sending so you never leak raw hardware ids to BuyBizz.
// npm i node-machine-id
import { machineIdSync } from "node-machine-id";
import crypto from "crypto";
export function fingerprint(): string {
const raw = machineIdSync(/* original=false */);
return crypto.createHash("sha256").update(raw).digest("hex");
}import hashlib, platform, uuid
def fingerprint() -> str:
raw = f"{uuid.getnode()}:{platform.node()}"
return hashlib.sha256(raw.encode()).hexdigest()// Don't fingerprint the browser; fingerprint the *account*.
// Stable across logins, devices, and incognito windows.
import crypto from "crypto";
export function fingerprint(userId: string, tenantId: string): string {
return crypto
.createHash("sha256")
.update(`${tenantId}:${userId}`)
.digest("hex");
}using System.Management;
using System.Security.Cryptography;
using System.Text;
string Fingerprint() {
using var s = new ManagementObjectSearcher("SELECT UUID FROM Win32_ComputerSystemProduct");
var raw = s.Get().Cast<ManagementObject>().First()["UUID"].ToString();
using var sha = SHA256.Create();
return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(raw))).ToLower();
}import CryptoKit
import UIKit
func fingerprint() -> String {
let raw = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
let digest = SHA256.hash(data: Data(raw.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}import android.provider.Settings
import java.security.MessageDigest
fun fingerprint(context: Context): String {
val raw = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray())
return digest.joinToString("") { "%02x".format(it) }
}maxActivations.Storing the bz_sk_ secret safely
The secret is shown once at creation and is equivalent to a password for your product's license validation. Where you put it depends on what you're building.
| Product type | Where the secret lives |
|---|---|
| Server-side SaaS / API | process.env.BUYBIZZ_SECRET backed by your secrets manager (AWS Secrets Manager, Doppler, Vault, Railway env, etc.). |
| Electron / desktop app | OS keychain via keytar (macOS Keychain / Windows Credential Manager / GNOME libsecret). |
| Python desktop / CLI | The keyring package (same OS backends as keytar). |
| iOS / Android app | iOS Keychain Services / Android Keystore. Never UserDefaults or SharedPreferences. |
| Browser / SPA | Never ship the secret to the browser. Proxy through your own backend; the secret only ever leaves your server. |
| Unity game / .NET desktop | DPAPI on Windows; SecKeychain on macOS. Don't hardcode in shipping binaries — they'll be extracted with strings in a day. |
Caching, retries, and offline behavior
BuyBizz can be slow or briefly unreachable. Plan for it: cache successful responses, retry transient failures with backoff, and grant a grace period when the customer was last seen valid.
- Cache the last successful
{ valid, status, expiresAt, features }in memory or on disk for ~30 min. - Retry on
503,429, and network errors with exponential backoff: 1s, 2s, 4s, 8s — cap at 4 attempts. - Grace period: if every retry fails AND the last cached
valid: trueis < 7 days old, keep the user logged in. Hard-stop only after the grace window expires. - Never retry on
401or any200withvalid: false— those are stable answers, not transient.
import crypto from "crypto";
import fs from "fs";
const SECRET = process.env.BUYBIZZ_SECRET!;
const CACHE_FILE = "/var/lib/myapp/license-cache.json";
const CACHE_TTL_MS = 30 * 60 * 1000; // re-check after 30 min
const GRACE_MS = 7 * 24 * 60 * 60 * 1000; // up to 7 days offline
async function verifyOnce(key: string, slug: string) {
const res = await fetch("https://api.buybizz.app/api/v1/licenses/heartbeat", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.BUYBIZZ_KEY_PREFIX}.${SECRET}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ key, productSlug: slug }),
});
// (omitted: signature verification — see "Verifying response signatures")
return { status: res.status, body: await res.json() };
}
export async function isLicenseValid(key: string, slug: string): Promise<boolean> {
let cached: { ok: boolean; at: number } | null = null;
try { cached = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); } catch {}
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.ok;
const delays = [1000, 2000, 4000, 8000];
for (let i = 0; i < delays.length; i++) {
try {
const { status, body } = await verifyOnce(key, slug);
if (status === 200 && body.valid === true) {
fs.writeFileSync(CACHE_FILE, JSON.stringify({ ok: true, at: Date.now() }));
return true;
}
if (status === 200 && body.valid === false) {
// stable rejection — don't retry, don't grace
fs.writeFileSync(CACHE_FILE, JSON.stringify({ ok: false, at: Date.now() }));
return false;
}
if (status === 401) return false; // bad credentials — operator problem
} catch { /* network error, fall through to retry */ }
await new Promise(r => setTimeout(r, delays[i]));
}
// All retries failed — fall back to last known-good if within grace window.
if (cached?.ok && Date.now() - cached.at < GRACE_MS) return true;
return false;
}UI patterns for the four end-user-visible failures
These are the failure codes a real customer will hit. The other codes (e.g. missing_bearer, signature_mismatch) mean YOU broke something and should never reach the user — log them and alert your team.
seat_limit_reachedSeat limit reached“This license is already in use on N other devices. Sign out from one of them to continue here.”
Show a 'Manage devices' link to your own UI (you can list activations from BuyBizz on request).
license_expiredLicense expired“Your license expired on {expiresAt}. Renew it to keep using premium features.”
Deep-link to the customer's BuyBizz subscription / renewal page.
license_revokedLicense revoked“Your license has been revoked. Contact support if you believe this is a mistake.”
Show a support email / chat link. Don't surface the reason — you don't have it.
vendor_mismatch / product_slug_mismatchWrong product“This license key is for a different product. Check that you're entering the right key.”
If your customer pasted a key from another vendor, send them back to BuyBizz to find the right one.
Pre-launch checklist
Tick all nine boxes before pointing real customers at your integration.
- Created a production API key in /dashboard/vendor/api-keys (a separate one from your dev/staging key).
- Stored the bz_sk_ secret in your secrets manager — verified it is NOT committed to git.
- Device fingerprint is deterministic across reboots, app updates, and OS minor upgrades.
- Response signature is verified on every successful call (X-BuyBizz-Signature, see #signing).
- Heartbeat is scheduled (every 30 min while app is active + on every server start).
- Deactivate is called on user sign-out / app uninstall — confirmed by checking seatsRemaining goes back up.
- All four end-user failure codes have UI: seat_limit_reached, license_expired, license_revoked, product_slug_mismatch.
- Sandbox tester returns valid: true for a real production license key without any code changes.
- Errors (signature_mismatch, 401, 503) are logged and routed to your alerting (PagerDuty, Sentry, etc.) — they always indicate a bug on your side.
Endpoints
Four routes, all under /api/v1/licenses/*.
/api/v1/licenses/validateVerify a key without consuming a seat. Use on every privileged feature gate.
Request body
{
"key": "BZ-XXXX-XXXXXXXXXXXXXXXX-XXXX",
"productSlug": "your-product-abc12345",
"fingerprint": "machine-id-hash-optional"
}Success response
{
"valid": true,
"status": "ACTIVE",
"type": "STANDARD",
"productSlug": "your-product-abc12345",
"productName": "Your Product",
"productId": "<uuid>",
"expiresAt": "2027-04-28T00:00:00.000Z",
"features": { "tier": "pro", "product": "your-product-abc12345" },
"seatsRemaining": 2
}/api/v1/licenses/activateRegister a device fingerprint against a license. Idempotent. Enforces Product.maxActivations.
Request body
{
"key": "BZ-XXXX-XXXXXXXXXXXXXXXX-XXXX",
"productSlug": "your-product-abc12345",
"fingerprint": "machine-id-hash",
"name": "Aman's MacBook Pro"
}Success response
{
"valid": true,
"status": "ACTIVE",
"activationId": "<uuid>",
"seatsRemaining": 1,
"expiresAt": "..."
}/api/v1/licenses/deactivateRelease the seat held by a fingerprint. Call on user sign-out or app uninstall.
Request body
{
"key": "...",
"productSlug": "...",
"fingerprint": "..."
}Success response
{ "valid": true, "status": "ACTIVE", "seatsRemaining": 2 }/api/v1/licenses/heartbeatCheapest call. Returns just { valid, status, expiresAt }. Use on a 30-minute schedule.
Request body
{
"key": "...",
"productSlug": "..."
}Success response
{ "valid": true, "status": "ACTIVE", "expiresAt": "..." }Heartbeats are how you catch refunds
Recommended cadence and rationale.
- Every 30 minutes — recommended cadence for active sessions. Bounds revoke / refund propagation to ~30 minutes worst-case.
- On every server start / app launch — catches anything that happened while the app was off.
- On every premium-feature gate — for high-value actions (export, paid-tier-only buttons), call
/heartbeatfirst if your last successful call is > 5 minutes old. - Cache the result locally between calls. Don't hit BuyBizz on every keystroke.
Verifying response signatures
Every successful response is signed with HMAC-SHA256.
Authenticate over HTTPS with the combined Bearer token. Each 200 response from /api/v1/licenses/* includes two extra headers:
Authorization: Bearer bz_pk_xxxx.bz_sk_yyyy # Response: X-BuyBizz-Timestamp: 1761643200 X-BuyBizz-Signature: v1=3b1f7c...e41a
Compute HMAC-SHA256(secret, "${timestamp}.${jsonBody}") and constant-time compare. Reject the response if the signature doesn't match or the timestamp is more than 5 minutes off your local clock.
import crypto from "crypto";
function verify(secret, body, headers) {
const ts = headers["x-buybizz-timestamp"];
const sig = headers["x-buybizz-signature"]; // "v1=<hex>"
if (!ts || !sig?.startsWith("v1=")) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${body}`)
.digest("hex");
const provided = Buffer.from(sig.slice(3), "hex");
const expectedBuf = Buffer.from(expected, "hex");
if (provided.length !== expectedBuf.length) return false;
if (!crypto.timingSafeEqual(provided, expectedBuf)) return false;
// Reject responses older than 5 minutes (replay protection).
const age = Math.floor(Date.now() / 1000) - Number(ts);
return Math.abs(age) <= 5 * 60;
}import hmac, hashlib, time
def verify(secret: str, body: str, headers: dict) -> bool:
ts = headers.get("x-buybizz-timestamp")
sig = headers.get("x-buybizz-signature", "")
if not ts or not sig.startswith("v1="):
return False
expected = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig[3:]):
return False
return abs(int(time.time()) - int(ts)) <= 300curl -X POST https://api.buybizz.app/api/v1/licenses/heartbeat \
-H "Authorization: Bearer bz_pk_xxxx.bz_sk_yyyy" \
-H "Content-Type: application/json" \
-d '{"key":"BZ-...","productSlug":"your-product-abc12345"}' \
-i | head -n 20Error codes
Stable machine-readable codes you can branch on.
| Code | HTTP | Description |
|---|---|---|
missing_bearer | 401 | Authorization header missing or not Bearer. |
missing_secret | 401 | Bearer token didn't include the secret half. |
invalid_credentials | 401 | Wrong prefix, wrong secret, or revoked key. |
timestamp_out_of_range | 401 | X-BuyBizz-Timestamp is more than 5 minutes off. |
signature_mismatch | 401 | X-BuyBizz-Signature didn't match the body. |
missing_fields | 400 | key, productSlug, or fingerprint missing. |
license_not_found | 200 / valid: false | No license matches the key. |
vendor_mismatch | 200 / valid: false | License does not belong to your vendor account. |
product_slug_mismatch | 200 / valid: false | License belongs to a different product. |
license_expired | 200 / valid: false | Past expiresAt or status=EXPIRED. |
license_revoked | 200 / valid: false | Admin or vendor revoked the license. |
license_not_activated | 200 / valid: false | Pending subscription activation or unassigned. |
seat_limit_reached | 200 / valid: false | Cannot activate; max activations exhausted. |
activation_not_found | 200 / valid: false | Deactivate target fingerprint never existed. |
Webhooks (coming soon)
Reserved event names so future opt-in is non-breaking.
BuyBizz currently serves the v1 license contract pull-only — vendors poll /heartbeat to catch revoked / refunded licenses. We have reserved a forward-compatible field for an upcoming opt-in webhook system; turning it on later will not break existing integrations.
When webhooks ship, they will deliver:
license.revoked— admin or vendor explicitly revoked the key.license.refunded— the underlying order was refunded.
Until then, design your product to tolerate up to ~30 minutes of stale state and use the heartbeat as your kill switch.