Vendor API

Automate licensing from your own backend.

The Vendor API is how your systems talk to Licentry: issue keys when an order is paid, reset a customer's HWID from your support panel, suspend on a chargeback. Everything the dashboard does, your code can do, scoped to exactly what each key is allowed to touch.

i

BASE is https://licentry.cc. Every path below is relative to it. This is the vendor management API; the client-facing session endpoints your app ships with live in the integration docs.

Authentication

Create an API key in your dashboard under API keys. Each key is shown once, is tied to your account, and carries only the scopes you grant it. Send it as a bearer token:

Authorization header
Authorization: Bearer lk_live_9fA2x…

Keys look like lk_live_ followed by 40 characters. Only a hash is stored, so a leaked database does not expose usable keys. Rotate or revoke a key from the dashboard at any time; a revoked key stops working immediately.

!

API keys are for server-to-server use. Never ship one in a client binary or web page. The build token and session endpoints, not an API key, are what your distributed app uses.

Keys can be minted vendor-wide or bound to a single product (pass productId when creating one, or create it from the product's settings page). A product-bound key only sees and touches that product: licences of other products return 404, issuing for another product returns 403 product_scope, and /products lists just the bound product. Give each app its own key and a leak of one never exposes the rest.

Scopes

Grant each key the least it needs. A key that only resets HWIDs cannot issue licences or read billing.

ScopeGrants
licenses.readList and read licences.
licenses.issueGenerate new licence keys.
licenses.manageRevoke, suspend, resume, extend, delete. Implies read.
devices.readList devices bound to a licence.
devices.resetHWID reset and unbind a device. Implies device read.
sessions.readList runtime sessions on a licence.
sessions.killTerminate runtime sessions. Implies session read.
products.readList products.
products.writeCreate and edit products. Implies read.
analytics.readRead account details. It gates the vendor block of GET /v1/vendor/me: email, company name, status, plan id, billing status, current period end and creation date. It does not gate usage counts, despite the name it was minted with; the usage block is returned to every key, because that is how a key checks its own quota. Your dashboard lists it as Read account details under Account.
*Full access. Use sparingly.

A call missing the required scope returns 403 insufficient_scope with the scope it needed. Account-management actions (minting keys, rotating signing keys, billing) are never available to an API key; they require a dashboard sign-in.

Account

GET/v1/vendor/me

Your account, current plan, and usage against plan limits. Also returns your per-account JWKS URLs.

200 OK · abridged
{
  "vendor": { "id": "…", "email": "[email protected]", "planId": "studio", "status": "active" },
  "plan": { "id": "studio", "maxActiveLicenses": 5000, "maxProducts": 15, "maxApiKeys": 20 },
  "usage": { "activeLicenses": 812, "products": 3, "apiKeys": 4 },
  "jwks": {
    "offlineGrace": "/v1/vendor/keys/…/offline-grace-jwks",
    "responseSig":  "/v1/vendor/keys/…/response-sig-jwks"
  },
  "session": { "impersonated": false }
}

Both blocks are abridged: plan and usage carry more counters than shown. The vendor block is the one to check against your key's scopes, because a key without analytics.read receives { "id": "…" } and nothing else there. The email in the sample is not sent to such a key. Everything else on this response, including usage, goes to every key.

session.impersonated is true only when Licentry staff are signed into your account through a support session, and it is a boolean rather than a name because whichever of us it is, is our business and lives in the audit trail. An API key always sees false.

Products

A product is the app you license. Its slug is stamped onto every key and is what your client sends to activate.

GET/v1/vendor/products

List your products. Scope products.read.

POST/v1/vendor/products

Create a product. Scope products.write.

shell
curl -sS -X POST "$BASE/v1/vendor/products" \
  -H "Authorization: Bearer $LICENTRY_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "slug": "desktop-app", "name": "Desktop App", "maxDevicesPerKey": 2 }'
PATCH/v1/vendor/products/:id

Update a product. Scope products.write, which covers name and archived and nothing else on its own.

!

Every field in the table below is a runtime protection, and an API key cannot set any of them. Changing one needs an account owner or an admin seat signed in to the dashboard; a key holding products.write, or even *, is refused with 403 policy_change_forbidden and the response names the fields it tried to touch. That is deliberate: products.write is an ordinary integration scope, held by keys minted to do something as small as rename a product, and it used to carry the power to turn every defence on the tenant off in one request. Renaming stays open to it. Weakening does not.

Each field below is one line here and a control on the product page. What your client has to do differently for each one, and the mistake people actually make, is in the integration guide under switch the protections on. A product nobody has configured enforces none of these.

FieldDefaultWhat it does
requireBuildTokenfalseRefuse session calls that carry no x-licentry-build header. Until this is on, revoking a leaked release does nothing, because the leaked binary can just stop sending the header.
requireDpopfalseRefuse to mint or renew a session that is not bound to a proof-of-possession key. This is what a copied installation folder cannot defeat. Bind your fleet before turning it on, since it retires sessions that predate it.
requireDpopBodyfalseRefuse a DPoP proof that carries no bh claim, on heartbeat and refresh. Without body binding a proof covers the method, the URL and the token, so a proxy on the customer's own machine can rewrite the body and forward the client's genuine proof. Ship a client that sends bh first: see body binding on the proof.
maxConcurrentSessions1 from the deployment defaultHow many live sessions one licence may hold at once, 1 to 20, or null to inherit the deployment default. The oldest is evicted when a new activation exceeds it.
maxDevicesPerKey1How many devices one licence may bind, 1 to 100. Unlike the concurrency cap this refuses, with 403 too_many_devices.
engineParamsnullAn object up to 16 KB, sealed at rest under your account's key and never returned to anyone, including you. Delivered to your client inside the session, alongside the reserved _session and _mark blocks. Editing it advances _session.epoch. Pass null to clear it. What belongs in it is worth getting right: see the worked example.
engineSecretPinnedfalseThis setting currently does nothing, and it is recorded as a defect rather than a feature. It was meant to stop _session.secret being re-derived on every heartbeat, as an escape hatch for an engine that caches the value at activation. Nothing passes the flag through to the derivation, so the secret rotates per beat for every product whatever this box says, and a vendor who ticks it to unblock a shipped engine stays broken with no error to read. Leave it alone. The fix is in the client: read _session.secret out of the blob you just opened, on every response.
payloadKeynullA key you hold, 16 to 4096 characters, sealed at rest and released only inside a live session's engine blob, so a build that never activates has a file it cannot open. Never returned once written, so keep your own copy. Saving a new one advances _payload.version. Pass null to clear it.
payloadRequiresDpopfalseRelease the payload key only to a session bound to a proof-of-possession key, so somebody who copies an installation folder gets a working session and still cannot open your payload. On such a session the key always arrives as _payload.sealed rather than _payload.key, so the client has to handle that shape before you turn this on.
vendorResponseSigningfalseSign this product's runtime responses with your account's own key instead of the platform's shared one. Your client has to pin your JWKS first, and there is no overlap signature, so the order matters: the rollout is written out in the integration guide.
minProtocolVersion0The oldest client protocol version this product opens a session for. 0 accepts everything. Read at activate and nowhere else, so raising it never cuts a session already running, and it is what the client declares about itself rather than something the server measures.
shell
curl -sS -X PATCH "$BASE/v1/vendor/products/$PRODUCT_ID" \
  -H "Authorization: Bearer $LICENTRY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "requireBuildToken": true,
        "requireDpop": true,
        "maxConcurrentSessions": 1,
        "engineParams": { "tableSeed": "…", "assetKeyId": 7 }
      }'

Client builds

A build token identifies one release of your client. It is not a secret, it ships inside the binary as plaintext, and it does not authenticate anything. Treat it as a revocable version number: registering each release gives you a way to cut one off after it leaks.

Minting and revoking build tokens is admin work, so these routes need an account owner or an admin seat, not a developer seat.

GET/v1/vendor/client-builds

List your registered builds and their tokens.

POST/v1/vendor/client-builds

Register a release and get its token back. Pass productId to pin the token to one product, so a token lifted from one binary cannot act on another. Pinning also decides which key signs the response: with it, every answer on that product carries the same kid whether the licence key was good, bad or never issued at all, and without it a key that does not resolve is answered under the deployment key instead of yours. Accounts are capped at 500 live builds; revoke the releases you no longer ship.

shell
curl -sS -X POST "$BASE/v1/vendor/client-builds" \
  -H "Authorization: Bearer $LICENTRY_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "label": "Desktop App 4.2.0", "version": "4.2.0", "productId": "$PRODUCT_ID" }'
POST/v1/vendor/client-builds/:id/revoke

Cut off a release. Every session route then answers 426 to that token. This only binds while the product has requireBuildToken on, because otherwise the revoked binary can drop the header and carry on.

Licences

POST/v1/vendor/licenses

Issue one or more keys for a product. Scope licenses.issue. The plaintext keys are returned once; only hashes are stored.

shell · issue 3 one-year keys
curl -sS -X POST "$BASE/v1/vendor/licenses" \
  -H "Authorization: Bearer $LICENTRY_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "product": "desktop-app", "count": 3, "validDays": 365, "note": "order #1024" }'
201 Created
{
  "keys": [
    { "id": "…", "licenseKey": "aB3xK9m-7Qp2n-M4kL8z-Xy1Wq9r", "product": "desktop-app", "validUntil": "2027-07-24T…" }
  ],
  "notice": "Store these keys now; only their hashes are kept."
}
GET/v1/vendor/licenses

List licences. Scope licenses.read. Query: product, status (active/suspended/revoked), limit, offset.

GET/v1/vendor/licenses/:id

One licence. Scope licenses.read.

POST/v1/vendor/licenses/:id/revoke
POST/v1/vendor/licenses/:id/suspend
POST/v1/vendor/licenses/:id/resume
POST/v1/vendor/licenses/:id/extend

Lifecycle actions. Scope licenses.manage. Revoke and suspend invalidate live sessions on the next heartbeat; resume credits the paused time back onto the expiry; extend takes { "days": n } (negative to shorten).

DELETE/v1/vendor/licenses/:id

Permanently delete a licence row. Scope licenses.manage.

Devices & HWID

GET/v1/vendor/licenses/:id/devices

List the machines bound to a licence, with first/last IP and last-seen. Scope devices.read.

POST/v1/vendor/licenses/:id/hwid-reset

Clear every bound device so the licence can activate on a fresh machine. Scope devices.reset. This bumps the revocation version and ends live sessions on the old machine at its next heartbeat, exactly like the dashboard button and your support team's most common request.

shell · reset a customer's HWID
curl -sS -X POST "$BASE/v1/vendor/licenses/$LICENSE_ID/hwid-reset" \
  -H "Authorization: Bearer $LICENTRY_KEY"
DELETE/v1/vendor/licenses/:id/devices/:deviceId

Unbind a single device without resetting the whole licence. Scope devices.reset.

Runtime sessions

Sessions are live activations of a key: which machine, from which IP, heartbeating when. Use them to see who is running your software right now and to pull the plug on a single machine without touching the rest of the licence.

GET/v1/vendor/licenses/:id/sessions

Newest first, with per-session state (live, idle, expired, evicted, killed, ended), device hash, IP with country when available, heartbeat count and expiry. ?active=1 filters to sessions that can still heartbeat. Scope sessions.read.

POST/v1/vendor/licenses/:id/sessions/:sessionId/kill

Terminate one session. The client receives session_killed on its next heartbeat and aborts instead of silently re-activating. Devices stay bound. Scope sessions.kill.

POST/v1/vendor/licenses/:id/sessions/kill-all

Terminate every live session on the licence in one call. Returns the count. Scope sessions.kill.

GET/v1/vendor/licenses/:id/insights

The full picture for one licence: the licence and its product, bound devices, session history, validation and network stats, plus a key-sharing analysis with named indicators, a severity on each, raw counts, and a verdict of none, possible or strong. Nothing in it enforces anything. This is what powers the licence console in the dashboard. Scope licenses.read.

The evidence is computed when this endpoint is called, not on a timer, so a licence nobody reads produces no verdict. That also makes this the call that emits sharing.suspected: poll it on the licences you care about if you want the alert without opening them by hand. Which indicators exist, what each one counts and the level each fires at are on the hardening guide in your dashboard rather than in this public reference, because a shared key is worth a lot more to the person sharing it once they know exactly where the lines are.

Public JWKS

Every account has its own offline-grace and response-signing keys, so the offline entitlement tokens your app honours, and the runtime responses it verifies once you opt in, are signed with keys only you hold. The same isolation applies to licence keys at rest: each account's keys are hashed under its own sealed pepper, never a shared secret, so nothing outside your tenant can verify or forge them. These endpoints are public (no auth) and return a standard JWK set.

GET/v1/vendor/keys/:vendorId/offline-grace-jwks
GET/v1/vendor/keys/:vendorId/response-sig-jwks

Find your vendorId and the exact URLs on the Signing keys page of your dashboard, or in the jwks block of GET /v1/vendor/me. Pin these in your client, or fetch them once at build time and cache them. Rotating from the dashboard publishes the new public key here beside the current one, and nothing is signed with it until you promote it. That gap is the window in which to ship a build that pins both. A 404 means the key is not configured for your account yet, offline_grace_not_configured or response_sig_not_configured.

Offline grace: pin your own key. A licence belonging to a vendor account is signed with that account's key and there is no fallback, so if your key cannot be used the offlineGraceJwt field is simply absent rather than signed with the platform key. GET /v1/sess/offline-grace-jwks serves the deployment key, which signs only licences that belong to no vendor account, so verifying your own tokens against it fails every time. Select the verifier from the kid in the JWT header.

Response signing: which key to pin depends on one switch. vendorResponseSigning is off by default, and while it is off your product's responses are signed with the deployment key from GET /v1/sess/response-sig-jwks. Turn it on and every response for that product, refusals included, is signed with your account's key from the endpoint above, with no platform overlap signature riding along. Pass productId when you register a build token, so the same kid arrives on an approval, on a refusal, and on an answer for a key that was never issued. Pin both sets in the release that spans the change, then drop the deployment keys once nothing in the field still needs them. The full order, and what happens if your key is ever unavailable, is under vendorResponseSigning in the integration guide.

Discord integration

Studio accounts can wire their Discord server straight into their keys. You connect exactly one server: generate a link code on the Discord page of the dashboard, run /link-account in your server, done. From there the Licentry bot gives you three things.

  • A redeem panel. The bot posts a message with a button; customers press it, paste their key, and their Discord account is stored on that licence. Title, copy, colors, button, image and the confirmation message are all editable on the dashboard, with a live preview. Post it with /panel post, and optionally grant a customer role on every redeem.
  • Key commands for your staff. /keys gen (with product autocomplete), /keys info by key, licence id or member, /keys reset for HWID resets, /keys ban, /keys unban and /keys revoke. Admin commands need Manage Server; replies containing keys are always private.
  • Activity logs. Key events (redeems, new devices, blocked activations, hardware mismatches, resets, bans) stream to a channel via /logs set, to a Discord webhook you paste into the dashboard, or both. Pick exactly which events on the dashboard.

Every command is checked server side against your account and plan, so a leaked bot or a spoofed guild cannot touch keys that are not yours. The Discord user id and username stored on a redeemed key show up in the licence console and in GET /v1/vendor/licenses responses as a discord object.

Webhooks

Register an HTTPS endpoint and Licentry posts every key event to it as signed JSON, seconds after it happens. Activations, device binds, blocked activations, sharing verdicts, the whole key lifecycle. Your backend stops polling /licenses on a timer and starts reacting. A second, smaller group covers your own Licentry account rather than your customers, so a sign in from an unfamiliar country or a newly minted API key reaches you rather than only us.

Registering an endpoint

Endpoints live on the Webhooks page of your dashboard. Up to five per account, each with its own signing secret and its own list of events. Subscribe to nothing and the endpoint gets everything, including events added after you set it up. Webhooks need an active plan or a running trial.

The URL has to be public HTTPS. We resolve it before every delivery and refuse loopback, link-local and private address space, so a webhook can never be pointed back at our own network. Redirects are not followed either: a 3xx counts as a failed delivery, because following it would re-target the request after the safety check ran. Point the endpoint at its final URL.

i

Registering and re-pointing endpoints is dashboard-only. An API key with * can read your endpoints and the delivery log, but no key can create one or change its URL, so a leaked key cannot redirect your event stream.

Events

EventFires when
license.activatedA key completes activation and a runtime session starts.
device.boundA key binds a hardware slot it had not used before.
activation.blockedA key hits its device cap and activation is refused.
session.mismatchA live session shows up from different hardware and is cut.
session.killedRuntime sessions are killed from the dashboard, API or bot.
sharing.suspectedA licence's evidence is computed and comes out at a strong verdict, which happens when the licence insights are read from the dashboard or the API. Rate limited per licence so one shared key cannot flood your endpoint. Treat it as evidence worth reading, not as proof: open the licence and judge from the timeline before you revoke anything.
license.createdNew license keys are created.
license.redeemedA customer ties a key to their Discord account.
license.unlinkedA Discord account is detached from a key.
license.suspendedA key is put on hold.
license.resumedA suspended key is reinstated.
license.revokedA key is permanently killed.
license.extendedTime is added to or removed from a key.
device.resetBound devices are cleared so a key can move machines.
account.login_new_locationA sign in to your Licentry account succeeds from a country, or from a browser and operating system, that the account has not been used from in the last 180 days. Sign in history is kept for 180 days and then removed, so a device you have not touched since last year counts as new again. The first sign in after this event was introduced establishes the baseline and never alerts.
account.twofa_changedTwo factor authentication on your account is turned on or off, or its recovery codes are replaced.
account.member_addedSomebody is invited to help manage your account. The invite is a second way to sign in to the same tenant, so it is worth seeing.
account.api_key_createdAn API key is minted from your dashboard. Carries the key prefix, never the key.
account.webhook_updatedAn endpoint is registered, or an existing one has its URL, event list or enabled state changed. Delivered to the endpoints that were registered before the change, which is what makes it useful: if somebody re-points your URL, the address they pointed away from is the one that gets told, and it carries both the old URL and the new one.

The last five describe your Licentry account rather than your customers, and they are the ones worth wiring to something that pages a human. Nobody else can tell us that a sign in from a new country was not you.

They reach your webhook endpoints only. We keep them off the Discord log feed on purpose: that channel is usually readable by the people you work with, and where the account owner signs in from, when the second factor changes and who minted an API key are not things to publish to a room that may contain the person the alert is about.

The Send test button on the dashboard posts one extra type, webhook.test, straight to the endpoint. It skips the queue and never lands in the delivery log, so use it to prove the plumbing works without waiting for a real event.

The payload

Every delivery is a POST with the same envelope. id is the delivery id, type is the event, and data carries whatever fits the event: typically the licence id, the product slug, a masked key and who did it.

POST to your endpoint
{
  "id": "9d1f7c0a-3b6e-4f21-9f2c-0f6a8b41d5e7",
  "type": "license.activated",
  "createdAt": "2026-07-24T18:04:11.220Z",
  "vendorId": "b3c1…",
  "data": {
    "licenseId": "7f2e…",
    "product": "desktop-app",
    "keyMasked": "aB3x…Wq9r"
  }
}

Verifying the signature

Three headers ride on every POST. Check the signature before you trust the body.

headers
Licentry-Signature: t=1753380251,v1=8f14e45fceea167a5a36dedd4bea2543…
Licentry-Event:     license.activated
Licentry-Delivery:  9d1f7c0a-3b6e-4f21-9f2c-0f6a8b41d5e7

v1 is HMAC-SHA256, hex encoded, over the string t + "." + raw body, keyed with that endpoint's secret. The timestamp is inside the signed material, so a captured delivery cannot be replayed later under a fresh header. Sign the bytes exactly as they arrived: re-serializing the JSON changes them and the signature will not match.

node
import crypto from 'crypto';

function verify(rawBody, header, secret) {
    const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec((header || '').trim());
    if (!m) return false;
    if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;

    const expected = crypto.createHmac('sha256', secret)
        .update(m[1] + '.' + rawBody)
        .digest('hex');
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
python
import hashlib, hmac, re, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    m = re.fullmatch(r"t=(\d+),v1=([a-f0-9]{64})", (header or "").strip())
    if not m:
        return False
    if abs(time.time() - int(m.group(1))) > 300:
        return False

    signed = m.group(1).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, m.group(2))
!

Reject anything whose t is more than 5 minutes off your clock, and always compare with a constant-time compare. Dedupe on Licentry-Delivery: a retry reuses the same delivery id, so a receiver that acknowledged late can see the same event twice.

Answer 2xx as soon as the signature checks out, then do the real work in a queue. We give up on a request after 10 seconds and count it as a failure, and anything outside 200-299 is a failure too.

Retries and auto-disable

A delivery gets eight attempts in total. After the first one fails we wait 15 seconds, then 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. That is roughly 20 hours of cover, enough to ride out an overnight outage on your side. After the last attempt the delivery is marked failed and stays in the log, where you can replay it by hand.

An endpoint that fails 40 times in a row is switched off automatically, so one dead URL cannot generate deliveries forever. The dashboard shows the reason on the endpoint card. Fix the receiver, send a test, then switch it back on; enabling clears the failure counter. Events that happen while an endpoint is off are not queued for it.

Reading the delivery log

GET/v1/vendor/webhooks/deliveries

Every attempt we made, newest first, with status (pending, delivered, failed), attempt count, the HTTP code and error we got back, how long it took, and when the next retry is due. Query: webhookId, limit (default 50, max 200). Scope *. The log keeps 30 days.

GET/v1/vendor/webhooks

Your endpoints and the event catalog. Scope *. Signing secrets are never in this response; they are readable only from a dashboard sign-in.

Errors & limits

Errors are JSON with an error code and, where useful, a human message.

StatusCodeMeaning
401unauthorizedMissing or invalid API key.
403insufficient_scopeKey lacks the required scope (returned in required).
403vendor_suspendedAccount suspended; settle billing to restore access.
403plan_limitA plan quota (licences, products, keys) was reached.
404not_foundNo such resource under your account.
429rate_limitedBack off; honour the RateLimit-* headers.