Developer documentation

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.

  1. Create your product in /dashboard/vendor/products/new with delivery type License Key Only and a license policy.
  2. Generate a key pair in API Keys. The secret is shown once — copy it to a secrets manager.
  3. On your customer's "Enter your license key" screen, call POST /api/v1/licenses/activate with the customer key, your productSlug, and a stable device fingerprint.
  4. Verify the X-BuyBizz-Signature response header before trusting the body.
  5. Once a day (or every 30 minutes for premium gates), call POST /api/v1/licenses/heartbeat to catch refunds and revocations.

Integrating with your product

A platform-agnostic walkthrough of where each call belongs in your app.

1

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.

2

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.

Electron / Node desktop
// 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");
}
Python CLI / desktop
import hashlib, platform, uuid

def fingerprint() -> str:
    raw = f"{uuid.getnode()}:{platform.node()}"
    return hashlib.sha256(raw.encode()).hexdigest()
Web SaaS (server-side)
// 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");
}
.NET / Unity (Windows)
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();
}
iOS (Swift)
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()
}
Android (Kotlin)
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) }
}
Avoid raw MAC addresses, IPs, browser-fingerprint-as-a-service products, or any value that changes when the user reinstalls the OS / clears cookies. Either of those shows up as a brand-new seat and will exhaust maxActivations.
3

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 typeWhere the secret lives
Server-side SaaS / APIprocess.env.BUYBIZZ_SECRET backed by your secrets manager (AWS Secrets Manager, Doppler, Vault, Railway env, etc.).
Electron / desktop appOS keychain via keytar (macOS Keychain / Windows Credential Manager / GNOME libsecret).
Python desktop / CLIThe keyring package (same OS backends as keytar).
iOS / Android appiOS Keychain Services / Android Keystore. Never UserDefaults or SharedPreferences.
Browser / SPANever ship the secret to the browser. Proxy through your own backend; the secret only ever leaves your server.
Unity game / .NET desktopDPAPI on Windows; SecKeychain on macOS. Don't hardcode in shipping binaries — they'll be extracted with strings in a day.
If the secret ever leaks (committed to GitHub, found in a crash dump, etc.), revoke it from /dashboard/vendor/api-keys and create a new one. Old keys stop working within a few seconds of revocation.
4

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: true is < 7 days old, keep the user logged in. Hard-stop only after the grace window expires.
  • Never retry on 401 or any 200 with valid: false — those are stable answers, not transient.
Node.js — verify with cache + grace period
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;
}
5

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.

6

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/*.

POST/api/v1/licenses/validate

Verify 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
}
POST/api/v1/licenses/activate

Register 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": "..."
}
POST/api/v1/licenses/deactivate

Release 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 }
POST/api/v1/licenses/heartbeat

Cheapest 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.

BuyBizz does not push outbound webhooks in v1. To catch revoked, refunded, or expired licenses, your product is responsible for polling the heartbeat endpoint.
  • 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 /heartbeat first 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.

Node.js
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;
}
Python
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)) <= 300
curl
curl -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 20

Error codes

Stable machine-readable codes you can branch on.

CodeHTTPDescription
missing_bearer401Authorization header missing or not Bearer.
missing_secret401Bearer token didn't include the secret half.
invalid_credentials401Wrong prefix, wrong secret, or revoked key.
timestamp_out_of_range401X-BuyBizz-Timestamp is more than 5 minutes off.
signature_mismatch401X-BuyBizz-Signature didn't match the body.
missing_fields400key, productSlug, or fingerprint missing.
license_not_found200 / valid: falseNo license matches the key.
vendor_mismatch200 / valid: falseLicense does not belong to your vendor account.
product_slug_mismatch200 / valid: falseLicense belongs to a different product.
license_expired200 / valid: falsePast expiresAt or status=EXPIRED.
license_revoked200 / valid: falseAdmin or vendor revoked the license.
license_not_activated200 / valid: falsePending subscription activation or unassigned.
seat_limit_reached200 / valid: falseCannot activate; max activations exhausted.
activation_not_found200 / valid: falseDeactivate 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.