License Server API Reference
Complete endpoint reference for the License Server API. See Introduction for an overview and quick start, or Rate Limits for the full per-category request-quota breakdown.
Times. Every time in an API response is ISO 8601 in UTC (2026-10-20T16:51:02.000Z), or null when it isn't set — from License Server 1.16.0. Before 1.16.0, the times the server records itself were YYYY-MM-DD HH:MM:SS instead (2026-10-20 16:51:02): UTC, but with no zone marker, so JavaScript's Date.parse alone reads them as local time. Those fields are a license's issued_at and revoked_at, an activation's activated_at, a storefront webhook's created_at and last_event_at, a mapping's created_at, and a delivery's processed_at and refunded_at. The others — expires_at, a release's issued_at and created_at, an activation's lease_expires_at and last_seen_at, a subscription's dates — were already ISO 8601. Signed content (license files, offline exports, release manifests) is unchanged.
Code that may meet a server older than 1.16.0, or that parsed the old format by hand (value.replace(' ', 'T') + 'Z' gives an invalid date on an ISO value), should parse times with a helper that adds the zone only when one is missing:
const parseTime = (value) =>
value == null
? null
: new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? value : value.replace(' ', 'T') + 'Z');It takes an ISO 8601 value ending in Z or a ±HH:MM/±HHMM offset as it is, reads 2026-10-20 16:51:02 as UTC, and returns null for a missing value (revoked_at is null on a license that isn't revoked). The License Console reads both formats from Console 1.6.1.
API Workflow Diagrams
License Verification Flow
License Activation Flow
Admin License Management Flow
Release Registration & Product Scoping Flow
See Software Distribution below for the full endpoint reference.
product_id is a per-tenant display label, not a global claim - two different tenants may freely register releases under the identical string. The real relationship key is product_uuid, resolved (or created, on first use) from {tenant, product_id} before anything else happens:
Update Check Flow
Storefront Auto-Fulfillment Flow
Refund and subscription events (1.8.0+, 1.9.0+) arrive the same way, signed with the same secret: a refund can revoke the license (or, 1.19.0+, take back the time a subscription payment bought), and a subscription's payments and changes move its expiry — see the endpoint reference below.
Authentication
Admin endpoints require a Bearer token in the Authorization header:
Authorization: Bearer your-api-keyWhich key depends on how you're deployed. On a self-hosted deployment, this is your ADMIN_API_KEY. On the hosted (SaaS) tier, it's your tenant's own API key instead — ADMIN_API_KEY is never accepted there (see Authentication for the full explanation of why). The exceptions are the routes for whoever runs the server, which always require ADMIN_API_KEY specifically, on every deployment mode; Authentication lists them all. The three documented on this page are GET /v1/admin/build-info, since the platform-level build watermark it returns isn't a tenant's data to read; POST /v1/admin/crash-test (1.15.0+), since the crash report it sends goes to the server operator's error service; and PUT /v1/admin/tenants/:id/products/:product_uuid/text-approval (1.17.0+, under Products), since approving a product name is the operator's decision.
Rate Limits
- Validation Endpoints: 1000 req/15min (verify, validate, usage tracking, check-update)
- Activation Endpoints: 300 req/15min per IP (activate, deactivate, verify-file, verify-file-base64) —
activateanddeactivateeach also have a stricter 20 req/15min limit per license key, on top of the per-IP one (separate counts from 1.14.0; before that they shared one) - Public Endpoints: 500 req/15min (export, export/file, export offline, public-key)
- Management Endpoints: 100 req/15min (issue, revoke, update-license-terms, delete, register-release, unpublish-release)
- Admin-Info Endpoints: 300 req/15min by default (list-licenses, admin/license/:key, admin/stats, recent-activations, list-activations, reissue-token, admin/reissue-portal-token, admin/deactivate-by-instance-id, admin/reset-usage, admin/update-notes, admin/build-info, admin/crash-test, billing/status, billing/checkout, rotate-api-key, list-releases, release/:id, the admin/storefront-webhooks routes, and (1.17.0+) GET/POST/PATCH admin/products) — the only tier that's configurable, via the
ADMIN_RATE_LIMIT_MAXenvironment variable
See Rate Limits for the full breakdown.
Base URL
https://your-license-api.com/v1https://your-license-api.com is a stand-in, used in every example on this page. Put your License Server's API address in its place:
- Hosted account:
https://license-cloud-api.casazium.com, so the base URL ishttps://license-cloud-api.casazium.com/v1. That is the value the console shows under Settings → API access → API base URL,/v1included, so it replaceshttps://your-license-api.com/v1as a whole: don't add a second/v1. Notlicense-cloud.casazium.com: that's the console, and an API call sent there gets a JSON 404 naming the right address. Use your account's API key, from the same Settings section, wherever an example sendsADMIN_API_KEY: a hosted account doesn't accept it. - Self-hosted: your own server's address plus
/v1. The self-hosted bundle answers onhttp://localhost:3001on the machine running it, sohttp://localhost:3001/v1there.
The health check, GET /, is at the server root, not under /v1.
Health Endpoints
GET /
Health check endpoint to verify server status. Note this route has no /v1 prefix — it's at the server root, not /v1/.
Response:
{
"message": "License API is running",
"version": "1.0.0",
"buildFingerprint": "a1b2c3d4e5f6",
"features": { "vendor_voice": false }
}features (1.18.0+) lists optional features and whether this server has them on. vendor_voice is false by default.
License Validation
POST /v1/verify-license
Verify if a license key is valid.
Request Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28"
}Response:
{
"valid": true,
"tier": "pro",
"product_id": "my-product",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"limits": {},
"expires_at": "2027-12-31T23:59:59.000Z",
"issued_at": "2025-01-01T00:00:00.000Z",
"status": "active",
"livemode": true,
"paid_through": "2027-12-24T23:59:59.000Z"
}product_uuid is the license's real, immutable product identity - see Product IDs are per-tenant, not global below. Present on every license issued from v1.3.0 onward.
livemode and paid_through (1.20.0+):
livemodeistruefor a license from a live storefront purchase,falsefor one from a sandbox (test-mode) purchase, andnullwhen unknown: a license issued by hand or through the API, or an older one whose purchase records are gone.paid_throughis the end of the latest period paid for with money, ornull. For a storefront subscription, it's the end of its latest paid invoice that isn't fully refunded (a free trial's or a 100% coupon's $0 invoice earns none). For a paid one-time purchase with a term, it's the expiry. Otherwise it's what you set withPOST /v1/admin/update-license-terms. A refund takes it back under either refund policy: withrecord, the license keeps working, but the refunded period no longer counts as paid. A subscription loses only what its fully refunded invoices paid for; a one-time purchase loses it on any refund, partial included (set it again by hand if you refund only part). On Lemon Squeezy, subscribe the webhook to the subscription events: a subscription's first order reads as a one-time purchase until its subscription joins it.- Licenses issued before 1.20.0 have no
paid_through: a subscription's license gets one at its next payment, and a one-time license only if you set it. - Neither is signed here. The offline license file signs both.
Refusals: 404 License key not found, 403 License is not active (revoked), 403 License has expired. From 1.20.0, a sandbox license whose product refuses sandbox licenses gets 403 License is not active with "reason": "test_license" added. error is the same text as for a revoked license, so an app that matches on it needs no change.
POST /v1/validate-license
Validate a license for a specific instance.
Request Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"instance_id": "unique-instance-id"
}Response:
{
"valid": true
}valid is false when the license is revoked, expired or unknown, or when this instance_id isn't activated on it. On a license with a lease (1.14.0+), this call is a check-in: it renews the machine's lease, and a machine whose seat has been given to another machine gets false.
License Activation
POST /v1/activate-license
Activate a license for a specific instance.
Request Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"instance_id": "unique-instance-id"
}Response (first activation of this key/instance_id pair):
{
"activated": true,
"token": "9f2c1e4a7b3d6f805c1a2b3d4e5f6789",
"livemode": true
}livemode (1.20.0+) is as verify-license answers it, on new and repeat activations. A sandbox license whose product refuses sandbox licenses gets 403 License has been revoked, as a revoked one does here.
token is returned once — only on the call that actually creates the activation. Capture and store it immediately: it's the credential POST /v1/deactivate-license requires to free this seat later, and it cannot be retrieved again. If the same key/instance_id pair activates again (e.g. an app restart), the response is instead {"activated": true, "alreadyActivated": true} (plus livemode from 1.20.0) — no token, since the token from the original activation remains the only valid one.
On a license with a lease (lease_seconds, 1.14.0+; see Seats that free themselves), both answers also carry lease_expires_at, when this machine's lease runs out (ISO 8601), and activating again counts as a check-in that renews it (against this route's per-key limit of 20 requests per 15 minutes). Without a lease the field is left out. Lapsed machines still count toward the seats. When the license has no free seat, a new machine gets in only by replacing lapsed ones, longest-lapsed first: it needs enough of them to bring the license below its seats, which is one if the license is exactly full, and more if it's already over them (after max_activations was lowered, or a storefront seat cut). If there aren't that many, the activation is refused with 403 {"error": "Activation limit exceeded for this license key"} as before, and nothing is removed.
{
"activated": true,
"token": "9f2c1e4a7b3d6f805c1a2b3d4e5f6789",
"lease_expires_at": "2027-01-17T14:02:11.000Z"
}POST /v1/deactivate-license
Free a single seat so it can be activated on a different instance — e.g. a customer moving from an old machine to a new one. Distinct from POST /v1/revoke-license (Admin Endpoints, below), which invalidates the entire license; this only removes one seat's activation record. Public, like activation itself — no admin authentication required.
Requires the opaque token returned by POST /v1/activate-license when that seat was created — not instance_id. instance_id is a caller-supplied label with no guaranteed entropy, so it isn't accepted as a credential here.
Request Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"token": "9f2c1e4a7b3d6f805c1a2b3d4e5f6789"
}Response:
{ "deactivated": true }deactivated is false, not an error, if that token didn't match an active seat — this call is idempotent.
Usage Tracking
POST /v1/track-usage
Track usage for a license instance. Requires token, the activation token returned by POST /v1/activate-license — proof the caller holds a real activation for this key, since the key alone is not a secret.
Request Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"token": "9f2c1e4a7b3d6f805c1a2b3d4e5f6789",
"metric": "api_calls_per_day",
"increment": 1
}Response:
{
"ok": true,
"metric": "api_calls_per_day",
"usage": 101
}usage is the metric's new running total (an integer), not an object keyed by metric name. metric must already be a key in the license's limits — see Usage Tracking for the full allowed-metric list and error responses. Returns 403 {"error": "Invalid activation token"} if token doesn't match a real activation for this key — including once the machine's seat has been given to another machine on a license with a lease. On a license with a lease (1.14.0+), any call whose token is accepted is a check-in that renews the machine's lease, even one then refused for its metric or a limit. On the rare 503 {"error": "..."} (a concurrent update to the same license lost a retry race), just retry the request — it's safe to resend.
POST /v1/usage-report
Get usage report for a license.
Request Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28"
}Response:
{
"valid": true,
"status": "active",
"expires_at": "2027-12-31T23:59:59.000Z",
"metrics": [
{
"metric": "api_calls_per_day",
"used": 101,
"limit": 1000,
"remaining": 899,
"exceeded": false
}
]
}License Information
Export a license: key in the body
(1.21.0+) Three POST routes take the license key in a JSON body, {"key": "..."}, instead of in the URL:
| Route (1.21.0+) | Replaces (deprecated GET) |
|---|---|
POST /v1/license-export | GET /v1/export-license/:key |
POST /v1/license-export/file | GET /v1/export-license/:key/file |
POST /v1/license-export/offline | GET /v1/export-license/:key/offline |
Mind the word order: the new routes are license-export, the old ones export-license. Mixing them up (POST /v1/export-license or GET /v1/license-export) answers 404.
Each answers exactly as its GET form does: the same body, status, error messages and refusals. The GET sections below describe the responses. Both forms share one rate limit (the Public tier, see Rate Limits).
Why: a key in a URL is recorded by every proxy, load balancer or CDN that logs requests, and by browser history. In the body it isn't. If a client puts the key in the URL by mistake (/v1/license-export/<key>, which answers 404), the server keeps it out of its own logs, but your proxy's access log still records it. Send the key in the body only.
curl -X POST https://your-license-api.com/v1/license-export/offline \
-H "Content-Type: application/json" \
-d '{"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28"}'To save the encrypted .lic file:
curl -X POST https://your-license-api.com/v1/license-export/file \
-H "Content-Type: application/json" \
-d '{"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28"}' \
-o license.licOn a refusal (403 or 404) the file holds the JSON error instead; add --fail to make curl stop.
Request Body:
{ "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28" }Errors only the POST forms give:
400License key is required: no body, nokey, an emptykey, or atext/plainbody.400with the framework's own message (FST_ERR_CTP_INVALID_JSON_BODYorFST_ERR_CTP_EMPTY_JSON_BODY): the body is sent as JSON but is malformed or empty.415: any other content type.curl -dwithout-H "Content-Type: application/json"sends form data and gets this.
From a browser. Browsers can call them: cross-origin requests are allowed, and the browser caches the preflight check for an hour. A POST can't be a plain download link: fetch /file and save the response yourself. From 1.21.1, a script on another origin can read the Content-Disposition header and save the file under the name it gives (license-<product_id>.lic); against 1.21.0 it can't, so name the file yourself. Buyers can still download it from the license portal.
The GET forms are deprecated (1.21.0). They still work, and every answer they give, errors included, carries two extra headers, both readable by browser scripts:
Deprecation: @1793059200
Link: <https://docs.casazium.com/docs/license-server/api-reference#export-a-license-key-in-the-body>; rel="deprecation"Deprecation is an RFC 9745 date: 27 October 2026, when 1.21.0 deprecated them. It is not a removal date. The Link points to this section. They'll be removed in 2.0.0, except GET /v1/export-license/:key/offline, which stays longer for apps already installed.
The SDK:
- SDK 0.4.x and earlier call the
GETforms. They keep working until 2.0.0. After that onlyexportOfflineLicense()keeps working;exportLicense()stops. - SDK 0.5.0 and later use the
POSTforms, and need License Server 1.21.0 or later: they don't fall back to theGETforms. If you run your own server, upgrade it first, then change your dependency to^0.5.0(^0.4.xwon't pick it up). No code changes. - Against an older server, the SDK's export methods throw
404with the messageexportLicense failed with 404: Not Found(orexportOfflineLicense …). That is a route the server doesn't have, not a refused key: an unknown key saysLicense key not found. - If you call the API directly, switch now.
GET /v1/export-license/:key
Deprecated (1.21.0): use POST /v1/license-export. Removed in 2.0.0.
Export a signed license object.
Parameters:
key(path) - The license key
Response:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"tier": "pro",
"product_id": "my-product",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"expires_at": "2027-12-31T23:59:59.000Z",
"limits": {},
"sig": "3f2c9e1a7b5d8f0c1e4a6b9d2f5c8e0a1b3d5f7c9e1a3b5d7f9c1e3a5b7d9f1c"
}issued_to is deliberately omitted — this export is meant to be embedded in a signed payload a consuming app verifies against, and the purchaser's email has no reason to travel with it. GET /v1/export-license/:key/file below is the one export variant that does include it (its own Content-Disposition-filename presentation model already assumes an operator-facing context); .../offline matches this route in leaving it out.
GET /v1/export-license/:key/file
Deprecated (1.21.0): use POST /v1/license-export/file. Removed in 2.0.0.
Export a license as an encrypted, signed binary .lic file (HMAC — same signing mechanism as GET /v1/export-license/:key, just packaged as a downloadable file rather than JSON). Returns 403 if the license is revoked or expired — a non-active license can't be re-exported with a fresh valid signature. Unlike the other two export variants on this page, its encrypted payload includes issued_to.
Parameters:
key(path) - The license key
Response: Not JSON — Content-Type: application/octet-stream, Content-Disposition: attachment; filename="license-<product_id>.lic", with the encrypted license bytes as the body. (1.21.0+) The file is named after the product, not the license key, here and from the buyer portal; characters other than letters, digits, ., _ and - become _, and the product part is cut to 100 characters. Before 1.21.0 it was license-<key>.lic.
GET /v1/export-license/:key/offline
Deprecated (1.21.0): use POST /v1/license-export/offline. Unlike the other two, it stays after 2.0.0 for a while, for apps already installed.
Export a license payload RSA-signed with the server's private key. Unlike GET /v1/export-license/:key (HMAC, shared secret), this can be verified with only the public key (see below) — no server contact required at verification time.
Parameters:
key(path) - The license key
Response:
{
"license": {
"type": "license",
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"tier": "pro",
"product_id": "my-product",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"expires_at": "2027-12-31T23:59:59.000Z",
"limits": {},
"issued_at": "2025-01-01T00:00:00.000Z",
"exported_at": "2026-06-01T08:00:03.512Z",
"livemode": true,
"paid_through": "2027-12-24T23:59:59.000Z"
},
"signature": "base64-encoded-rsa-signature"
}Verify the license object exactly as received: JSON.stringify of the parsed object, in the order the response gives it. Don't rebuild it from the fields you know, sort its keys, or round-trip it through a typed struct: fields may be added after limits, and a rebuilt object fails verification. Treat every field after limits as optional, and ignore the ones you don't use.
Signed after limits (1.20.0+), in this order:
issued_at— when the license was issued, as verify-license answers it. Left out only if the stored value can't be read.exported_at— the server's clock when it signed the file. To limit how long an app runs offline, count from the signedexported_atrather than a time your app saved. A local clock well before it (allow some clock difference, say a day) was set back.livemode—true(live purchase) orfalse(sandbox purchase). Left out when unknown, nevernull.paid_through— the end of the latest period paid for with money. Left out when there is none. Base a "keep what you paid for" rule on it (for example, keep working after a paid year): unlikeissued_atandexpires_at, a trial or a free period can't stretch it.
Files from servers before 1.20.0 have none of these fields, and older SDKs verify the new files. A verifier that requires the new fields refuses the older files.
issued_to is deliberately omitted here too, same reasoning as GET /v1/export-license/:key above.
type is always "license" (1.10.0+). Release manifests from POST /v1/check-update are signed with the same key, so a verifier must check type to tell a license from a manifest — without it, a manifest, which any license holder can fetch, verifies as a license with no key and no expiry.
Verify locally with the RSA public key (no server call needed). The SDK's verifySignedFile() makes all four checks below; by hand, make every one:
import { createVerify } from 'node:crypto';
const verifier = createVerify('SHA256');
verifier.update(JSON.stringify(license));
verifier.end();
const authentic = verifier.verify(publicKey, signature, 'base64');
const expiry = license.expires_at === null ? Infinity : Date.parse(license.expires_at);
const valid =
authentic &&
license.type === 'license' && // not a release manifest
license.product_uuid === expectedProductUuid && // your product
expiry > Date.now(); // null is perpetual; an unparseable date is NaN, so refusedThis confirms the payload hasn't been tampered with, but it doesn't know about revocations issued after export — use POST /v1/verify-license-file or POST /v1/verify-license-file-base64 instead if you need live checks. A file exported before 1.10.0 has no type: export it again.
POST /v1/verify-license-file
Verify a license's HMAC signature and status, submitted as a JSON body — despite the name, this does not accept a file upload.
Request Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"tier": "pro",
"product_id": "my-product",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"expires_at": "2027-12-31T23:59:59.000Z",
"limits": {},
"sig": "3f2c9e1a7b5d8f0c1e4a6b9d2f5c8e0a1b3d5f7c9e1a3b5d7f9c1e3a5b7d9f1c"
}Post the object exactly as POST /v1/license-export (or the deprecated GET /v1/export-license/:key) returned it — the same fields in the same order. (The encrypted /file export is checked with POST /v1/verify-license-file-base64 instead.) The signature covers every field present, so adding, removing or reordering one fails with Invalid signature. key, tier, product_id and sig are required.
Response (200):
{
"valid": true,
"reason": null
}On failure (403), reason is one of "Invalid signature", "Expired", "Revoked or not found", or (1.10.0+) "License terms have changed - export a new license file" — the DB is checked live on every call, unlike the offline-RSA export above.
Out-of-date files (1.10.0+). The file's terms are compared with the license's current ones. It's refused as out of date when the license now expires earlier than the file says (or a perpetual license was given an expiry), its tier or limits changed, or the file names a different product_uuid. A later expiry — every subscription renewal — doesn't make a file out of date: it stays valid to its own expires_at. On this reason, export a new file and use that.
POST /v1/verify-license-file-base64
Same live signature + revocation check as above, but for a license file produced by POST /v1/license-export/file (or the deprecated GET /v1/export-license/:key/file) — pass its base64/encrypted contents as license_file, not the individual license fields.
Request Body:
{
"license_file": "base64-encoded-encrypted-license-contents"
}Response (200):
{
"valid": true,
"license": {
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"tier": "pro",
"product_id": "my-product",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"issued_to": "customer@example.com",
"expires_at": "2025-12-31T23:59:59.000Z",
"limits": { "seats": 5 }
}
}license.limits carries the license's limits (before 1.10.0 it was always {}).
Failure reasons: 400 with an empty body for a missing license_file, 400 "Incomplete license object" / "Malformed or corrupt license_file", or 403 "Invalid signature" / "Expired" / "Revoked or not found" / (1.10.0+) "License terms have changed - export a new license file" (the same out-of-date rule as above).
GET /v1/public-key
Get the RSA public key used to verify offline-signed license files (see above). Fetch it once and cache/embed it in your application — it isn't meant to be called before every verification.
Response:
{ "publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" }Admin Endpoints
INFO
All admin endpoints require Bearer token authentication. On self-hosted deployments this is your ADMIN_API_KEY; on the hosted (SaaS) tier it's your tenant's own API key instead — see Authentication above. The routes for whoever runs the server, such as GET /v1/admin/build-info and POST /v1/admin/crash-test below, are the exceptions, requiring ADMIN_API_KEY specifically on every deployment mode.
POST /v1/issue-license
Issue a new license.
Headers:
Authorization: Bearer your-api-keyRequest Body:
{
"tier": "pro",
"product_id": "my-product",
"issued_to": "customer@example.com",
"expires_at": "2027-12-31T23:59:59.000Z",
"limits": {},
"max_activations": 5,
"lease_seconds": 7776000
}max_activations is how many machines can be activated at once; without it, the license works on one.
lease_seconds (1.14.0+, optional) frees the seat of a machine that hasn't checked in for that many seconds, when another machine needs one: a whole number from 86400 (1 day) to 31536000 (365 days), or null. Omitted or null, seats are permanent until deactivated, as before. See Seats that free themselves. A whole number out of range returns 400 {"error": "lease_seconds must be a whole number of seconds from 86400 (1 day) to 31536000 (365 days), or null"}.
Response:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"status": "issued",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"portal_token": "a1d5163d1e1e4cd7e0e46f577dbeb6f2"
}portal_token is the credential for the end-user license portal below — distinct from the license key itself, returned once, here. It cannot be retrieved again except via POST /v1/admin/reissue-portal-token, which also rotates it.
product_id is scoped to your own tenant — a second, unrelated tenant issuing under the identical product_id string on a hosted account succeeds independently, with its own product_uuid; there's no shared namespace to collide over. See Product IDs are per-tenant, not global.
POST /v1/revoke-license
Revoke or reactivate a license.
Headers:
Authorization: Bearer your-api-keyRequest Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"revoked": true
}Response:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"status": "revoked"
}Setting revoked: false reactivates the license, returning status: "active". Returns 409 if the license is already in the requested state, and 403 {"error": "License has expired"} if reactivating a license whose expires_at has already passed.
On a hosted deployment, reactivating (revoked: false) re-runs the same subscription/quota checks POST /v1/issue-license does — un-revoking is the one other way a license's status flips back to active, so it's gated the same way to prevent a tenant working around their issuance quota by revoking and un-revoking instead of issuing fresh. Returns 403 {"error": "Subscription is not active"} if the tenant's billing subscription isn't current, or 403 {"error": "License issuance quota exceeded"} if reactivating would push them over their plan's active-license limit. Not applicable self-hosted, where there's no subscription/quota concept.
DELETE /v1/delete-license
Delete a license permanently.
Headers:
Authorization: Bearer your-api-keyRequest Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28"
}Response:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"deleted": true
}POST /v1/admin/update-license-terms
Patch expires_at, max_activations, limits, lease_seconds and/or (1.20.0+) paid_through on an existing license, without deleting and re-issuing it — re-issuing cascades away every activation on the old key (ON DELETE CASCADE), forcing the customer to re-enter a new key in every deployed copy of their software.
Headers:
Authorization: Bearer your-api-keyRequest Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"expires_at": "2028-12-31T23:59:59.000Z",
"max_activations": 10,
"limits": {},
"lease_seconds": 7776000
}At least one of expires_at, max_activations, limits or lease_seconds is required; any field you omit is left unchanged. expires_at: null makes the license perpetual, same as POST /v1/issue-license. limits replaces the whole object — there's no partial/merge mode. Lowering max_activations below the license's current activation count is allowed (a soft cap; no existing activation is force-deactivated to fit), but see activations_count in the response below.
lease_seconds (1.14.0+) takes the same values as on POST /v1/issue-license; null turns the lease off. A change applies to the machines already activated, in the same call: setting a lease gives each machine without one a full lease from now, and shortens any longer lease to the new length; null makes every seat permanent again. See Existing licenses.
paid_through (1.20.0+) is an ISO 8601 date-time such as 2027-11-01T10:00:00Z, or null to clear it: the end of the latest period the buyer paid for (see verify-license). Set it on a license you sold outside a storefront, or on an existing one-time license, which 1.20.0 doesn't fill in. On a storefront subscription's license, its next payment or refund recomputes it. It must be a full date-time with a time zone (2027-11-01T10:00:00Z); anything else is 400. The response echoes it.
Response:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"expires_at": "2028-12-31T23:59:59.000Z",
"limits": {},
"max_activations": 10,
"lease_seconds": 7776000,
"paid_through": null,
"usage": {},
"status": "active",
"activations_count": 3
}Returns 400 {"error": "No updatable fields provided (expires_at, max_activations, limits, lease_seconds)"} if the body has none of the five fields (the message still lists four), 400 with the lease_seconds message above for a value out of range, 404 {"error": "License key not found"} if the key doesn't exist (or isn't owned by your tenant, on the hosted tier), and 400 {"error": "expires_at is required for self-license credentials"} for the one internal self-licensing credential that can't be made perpetual.
On a storefront subscription's license (1.13.0+), max_activations, limits and lease_seconds set here last until the subscription's next plan change (or seat change, on a mapping with activations_per_unit), or until its mapping is applied to existing subscribers, which replace them.
GET /v1/list-licenses
List all licenses with optional filtering.
Headers:
Authorization: Bearer your-api-keyQuery Parameters:
product_id(optional) - Filter by product IDstatus(optional) - Filter by status (activeorrevoked)issued_to(optional) - Case-insensitive substring match againstissued_to(e.g. searching by customer name/email)key(optional) - Substring match against the license keylivemode(optional, 1.20.0+) -truefor licenses from live storefront purchases,falsefor sandbox ones,unknownfor licenses with no recorded modesort(optional) -key,product_id,tier,status,issued_to,expires_atoractivations_count(default: newest issued first)order(optional) -ascordesc(defaultdesc)limit(optional) - Maximum number of results (1-1000, default 50)offset(optional) - Pagination offset (default 0)include_activations(optional, 1.12.0+) -trueadds each license'sactivationsarray (below). Defaultfalse
Response:
{
"licenses": [
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"tier": "pro",
"product_id": "my-product",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"issued_to": "customer@example.com",
"issued_at": "2025-01-01T00:00:00.000Z",
"expires_at": "2027-12-31T23:59:59.000Z",
"status": "active",
"limits": {},
"usage": {},
"max_activations": 5,
"lease_seconds": null,
"revoked_at": null,
"livemode": true,
"paid_through": "2027-12-24T23:59:59.000Z",
"activations_count": 2
}
],
"total": 1
}lease_seconds (1.14.0+) is the license's lease, or null when its seats are permanent. livemode and paid_through (1.20.0+) are as verify-license answers them. activations_count is computed server-side per row (a correlated subquery), so you don't need a separate GET /v1/list-activations/:key call per license just to show seat usage in a table.
With include_activations=true, each license also has an activations array: the same entries, in the same order (newest first), as GET /v1/list-activations/:key returns, and [] for a license with none. Fetching every license's activations this way takes one request per page of up to 1,000 licenses rather than one per license, all of which count against the admin rate limit. A server older than 1.12.0 ignores the parameter and leaves activations out.
GET /v1/admin/stats
Get admin statistics.
Headers:
Authorization: Bearer your-api-keyResponse:
{
"totalLicenses": 100,
"activeLicenses": 95,
"revokedLicenses": 5,
"totalActivations": 250,
"recentActivations": [
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"instance_id": "unique-instance-id",
"activated_at": "2025-01-01T00:00:00.000Z"
}
]
}GET /v1/recent-activations
Get recent activations.
Headers:
Authorization: Bearer your-api-keyQuery Parameters:
limit(optional) - Number of activations to return (default: 50)
Response:
[
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"instance_id": "unique-instance-id",
"activated_at": "2025-01-01T00:00:00.000Z"
}
]GET /v1/list-activations/:key
List all activations for one specific license key. Requires admin authentication — this returns per-device instance IDs and activation timestamps, which is sensitive enough that it isn't a public endpoint.
Parameters:
key(path) - The license key
Headers:
Authorization: Bearer your-api-keyResponse:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"activations": [
{
"instance_id": "unique-instance-id",
"activated_at": "2025-01-01T00:00:00.000Z",
"lease_expires_at": "2027-01-17T14:02:11.000Z",
"last_seen_at": "2026-10-19T14:02:11.000Z",
"lapsed": false
}
]
}Newest first. The lease fields are new in 1.14.0 (see Seats that free themselves):
lease_expires_at— when this machine's lease runs out (ISO 8601), ornullwhen its seat is permanent.last_seen_at— when its lease was last renewed (ISO 8601), ornulluntil the machine first renews under a lease. (Turning a lease off and on again keeps each machine's earlier value until it renews.) A check-in renews the lease only once less than half of it remains, so this is accurate to within half the lease.lapsed—trueoncelease_expires_athas passed. A lapsed machine still holds its seat, and keeps working, until a new activation needs the seat.
POST /v1/admin/reissue-token
Mint a fresh activation token for a specific key/instance_id pair, invalidating the previous one. Use this when a caller has lost the one-time token POST /v1/activate-license issued — there is no self-service recovery path; only an admin can reissue a lost token.
Headers:
Authorization: Bearer your-api-keyRequest Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"instance_id": "unique-instance-id"
}Response:
{
"reissued": true,
"token": "9f2c1e4a7b3d6f805c1a2b3d4e5f6789"
}On a license with a lease (1.14.0+), reissuing also renews the machine's lease, as a check-in would.
POST /v1/admin/reissue-portal-token
Mint (or, for a license issued before the end-user license portal below existed on this deployment, mint for the first time) that license's portal credential, invalidating any previous one. Use this for a customer's "I lost my portal link" request — there is no self-service recovery path, same reasoning as POST /v1/admin/reissue-token above. Unlike that route, this one needs no instance_id: the portal token is per-license, not per-activation, so it works even for a license with no activations yet.
Headers:
Authorization: Bearer your-api-keyRequest Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28"
}Response:
{
"reissued": true,
"portal_token": "a1d5163d1e1e4cd7e0e46f577dbeb6f2"
}POST /v1/admin/deactivate-by-instance-id
Free a single seat by key/instance_id, without needing that instance's own activation token. Recovery path for a slot occupied by an attacker-chosen instance_id (the license key alone isn't a secret, so anyone holding it can activate with any instance_id they choose) or an instance whose owner is unreachable and never captured its token — POST /v1/deactivate-license requires the opaque token issued at activation time, which only an admin can bypass, and only by identifying the exact instance_id to free (via GET /v1/list-activations/:key).
Headers:
Authorization: Bearer your-api-keyRequest Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"instance_id": "unique-instance-id"
}Response:
{ "deactivated": true }Returns 404 {"error": "License key not found"} for an unknown key, or 404 {"error": "No activation found for that key and instance_id"} if that instance_id was never activated on this key.
POST /v1/admin/reset-usage
Reset a license's tracked usage — the recovery path for POST /v1/track-usage, which has no way for a license to self-correct once a metric is maxed out. Provide metric to reset just that one metric to 0; omit it to clear every metric on the license at once.
Headers:
Authorization: Bearer your-api-keyRequest Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"metric": "api_calls_per_day"
}Response:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"usage": { "api_calls_per_day": 0 }
}Returns 404 {"error": "License key not found"} for an unknown key. On the rare 503 {"error": "..."} (a concurrent update to the same license lost a retry race), just retry the request — it's safe to resend.
GET /v1/admin/license/:key
Fetch a single license's full admin record — unlike GET /v1/export-license/:key (the public, signed export shape meant for offline verification), this returns the same operational fields as GET /v1/list-licenses's per-row shape, without activations_count and plus notes.
Parameters:
key(path) - The license key
Headers:
Authorization: Bearer your-api-keyResponse:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"tier": "pro",
"product_id": "my-product",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"issued_to": "customer@example.com",
"issued_at": "2025-01-01T00:00:00.000Z",
"expires_at": "2027-12-31T23:59:59.000Z",
"status": "active",
"limits": {},
"usage": {},
"max_activations": 5,
"lease_seconds": null,
"revoked_at": null,
"notes": "Renewed via phone call 2024-01-15",
"livemode": true,
"paid_through": "2027-12-24T23:59:59.000Z"
}livemode and paid_through (1.20.0+) are as verify-license answers them.
POST /v1/admin/update-notes
Update a license's freeform internal notes — editable independently of issuing the license. Not part of GET /v1/list-licenses's per-row payload (notes can be arbitrarily long and have no use in a list view); read them back via GET /v1/admin/license/:key.
Headers:
Authorization: Bearer your-api-keyRequest Body:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"notes": "Renewed via phone call 2024-01-15"
}An empty string is a valid value: it clears existing notes, which then read back as "notes": null. Only a missing or wrong-typed notes field is rejected.
Response:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"notes": "Renewed via phone call 2024-01-15"
}GET /v1/admin/build-info
Returns the raw build watermark for this deployment — admin-gated, unlike the opaque buildFingerprint on the public GET / health check, since the watermark itself may be a readable per-customer identifier. Unlike every other route in this section but POST /v1/admin/crash-test (below), this one always requires ADMIN_API_KEY specifically, on every deployment mode — a tenant's own API key is never accepted here, even on the hosted tier.
Headers:
Authorization: Bearer your-admin-api-keyResponse:
{
"watermarkId": "customer-acme-corp",
"fingerprint": "a1b2c3d4e5f6",
"version": "1.0.0"
}POST /v1/admin/crash-test
(1.15.0+) Throws a deliberate error, so you can check that crash reporting works: the server answers 500 and sends a report with a stack trace to the error service set in CRASH_REPORT_DSN. It changes nothing. Like GET /v1/admin/build-info, it always requires ADMIN_API_KEY specifically, on every deployment mode; a tenant's own API key gets a 403. No request body.
Headers:
Authorization: Bearer your-admin-api-keyResponse (500):
{
"error": "Internal Server Error"
}The report's message is always "Crash test: a deliberate error, requested with the admin key", so repeated tests group into one issue in your error service. In every build Casazium distributes, the stack's frames from the server's own code are on line 1 of one obfuscated file: index.js:1:<column> in the Docker image, the bundled file's name in a SEA binary. With CRASH_REPORT_DSN unset, the request still answers 500 and the error goes only to the server's log.
End-user license portal
A page your own customer can reach directly — no Casazium account, no password — to view their license, deactivate a machine themselves, and download their offline .lic file, without you having to build any of that yourself or route the request through your own backend.
This is a self-hosted feature, off by default (PORTAL_ENABLED — see your deployment's own setup docs) and not reachable at all until an operator turns it on. It's also not part of the versioned /v1 JSON API above: these are plain HTML pages meant for a person's browser, not a JSON response your own code parses.
GET /portal/:token— the portal page itself: license status (plan, product, issued-to, expiry, limits) and the list of activated machines, each with a Deactivate button. On a license with a lease (1.14.0+), each machine also shows the date it was last seen, and an inactive (lapsed) one is marked as giving its seat to the next machine that needs one. Dates are in UTC whatever the server's timezone, and each machine's activation time readsYYYY-MM-DD HH:MM UTC(1.15.0+).POST /portal/:token/deactivate— what that button submits (a plain HTML form,instance_idin the body). Blocked with a plain-language message if the license isn't active.GET /portal/:token/license-file— downloads the same signed, encrypted.licfileGET /v1/export-license/:key/fileproduces, resolved via the portal token instead of the license key. From 1.17.0, a product withoffline_fileset tofalse(Products) has no Download license file button, and this answers404("This product doesn't use a license file. Enter your license key in the app instead.").
From 1.17.0 the page names the product as buyers see it (Products): its title is "name license" and its heading the name, or "Your license" when there's no name buyers can be shown, and its Product row shows the same name or is left out. On hosted accounts the page ends "License portal provided by Casazium."
:token here is the portal_token POST /v1/issue-license returns once, at issuance — a separate credential from the license key itself, never distributed inside your app the way the key is. Give your customer a link in whatever purchase confirmation you already send them (Gumroad, Stripe, your own account page). The one exception is a storefront purchase: once email is configured, License Server emails that buyer their key and portal link. Otherwise Casazium doesn't email your customers. Lost their link? Reissue one with POST /v1/admin/reissue-portal-token — the same route also mints a first portal token for a license issued before you turned this feature on.
Billing (Hosted/SaaS Tier)
These two endpoints let a hosted tenant manage their own subscription without an admin key — see Hosted (SaaS) Tier for the plans and quotas they refer to. They're not useful on a self-hosted deployment (MULTI_TENANT=false): with no tenant identity on the request, GET /v1/billing/status reports the same default as an account with no billing history at all, and there's no billing to check out into.
GET /v1/billing/status
Check the calling tenant's current subscription plan and status.
Headers:
Authorization: Bearer your-tenant-api-keyResponse:
{
"status": "active",
"plan": "pro",
"interval": "monthly",
"cancelAtPeriodEnd": false,
"currentPeriodEnd": null,
"licensesUsed": 12,
"licenseLimit": 1000
}licensesUsed and licenseLimit are the same active-license count and plan limit that license issuance enforces against, so they can't drift from what actually blocks a new license. Both are null on a self-hosted server, which has no plans.
plan is null for a tenant with no billing history, which is treated as the Free plan (see Plans and quotas).
interval (1.18.1+) is the paid plan's billing period, "monthly" or "annual". It's null on Free, or when it isn't known yet.
cancelAtPeriodEnd is true once a paid subscription (Pro or Business) has been canceled but hasn't taken effect yet — status/plan stay exactly as they are until then. currentPeriodEnd (an ISO 8601 timestamp, or null when cancelAtPeriodEnd is false) is when that cancellation actually takes effect and the account reverts to Free. See Managing your subscription for why cancellation isn't instant.
POST /v1/billing/checkout
Start an upgrade to a paid plan, or manage/cancel an existing one. Returns a URL to send the tenant to — this call alone does not change the subscription; only a completed checkout (or, for a cancellation, the end of the current billing period) does.
Request Body:
{
"plan": "pro",
"interval": "monthly"
}plan is "free", "pro", or "business" — see Plans and quotas for what each one costs and allows. interval is "monthly" or "annual" (defaults to "monthly" if omitted) — it selects which of the chosen paid plan's two prices to check out at and has no effect on the plan's license limit. It's meaningless for plan: "free", since there's no price to check out into there.
A tenant that already holds a paid subscription never gets a second one (1.18.1+). Another plan, or the other interval on the same plan, returns a URL to Stripe's billing portal instead of a checkout page; it shows the prorated price and changes the existing subscription once the tenant confirms. plan: "free" returns a URL to the billing portal too — that's where cancellation happens. See Managing your subscription for why cancellation isn't instant.
Headers:
Authorization: Bearer your-tenant-api-keyResponse:
{
"url": "https://checkout.example.com/session/..."
}Account Management (Hosted/SaaS Tier)
Tenant self-service, same tenant-key auth as the Billing endpoints above — not useful on a self-hosted deployment (MULTI_TENANT=false), which has no per-tenant credential to rotate.
POST /v1/rotate-api-key
Mint a fresh API key for the calling tenant and retire the old one in the same operation — the old key stops authenticating immediately, not after a grace period. Use this if your key is ever exposed (committed to a repo, embedded in a client build, leaked in a log) — see Rotating your API key for the full picture, including why there's deliberately no overlap window between the old and new key.
Headers:
Authorization: Bearer your-current-tenant-api-keyResponse:
{
"apiKey": "9x2K7pQmR4vN8sT1wY3zB6cF5dH0jL2n"
}apiKey is the new plaintext key — the only time it's returned; only its hash is stored server-side afterward. Returns 400 {"error": "API key rotation is only available for hosted accounts"} on a self-hosted deployment.
Product IDs are per-tenant, not global
product_id is a human-chosen label, not a claim on a shared namespace. Two different tenants (or, if you're hosted, two different customers of Casazium) may freely use the identical product_id string — "myapp" under one tenant and "myapp" under another are two entirely separate products, each with their own licenses and releases. There's no "first tenant to use a string owns it" rule, and nothing to reclaim or release when a tenant stops using one.
The real, immutable identity behind a product is product_uuid — generated server-side the first time you issue a license or register a release under a given product_id, and reused for every subsequent one under that same string. verify-license, check-update, and the offline export endpoints all return it whenever the underlying license/release has one (every one issued from v1.3.0 onward does). If your integration checks it, check product_uuid against what your app expects, not just product_id — the string is a display label; only product_uuid actually proves "this belongs to my product," in a way a signature's authenticity check alone never could.
This replaced an earlier model where product_id strings were globally exclusive (the first tenant to claim a string kept it, even after deleting their account) with a permanent-retirement mechanism to protect that scarcity. Per-tenant scoping removes the scarcity itself, so there's nothing left to retire or recover.
Products
License Server 1.17.0+. Your products, and what buyers see of them in the license email and the buyer portal. A product is created when a license or release first names its product_id, or with POST /v1/admin/products. The console's Products page (Console 1.13.0+) uses these routes. Tenant self-service, with the tenant API key; on a single-tenant server they take ADMIN_API_KEY, for the server's own products.
GET /v1/admin/products
Headers:
Authorization: Bearer your-tenant-api-keyResponse:
[
{
"id": "5f0c...-uuid",
"product_id": "com.acme.pro",
"display_name": "Acme Pro",
"buyer_display": "Acme Pro",
"buyer_instructions": null,
"offline_file": true,
"text_approved": false,
"created_at": "2026-10-21T10:00:00.000Z",
"settings_updated_at": "2026-10-21T10:05:00.000Z",
"test_licenses": "allow"
}
]id is the product's product_uuid. buyer_display is the name buyers see: the email's subject is Your <name> license, and the portal's title and heading name it. It is display_name if that passes the display-name rules below, else product_id if that passes them, else null: the email's subject is then "Your license", and so is the portal's title. A product_id like com.acme.pro doesn't pass (.pro is a real top-level domain, so it reads as a web address), nor does one with _ or @: set a display name. text_approved is true when support has approved a name with a real domain in it (below). buyer_instructions (1.18.0+) is null for now. test_licenses (1.20.0+) is "allow" (the default) or "refuse": see Refusing sandbox licenses once you go live.
The display-name rules, checked when you save a display name and again whenever one is shown:
- 1-80 characters of letters, digits, spaces and
. , & ' - ( ) + : / ! # ® ™, with at most 2 accent marks on a letter; - no control or invisible characters (bidi overrides, zero-width);
- no look-alike characters (fullwidth or mathematical letters,
ℍ), no mixed alphabets (Pаypalwith a Cyrillicа), and no name written in Cyrillic or Greek letters that all look Latin. Japanese names mixing kanji and kana, and Korean names mixing Hangul and Hanja, pass; - no web or email address:
https://,www.,@, or a dotted word ending in a real top-level domain (acme.shop,Foo.app).Node.js,license.licandsetup.dmgpass (js,licanddmgaren't top-level domains), as do a few tech names such asASP.NETandSocket.io.
If your product's real name ends in a real domain, ask support to approve it. Self-hosted, you approve it yourself with ADMIN_API_KEY: PUT /v1/admin/tenants/self-hosted/products/{id}/text-approval with { "approved": true, "display_name": "Foo.app" } sets the name and skips only the domain rule; { "approved": false } withdraws it. Changing the display name afterwards withdraws an approval.
POST /v1/admin/products
Create a product ahead of its first sale.
Request Body:
{ "product_id": "com.acme.pro" }Idempotent: the same product_id (trimmed, any case) returns the existing product instead of a second one. Response: the product, as in the list.
Errors: 400 for a blank product_id or one with a reserved prefix (product_id uses a reserved prefix).
PATCH /v1/admin/products/:id
Change what buyers see, or (1.20.0+) whether its sandbox licenses work. :id is the product's id from the list.
Request Body (any of the fields below):
{ "display_name": "Acme Pro", "offline_file": false }display_name— a string that passes the rules above, ornullto go back to theproduct_id.offline_file—falsehides the portal's Download license file button and makes its download answer404, for an app that only takes the key. Your own exports are unaffected:POST /v1/license-export/fileand/offline(1.21.0+), or the deprecatedGET /v1/export-license/:key/fileand/offline, work as before.test_licenses(1.20.0+) —"refuse"makes this product's licenses from sandbox purchases answer as not active;"allow", the default, lets them work. See below.
Response: the product, as in the list.
Refusing sandbox licenses once you go live
From 1.20.0, each license from a storefront purchase records whether the purchase was live or a sandbox (test-mode) one: livemode, from the storefront's signed event (Stripe's and Lemon Squeezy's test mode, Paddle's environment). Licenses issued by hand or through the API have none (null), as do most licenses issued before 1.20.0.
Licenses from sandbox purchases keep working after you go live. If you tested on the product you sell, refuse them once you go live:
curl -X PATCH https://your-license-api.com/v1/admin/products/<id> \
-H "Authorization: Bearer your-tenant-api-key" \
-H "Content-Type: application/json" \
-d '{ "test_licenses": "refuse" }'Every sandbox license of that product then answers as not active wherever a buyer uses it (verify, activate, validate, check-update, usage, the exports, verify-license-file* and the buyer portal), with the answer each gives a revoked license. Where the answer has room for it, "reason": "test_license" is added. Licenses with no recorded mode are never affected. "test_licenses": "allow" restores them, or revoke sandbox licenses one by one instead.
Offline license files exported before the switch keep verifying offline until their own expiry. An app can refuse those itself: the file signs livemode, so reject livemode: false. To find sandbox licenses, use GET /v1/list-licenses?livemode=false.
In console 1.15.0 the switch is Accept licenses from sandbox purchases in a product's Edit, and Licenses shows livemode as a sandbox badge, a Mode filter and refused (sandbox).
Errors:
400— the rule a display name broke:display_name must be 1-80 charactersdisplay_name can't include control or invisible charactersdisplay_name may use letters, numbers, spaces and . , & ' - ( ) + : / ! # ® ™ onlydisplay_name can't stack accent marks - at most 2 on a letterdisplay_name mixes alphabets or uses look-alike charactersdisplay_name can't include a web address or email addressdisplay_name can't include a web address - a name ending in a real domain (like Foo.app) needs support to approve it
or
display_name must be a string or null,offline_file must be true or false,test_licenses must be 'allow' or 'refuse'(1.20.0+), orNothing to change - send display_name and/or offline_file.400(1.18.0+) —buyer_instructions isn't enabled on this server - leave it out, for a request with text inbuyer_instructions.404—Product not found(another tenant's included).
Software Distribution
License-gated software releases and signed update-check manifests — see Software Distribution for the full picture, including the scope boundary these endpoints are built around: License Server never stores or proxies your software's actual bytes, only metadata and a signed pointer to wherever you already host it. Every endpoint below except POST /check-update is tenant self-service, same tenant-key auth as Billing/Account Management above; POST /check-update is different — it's called by your own end-user apps, gated by one of their license keys, not your tenant API key.
POST /v1/register-release
Register a new release under a product_id scoped to your own tenant — resolved, or created on first use, the same per-tenant behavior as POST /issue-license. Two different tenants may freely register releases under the identical product_id string; see Product IDs are per-tenant, not global below.
Headers:
Authorization: Bearer your-tenant-api-keyRequest Body:
{
"product_id": "widget-pro",
"version": "2.3.0",
"channel": "stable",
"platform": "darwin-arm64",
"artifact_url": "https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg",
"checksum": "sha256:...",
"release_notes": "Fixes a rare crash on startup."
}channel defaults to "stable" if omitted. artifact_url must already be reachable by your own end users — this call never uploads or moves a file, and must be http:// or https:// (anything else, including a non-URL string, is rejected). release_notes is capped at 10,000 characters; product_id/version/channel/platform/artifact_url/checksum are each capped too (200/100/100/100/2048/256 characters respectively) — generous for any real value, closing off unbounded input on a field that gets RSA-signed and returned on every check-update call.
Response:
{
"id": 42,
"status": "published",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8"
}product_uuid is this product's real, immutable identity - the natural place to capture it once and hard-code it in your app, also shown on the console's release detail page afterward if you need it again.
Errors:
400 {"error": "product_id uses a reserved prefix"}—product_idstarts with_casazium_.400 {"error": "artifact_url must be a valid http(s) URL"}—artifact_urlisn'thttp:///https://.403 {"error": "Subscription is not active"}— your subscription payment has failed (hosted tier only).403 {"error": "Release limit reached (500 published releases) for this product_id/channel/platform - unpublish old releases before registering new ones"}— this product's channel/platform bucket is at its cap; unpublish an old release first.409 {"error": "A release already exists for this product_id/version/channel/platform"}— a published release already exists for this exact combination, under your own tenant. Unpublish it, then register again — that's also how you get an accidentally-unpublished version back; there's no separate "republish" call.
GET /v1/list-releases
List your own registered releases, optionally filtered by product_id, channel, platform, and/or status (published or unpublished), paginated the same way as GET /v1/list-licenses.
Headers:
Authorization: Bearer your-tenant-api-keyResponse:
{
"releases": [
{
"id": 42,
"product_id": "widget-pro",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"version": "2.3.0",
"channel": "stable",
"platform": "darwin-arm64",
"artifact_url": "https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg",
"checksum": "sha256:...",
"release_notes": "Fixes a rare crash on startup.",
"status": "published",
"created_at": "2026-08-19T00:00:00.000Z"
}
],
"total": 1
}GET /v1/release/
Fetch a single release you own, including signature — the one field GET /list-releases doesn't return per row.
Headers:
Authorization: Bearer your-tenant-api-keyResponse:
{
"id": 42,
"product_id": "widget-pro",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"version": "2.3.0",
"channel": "stable",
"platform": "darwin-arm64",
"artifact_url": "https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg",
"checksum": "sha256:...",
"release_notes": "Fixes a rare crash on startup.",
"status": "published",
"signature": "base64-RSA-SHA256-signature...",
"created_at": "2026-08-19T00:00:00.000Z"
}Returns 404 {"error": "Release not found"} for a nonexistent id or one belonging to a different tenant — the same information-hiding behavior as a cross-tenant POST /unpublish-release.
POST /v1/unpublish-release
Mark a release unpublished — a state flip, not a delete, so the row and its history survive. Every one of your end users on that release's channel/platform immediately stops seeing it in POST /check-update results. There's no dedicated "republish" call, but getting the version back is straightforward: call POST /register-release again with the exact same {product_id, version, channel, platform} — the uniqueness constraint that normally rejects a duplicate only applies to published releases, so re-registering an unpublished one always succeeds.
Headers:
Authorization: Bearer your-tenant-api-keyRequest Body:
{
"id": 42
}Response:
{
"id": 42,
"status": "unpublished"
}A release id belonging to a different tenant returns 404 {"error": "Release not found"} — the same information-hiding behavior as POST /revoke-license for a cross-tenant key.
POST /v1/check-update
Called by your own end-user apps, not by you directly — license-gated "is there a newer release" check that doubles as the download manifest, since there are no separate bytes for License Server to hand back: the response already is the complete, verifiable answer. Same base license validation as POST /verify-license (a valid, active, non-expired key) — no activation-instance requirement, so an app can check for updates before its first activation.
Request Body:
{
"key": "your-customers-license-key",
"channel": "stable",
"platform": "darwin-arm64",
"current_version": "2.2.9"
}channel defaults to "stable" if omitted. The releases this call can see are scoped to the license's own product_uuid, read from the license itself — a license for one product can never be used to probe a different product's release channel, regardless of what product_id string either product uses. product_id in the request body is optional: pass it as a defense-in-depth convenience (checked against either the license's frozen product_id or the product's current display name, so a rename never breaks this call) if you like, but it plays no role in which releases are actually returned.
product_id matching, when supplied, is case/whitespace-insensitive. channel and platform are exact, case-sensitive matches against the values used at registration — "Stable" and "stable" are different channels here. A channel or platform mismatch fails closed (update_available: false), not an error — if updates never seem to arrive, double-check these values match registration exactly. A product_id that isn't the license's is an error: 403 {"error": "License does not match product_id"}.
Response:
{
"update_available": true,
"release": {
"product_id": "widget-pro",
"version": "2.3.0",
"channel": "stable",
"platform": "darwin-arm64",
"artifact_url": "https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg",
"checksum": "sha256:...",
"release_notes": "Fixes a rare crash on startup.",
"signature": "base64-RSA-SHA256-signature...",
"issued_at": "2026-08-19T00:00:00.000Z",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8"
}
}product_uuid is present on every release registered from v1.3.0 onward — check it against your app's own expected value, not just product_id, the same way verify-license's product_uuid works.
release is null and update_available is false when nothing published matches, or when current_version is already current. "Latest" is the highest semver version among your published releases for that product's channel/platform — not registration order, so backporting a hotfix to an older line (e.g. publishing 1.9.1 after 2.0.0 is already out) never shadows the genuinely newer release. A release whose version doesn't parse as semver never wins over one that does; among releases that tie on version, or where none parse, the most recently registered one is used.
signature is an RSA/SHA256 signature (base64) over this exact response object minus signature itself, computed once when the release was registered — verify it against GET /v1/public-key the same way an offline-exported license file is verified, before your app trusts artifact_url or checksum.
Field order matters when verifying
The signature is computed over the plain JSON.stringify() of the manifest — there's no canonical/sorted-key serialization. To verify, your client must reconstruct the exact object in the field order shown in the response above (product_id, version, channel, platform, artifact_url, checksum, release_notes, issued_at, product_uuid, with signature itself excluded, and product_uuid omitted entirely — not null — on the rare release that has none) and stringify that, not a re-serialization that reorders keys. This matters most outside JavaScript: Python's json.dumps(manifest, sort_keys=True) or Go's encoding/json on a map (which sorts keys) will produce a signature mismatch even for an untampered response, with no error explaining why. See Auto-Update Checking for a worked example.
Don't verify a manifest with the SDK's verifySignedFile(): from 0.3.0 it accepts only licenses (type: "license"), so it refuses every manifest — on purpose, so that a manifest can't pass as a license.
issued_at is part of what's signed, so it can't be forged separately from the rest of the manifest — but the signature alone verifies forever, even after a release is unpublished. If your app caches a check-update response instead of always calling it fresh, check issued_at yourself and reject anything older than you're willing to trust; otherwise a captured response for a since-unpublished (e.g. known-bad) release replays successfully indefinitely. The server has no way to force this check — it's the one piece of this API's security model that's the integrator's own responsibility, not something check-update itself can enforce.
Storefront Auto-Fulfillment
Connect a storefront — Stripe Payment Links, Lemon Squeezy or (License Server 1.11.0+) Paddle Billing — so a completed purchase issues a license and emails the buyer their key, (License Server 1.8.0+) a full refund revokes it, (1.9.0+) a subscription keeps its license's expiry in step with what's been paid, (1.19.0+) including when one of its payments is refunded, and (1.13.0+) its tier, limits and seats in step with its plan, with no integration code on the storefront side. See Storefront auto-fulfillment for the setup walkthrough and the decisions each mapping encodes.
The console (Settings → Storefront webhooks) drives all of this; these endpoints exist for automation and for troubleshooting a delivery. The fourteen admin/storefront-webhooks routes (thirteen before 1.17.0, twelve before 1.16.0, eleven before 1.13.0) are tenant self-service, same tenant-key auth as Billing above. POST /webhooks/storefront/{id} is different: your storefront (Stripe, Lemon Squeezy or Paddle) calls it, authenticated by signature rather than by a bearer token.
For the confirmation email to actually send, a self-hosted server needs EMAIL_PROVIDER=resend, RESEND_API_KEY, EMAIL_FROM and PUBLIC_BASE_URL set — otherwise the email is only logged, and from 1.15.1 the purchase is recorded as logged rather than sent — and PORTAL_ENABLED=true, without which the server refuses to start once a real email provider is set (every email links to the end-user portal). Hosted accounts already have this.
POST /v1/admin/storefront-webhooks
Connect a new storefront webhook.
Headers:
Authorization: Bearer your-tenant-api-keyRequest Body:
{ "provider": "stripe" }provider is stripe, lemonsqueezy (License Server 1.7.0+) or paddle (1.11.0+). A paddle webhook also needs environment:
{ "provider": "paddle", "environment": "sandbox" }environment is sandbox or live. Paddle's events don't say which account sent them, so the webhook says it: chosen here, never changed. To go live after testing, disable the sandbox webhook and connect a live one. The other providers refuse environment.
Response:
{
"id": "8cd4406a-5f3e-47e9-a2ac-a74ac4f0f2b7",
"provider": "stripe",
"status": "pending",
"environment": null,
"refund_policy": "revoke",
"subscription_grace_days": 7,
"buyer_delivery": "email",
"created_at": "2026-09-23T11:37:05.000Z"
}refund_policy (1.8.0+) is what a full refund does, subscription_grace_days (1.9.0+) how long a subscription's license stays valid past its paid period, and buyer_delivery (1.17.0+) whether License Server emails the buyer their key — see PATCH /v1/admin/storefront-webhooks/:id.
The webhook starts pending and ignores purchases until a signing secret is saved. Its callback URL is:
{your-server-root}/v1/webhooks/storefront/{id}{your-server-root} is the server's address without /v1, for example https://license-cloud-api.casazium.com on a hosted account. The console shows the whole callback URL, ready to copy.
- Stripe: under Developers → Webhooks, add a destination (an endpoint). Stripe asks for the events first, listed alphabetically:
charge.refundedfor refunds (1.8.0+),checkout.session.async_payment_failed,checkout.session.async_payment_succeededandcheckout.session.completed, and for subscriptions (1.9.0+)customer.subscription.created,customer.subscription.deleted,customer.subscription.updatedandinvoice.paid, and (1.19.0+, on an endpoint at API version2025-05-28.basilor later)invoice_payment.paid, for refunds of subscription payments. If Stripe asks what kind of destination, choose Webhook endpoint; then give it this URL. Test mode and live mode are separate in Stripe, each with its own endpoints and signing secrets. An endpoint from2025-03-31.basilto before2025-05-28.basilcan't match refunds of subscription payments, and Stripe can't change an endpoint's API version: replace it with a new endpoint (see Moving a Stripe endpoint to a newer API version). - Lemon Squeezy: add it under Settings → Webhooks, with the
order_createdevent,order_refundedfor refunds (1.8.0+), for subscriptions (1.9.0+)subscription_created,subscription_updated,subscription_cancelled,subscription_resumed,subscription_expired,subscription_paused,subscription_unpaused,subscription_payment_successandsubscription_payment_recovered— without the two payment events a subscription never extends — (1.19.0+)subscription_payment_refundedfor refunds of subscription payments, and a signing secret you choose (see below). - Paddle (1.11.0+): add it under Developer tools → Notifications → New destination, with
transaction.completed,adjustment.createdandadjustment.updated; for subscriptionssubscription.created,subscription.activated,subscription.updated,subscription.trialing,subscription.past_due,subscription.paused,subscription.resumedandsubscription.canceled; and optionallyapi_key.expiring,api_key.expiredandapi_key.revoked. Paddle's events carry no buyer email, so the webhook also needs an API key (below).
id is not a secret — it is the routing segment of that URL, the same way a Stripe webhook endpoint URL is not a secret, and every route taking it verifies server-side that it belongs to the calling tenant.
Errors: 409 if an active webhook for the same provider already exists for this tenant — at most one per provider, so Stripe, Lemon Squeezy and Paddle can run side by side; disable the existing one first. 400 for an unknown provider (and for lemonsqueezy on a server older than 1.7.0, or paddle before 1.11.0), or for a missing or unexpected environment.
POST /v1/admin/storefront-webhooks/:id/secret
Save the webhook's signing secret. This flips a Stripe or Lemon Squeezy webhook to active; a Paddle webhook stays pending until its API key is saved too.
- Stripe generates the secret (
whsec_...) and shows it when you add the endpoint. - Lemon Squeezy has you choose it: enter the same value in Lemon Squeezy and here. It must be 16–40 characters; for example,
openssl rand -hex 16gives 32. - Paddle generates it: the notification destination's secret key, starting
pdl_ntfset_(64–128 characters).
Saving it again on an active webhook rotates it.
Request Body:
{ "secret": "whsec_..." }Response:
{ "updated": true, "status": "active" }status (1.11.0+) is the webhook's status now.
Errors: 400 if a Lemon Squeezy secret is shorter than 16 or longer than 40 characters, or a Paddle secret doesn't start pdl_ntfset_ or isn't 64–128 characters.
Errors: 404 if the id does not exist or belongs to another tenant.
Errors: 409 if the webhook is disabled (License Server 1.8.0+). A disabled webhook can't be re-activated; connect a new one instead.
POST /v1/admin/storefront-webhooks/:id/api-key
License Server 1.11.0+, Paddle only. Save the API key used to look up each buyer's email: Paddle's events name the buyer only by customer ID.
Create it in Paddle under Developer tools → Authentication, with only the Customers: read permission and a custom expiry a year out. Paddle can't confirm a key has only that permission (a key with more passes too), so that part is up to you.
Request Body:
{ "api_key": "pdl_sdbx_apikey_..." }Response:
{ "updated": true, "status": "active" }The key must be in Paddle's current format and for the webhook's environment: pdl_sdbx_apikey_… for sandbox, pdl_live_apikey_… for live. The server checks it with Paddle before saving, stores it encrypted and never returns it. Saving a new key clears any api_key_problem (below), and is how to replace a key that expires or is revoked.
Errors: 400 if the format or environment is wrong, Paddle refuses the key, or the webhook isn't a Paddle one; 502 if Paddle can't be reached; 429 if Paddle is rate limiting the server, or this account has made too many Paddle calls in the last minute; 404 if the id does not exist or belongs to another tenant; 409 if the webhook is disabled.
GET /v1/admin/storefront-webhooks
List your webhooks.
Response:
[
{
"id": "8cd4406a-...",
"provider": "stripe",
"status": "active",
"environment": null,
"has_api_key": false,
"api_key_problem": null,
"refund_policy": "revoke",
"subscription_grace_days": 7,
"buyer_delivery": "email",
"created_at": "2026-09-23T10:02:44.000Z",
"last_event_at": "2026-09-23T01:40:01.000Z",
"api_version": "2025-09-30.clover",
"refund_matching": "ok"
}
]From 1.19.0, for a Stripe webhook: api_version, the Stripe API version of the latest event the endpoint sent, and refund_matching — whether refunds of subscription payments can be matched to their payments (Refunds of subscription payments):
| Value | Meaning |
|---|---|
ok | No problem found. On an endpoint at 2025-05-28.basil or later, a missing invoice_payment.paid shows only once a paid subscription invoice is 10 minutes old (1.19.1+). After the upgrade to 1.19.1, every webhook shows ok until its next paid subscription invoice. If you saw needs_event under 1.19.0, or sell subscriptions, check invoice_payment.paid is on the storefront endpoint now. |
needs_api_upgrade | The endpoint is at 2025-03-31.basil or later, but before 2025-05-28.basil, whose events don't link a refund to its invoice. Stripe can't change an endpoint's API version: replace it with a new endpoint (how). From 1.21.2, the move no longer leaves a false needs_event until the next payment |
needs_event | The endpoint is at 2025-05-28.basil or later, this webhook's latest paid subscription invoice arrived more than 10 minutes ago, and its invoice_payment.paid hasn't: add that event to the endpoint, then resend that payment's invoice_payment.paid with the Stripe CLI (how) so a refund from the last 7 days that was waiting applies. For an older one, resend its latest charge.refunded too. If either event is more than 30 days old, Stripe can't resend it: change that license by hand. Only invoices charged automatically for more than $0 count (a free trial, a 100% coupon, or an invoice Stripe emails to the customer to pay instead doesn't count). Before 1.19.1 it showed a day after a subscription invoice, and looked at every Stripe webhook you'd connected, test and live |
null | No event has arrived since 1.19.0 |
Both are null for the other providers.
From 1.11.0, for a Paddle webhook: environment (sandbox or live), has_api_key (the key itself is never returned), and api_key_problem — why buyer emails aren't being looked up, or null when all is well:
| Value | Meaning |
|---|---|
no_api_key, key_rejected, key_unreadable | No usable key: save one |
key_expired, key_revoked | Paddle reported the key expired or revoked: save a new one |
key_expiring | A warning: the key still works, but Paddle says it expires soon |
customer_not_found | Paddle didn't know a buyer, usually because the webhook was set up in the wrong environment |
Until a problem other than key_expiring is fixed, purchases still issue their licenses but their emails wait for send-pending. These fields are null (and false) for the other providers.
PATCH /v1/admin/storefront-webhooks/:id
License Server 1.8.0+. Change a webhook's settings: send any of them.
Request Body:
{ "refund_policy": "record", "subscription_grace_days": 14 }Response (the webhook's settings as they now stand):
{ "id": "8cd4406a-...", "refund_policy": "record", "subscription_grace_days": 14, "buyer_delivery": "email", "pending_moved_to_vendor": 0 }buyer_delivery and pending_moved_to_vendor are in the response from 1.17.0.
refund_policy (1.8.0+) — what a full refund of a purchase through this webhook does:
revoke— the default for every webhook, including ones connected before 1.8.0 — revokes the license the purchase issued.recordkeeps the license; the refund is only recorded in the deliveries list.
Partial refunds are always recorded only. Either way, a purchase refunded before its license is issued never gets one, and a refunded purchase is never emailed its key. Revocation also needs the refund event enabled in the storefront (Stripe charge.refunded, Lemon Squeezy order_refunded, Paddle adjustment.created and .updated).
From 1.19.0, the policy covers refunds of subscription payments too. Under revoke, a full refund of the subscription's only paid payment that hasn't been refunded revokes its license; of the latest payment, with paid ones before it that haven't been refunded, rolls the license back to the end of what's still paid for, with no grace (a license set to never expire by hand isn't changed); and of an older payment, while a later payment hasn't been refunded, is recorded only. A subscription that started before 1.19.0 is never revoked by a refund. Refunds of subscription payments also need, on Stripe, invoice_payment.paid (on an endpoint at API version 2025-05-28.basil or later) and, on Lemon Squeezy, subscription_payment_refunded; see Refunds of subscription payments.
subscription_grace_days (1.9.0+) — how long a subscription's license stays valid past the period it's paid for: a whole number from 0 to 30, default 7. It covers the time before a renewal's payment event arrives, and how long a customer keeps access while the storefront retries a failed payment. It applies from each subscription's next event; expiries already set aren't recomputed. See Subscriptions.
buyer_delivery (1.17.0+) — who sends the buyer their license:
email, the default: License Server emails the buyer their key.vendor— deliver it yourself: licenses are still issued, but no email is sent, and you send each key from your own system (Delivering the key yourself). Its purchases get thedelivery_statusvendor.
Switching to vendor also leaves to you every purchase of this webhook not yet emailed — still processing, awaiting payment, or issued but not sent (one being sent at that moment can't be stopped, but isn't retried) — except a fully refunded one; pending_moved_to_vendor says how many. Switching back to email moves nothing back; a resend emails any one of them. Each purchase keeps the setting it arrived under. Use License Console 1.13.0 or later with vendor: older consoles show these purchases as needing attention.
Fields outside the schema are stripped, never applied. On 1.8.0, refund_policy is required and subscription_grace_days is ignored.
Errors: 400 for an unknown policy, a grace outside 0–30 or that isn't a JSON number (null, true and "7" are refused, not converted), a buyer_delivery other than email or vendor (buyer_delivery must be one of: email, vendor), or a body with no setting (from 1.17.0, Nothing to change - send refund_policy, subscription_grace_days and/or buyer_delivery); 404 if the id does not exist or belongs to another tenant; 409 if the webhook is disabled.
DELETE /v1/admin/storefront-webhooks/:id
Soft-disable a webhook. Purchase history and product mappings survive; purchases stop auto-issuing immediately. Disabling can't be undone: to replace the webhook, connect a new one and add the same mappings to it — from 1.10.0 a disabled webhook's mapping gives way to the new one's. From 1.11.0 its signing secret, and a Paddle webhook's API key, are erased. A Paddle webhook's undelivered purchases stay reachable through the replacement's send-pending.
Response:
{ "disabled": true }POST /v1/admin/storefront-webhooks/:id/mappings
Map one storefront reference to what a purchase against it should issue.
Request Body:
{
"ref_kind": "payment_link",
"external_ref": "plink_1UIaMlQu14RJmyaQ1x5Tw53t",
"product_id": "your-product",
"tier": "pro",
"limits": { "api_calls_per_day": 10000 },
"max_activations": 3,
"lease_seconds": 7776000,
"duration_days": 365,
"notes": "Optional internal note"
}Response:
{ "id": "..." }ref_kind depends on the webhook's provider:
| Provider | ref_kind | external_ref |
|---|---|---|
stripe | payment_link | a Stripe Payment Link ID (plink_...) |
lemonsqueezy | variant | a Lemon Squeezy variant ID (digits only) |
paddle | price | a Paddle price ID (pri_...) |
paddle | product | a Paddle product ID (pro_...), covering all its prices |
stripe | metadata | a casazium_ref value in the Checkout Session metadata (set on the Payment Link) |
paddle | metadata | a casazium_ref value in the transaction's custom data |
stripe | price (1.13.0+) | a Stripe price ID — matches a subscription's plan, never a purchase |
stripe | product (1.13.0+) | a Stripe product ID — matches a subscription's plan, never a purchase |
When a Stripe purchase carries both, the metadata mapping is tried first. Lemon Squeezy purchases match by variant only: buyers can edit a checkout's custom data, so it never picks a mapping. Paddle purchases try the price, then the product, then metadata — which counts only on a transaction created through Paddle's API, since Paddle.js lets a buyer set custom data in their browser, and hosted checkout links can't carry it. limits takes the same shape and validation as POST /issue-license. max_activations and duration_days are optional: without max_activations the license works on one machine (the same default as POST /issue-license), and without duration_days it's perpetual.
A Stripe payment_link reference must be the link's ID (1.16.0+): plink_ followed by 1–200 letters and digits. A checkout carries the ID, never the https://buy.stripe.com/... address you share with buyers, so a mapping on the address would never match. Find the ID in the Stripe Dashboard: open the Payment Link and copy the part after /payment-links/ in the address bar (Finding a Stripe Payment Link's ID). Before 1.16.0 the address was accepted and every purchase through the link was recorded unmapped; mappings created then are left as they are.
activations_per_unit (1.13.0+, integer 1–10,000, optional): for seat-based subscriptions, the license allows this many activations per unit of the subscription's quantity, and follows it as it changes (Plan changes and seats). A Lemon Squeezy or Paddle purchase is issued with it times the quantity bought; a Stripe purchase gets its seats when its subscription's event arrives. Only on a price, product or variant mapping, and not together with max_activations.
Stripe price and product mappings (1.13.0+) match the plan a subscription's events report. A Stripe checkout names only its Payment Link, so a purchase is still issued by its payment_link or metadata mapping; for a subscription whose plan can change, map both. When the subscription's event arrives, seconds later, the price mapping's terms replace the issuing mapping's — give both the same product_id.
lease_seconds (1.14.0+, optional): the lease the licenses this mapping issues get, with the same values as on POST /v1/issue-license. A plan change or Apply sets the license's lease to the mapping's, so a mapping without one turns off a lease set on the license by hand. See Seats that free themselves.
Mappings are validated at creation, not at purchase time, so a malformed mapping fails while you are configuring it rather than silently failing every future purchase until a buyer complains.
duration_days defaults to perpetual
Omitting duration_days (or setting it null) issues licenses with no expiration at all, for every purchase through this mapping. There is no separate step that adds an expiry later. If the product is a time-limited one-time purchase, set it before the first purchase. A subscription's license (1.9.0+) doesn't use it: its expiry follows the subscription. (On Lemon Squeezy, duration_days applies only until the subscription's first payment arrives.)
There is also no update endpoint: to change a mapping, delete it and recreate it. That affects future purchases only. To fix a license already issued without an expiry, use POST /admin/update-license-terms against that key.
Errors: 409 if a mapping for this exact {ref_kind, external_ref} already exists on a webhook that isn't disabled, or if this webhook is disabled; 400 if limits fails validation, if ref_kind isn't one the webhook's provider uses, if a variant reference isn't numeric, or if a Paddle price isn't a price ID (pri_…) or a product isn't a product ID (pro_…); (1.16.0+) if a Stripe payment_link reference isn't a plink_ ID — a buy.stripe.com or other http(s) address gets That's the Payment Link's buy.stripe.com address. Use its ID instead (plink_...): open the link in Stripe and copy the part after /payment-links/ in the address bar., anything else A Payment Link ID starts with plink_; (1.13.0+) if both max_activations and activations_per_unit are set, or activations_per_unit is set on a payment_link or metadata mapping; (1.14.0+) if lease_seconds is out of range.
GET /v1/admin/storefront-webhooks/:id/mappings
List the mappings on a webhook — the fields above, plus id, webhook_id, tenant_id, provider and created_at. limits comes back as limits_json, the JSON text as stored. From 1.13.0 every row carries activations_per_unit (null when not set), and from 1.14.0 lease_seconds (null when not set).
DELETE /v1/admin/storefront-webhooks/:id/mappings/:mapping_id
Response:
{ "deleted": true }Errors: 404 if the mapping belongs to a different webhook — even one you own.
POST /v1/admin/storefront-webhooks/:id/mappings/:mapping_id/apply
License Server 1.13.0+. Give a mapping's terms to the subscriptions already on its plan. Adding or editing a mapping never changes licenses already issued on its own; this does, for the subscriptions on this webhook whose current plan is the mapping's price, product or variant. It sets each license's tier, limits, max_activations (with activations_per_unit, that many per unit of the subscription's quantity) and, from 1.14.0, lease_seconds — replacing terms set on those licenses by hand.
Request Body:
{ "dry_run": true }dry_run defaults to true: the response says what would change and nothing is written. Send false to apply; the selection is worked out again, so an apply never writes an out-of-date preview. It must be a JSON boolean (400 otherwise).
Response:
{
"dry_run": true,
"matched": 12,
"changed": 9,
"unchanged": 2,
"skipped": { "closed": 1, "license_revoked": 0, "license_missing": 0, "product_mismatch": 0 },
"next": null,
"note": "Applying writes these terms over each changed license, replacing any terms set on it by hand.",
"licenses": [
{
"license_key": "...",
"subscription_id": "sub_...",
"activations_count": 3,
"current": { "tier": "basic", "max_activations": 1, "limits": {}, "lease_seconds": null },
"new": { "tier": "team", "max_activations": 5, "limits": {}, "lease_seconds": 7776000 }
}
]
}licenses lists up to 200 of the licenses that change. Skipped: a subscription that has ended (closed), a revoked license, a license that no longer exists, or a mapping whose product_id differs from the license's. Up to 10,000 subscriptions are handled per call; when next isn't null, call again with { "dry_run": <same>, "after": "<next>" }. A subscription whose plan matched no mapping is found by its price or variant, so a new product mapping reaches it only after its next subscription event.
Errors: 404 if the mapping belongs to a different webhook; 409 if the webhook is disabled.
GET /v1/admin/storefront-webhooks/:id/deliveries
One row per purchase the storefront has sent this webhook, newest first. checkout_session_id is the Stripe Checkout Session ID, ls_order_ followed by the Lemon Squeezy order's identifier, or paddle_txn_ followed by the Paddle transaction ID. This is the only visibility into whether a purchase actually worked.
Query: limit (default 50, max 200), offset.
Response:
[
{
"tenant_id": "...",
"checkout_session_id": "cs_test_...",
"outcome": "issued",
"attempts": 1,
"license_key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"delivery_status": "sent",
"buyer_delivery": "email",
"delivery_held": null,
"processed_at": "2026-09-23T01:40:01.000Z",
"refund_kind": "full",
"refunded_at": "2026-09-30T10:12:44.000Z",
"refund_action": "revoked",
"refunded_amount": 4900,
"amount_total": 4900
}
]outcome is one of:
| Value | Meaning |
|---|---|
processing | Claimed, not yet resolved |
awaiting_payment | A delayed payment method completed checkout before settling — not terminal, a later async-payment event can still resolve it |
issued | A license was issued |
unmapped | No mapping matched the reference |
over_quota | The tenant's active-license quota would have been exceeded |
invalid_input | The mapping or payload failed validation |
error | Attempts exhausted, or an unexpected exception |
refunded_before_issue | (1.8.0+) Fully refunded before a license could be issued, so none was |
Every value except awaiting_payment and processing is terminal for re-issuance.
delivery_status tracks the confirmation email separately: a license can be issued with delivery_status still pending or sending. The storefront's own retry for the same purchase redelivers the email and never re-issues the key — except for a fully refunded purchase, which is never emailed. Paddle never redelivers a purchase it was answered with a 200, so one issued while the buyer's email couldn't be looked up (a dead API key, the wrong environment) waits for send-pending. From 1.16.0, an issued purchase's email can also be sent again with resend.
delivery_status is one of:
| Value | Meaning |
|---|---|
pending | Not emailed yet |
sending | Being emailed now |
sent | Emailed |
logged | (1.15.1+) The server has no email provider (EMAIL_PROVIDER unset or stub), so the email was only written to its log and the buyer wasn't emailed |
vendor | (1.17.0+) Left to you to deliver: the webhook was set to deliver it yourself (buyer_delivery vendor), so nothing was emailed and nothing will be. Finished, like sent |
A logged purchase is kept until it's sent (unless fully refunded). Once a real provider is set, the storefront's redelivery (for Paddle, send-pending) emails it, and so does a resend (1.16.0+). Before 1.15.1, such a purchase was recorded as sent, and it stays that way after upgrading; the server's log line Storefront license email (EMAIL_PROVIDER=stub, not actually sent) finds it.
Delivery fields (1.17.0+):
| Field | Meaning |
|---|---|
buyer_delivery | The webhook's buyer_delivery when the purchase arrived: email or vendor (email for purchases from before 1.17.0). An automatic send needs both this and the webhook's current setting to be email |
delivery_held | Why the license email wasn't sent: daily_cap (the account's 200 license emails a day) or suspended (the account's buyer emails are paused — contact support); null otherwise, and cleared once the email goes out |
A held purchase stays pending. Stripe and Lemon Squeezy don't redeliver a purchase once answered with a 200, so a held one there needs a resend once the limit allows; on Paddle, send-pending sends it too.
Refund fields (1.8.0+), null until a refund arrives:
| Field | Meaning |
|---|---|
refund_kind | partial or full. A full refund is final. |
refunded_at | When the refund happened |
refund_action | revoked; (1.19.0+) rolled_back (a subscription's license now ends when its remaining paid time does); recorded (a partial refund, the webhook's policy is record, or an older subscription payment while a later payment hasn't been refunded); no_license (nothing was issued); already_revoked; or revoke_failed — revoke that license by hand |
refunded_amount, amount_total | In the provider's smallest currency unit, with no currency attached. amount_total is what the purchase was charged: from 1.20.0 it's stored when the purchase arrives (on Paddle from 1.8.0), otherwise it's null until a refund arrives |
These describe a refund of the purchase itself. For a subscription, subscription_refund (below) shows the latest refund of any of its payments, renewals included.
Subscription fields (1.9.0+), from the subscription holding the row's license — null (and false) for a one-time purchase:
| Field | Meaning |
|---|---|
subscription_id | The storefront's subscription id |
subscription_state | active; trialing; past_due (a payment failed, still retrying); ending (canceled — runs to its end); ended (retries ran out — can still come back if the customer pays); or terminal (over for good) |
subscription_paid_through | The end of the latest period paid for |
subscription_ends_at | When it ends, ended or lapsed |
subscription_closed_at | When it ended for good |
subscription_events_missing | true when the purchase is more than a day old, its subscription is still open, and nothing has been paid or granted — the payment events are probably not enabled |
Plan and seat fields (1.13.0+) — the plan the subscription last reported, and what the row's license holds now:
| Field | Meaning |
|---|---|
subscription_plan_item | The plan's price or variant ID (with several items, the first one a mapping matches) |
subscription_plan_ref_kind, subscription_plan_ref | The reference that matched a mapping, or the item's ID when none did |
subscription_quantity | The subscription's seat count, when known |
subscription_plan_status | Null before any plan is reported; mapped; unmapped (no mapping matches it — the license keeps its terms); or product_mismatch (its mapping names another product, so the change is refused) |
license_tier, license_max_activations | The license's current tier and machines (max_activations; null means unlimited, which only a license set by hand can be — a mapping without a maximum issues 1) |
activations_count | Machines activated now — more than license_max_activations after a seat cut, which deactivates nothing |
subscription_refund (1.19.0+) — the latest refund recorded on any of the subscription's payments, renewals included, or null:
{
"subscription_refund": {
"invoice_id": "in_...",
"refund_kind": "full",
"refund_action": "rolled_back",
"refunded_at": "2026-11-02T10:00:00.000Z",
"refunded_amount": 5900,
"first_invoice": false
}
}| Field | Meaning |
|---|---|
invoice_id | The storefront's invoice: Stripe's in_..., the Lemon Squeezy subscription invoice's ID, or Paddle's invoice number (its transaction ID when there is none) |
refund_kind | partial or full |
refund_action | revoked, rolled_back, recorded, no_license, already_revoked or revoke_failed (revoke that license by hand), as above |
refunded_at | When the refund happened |
refunded_amount | In the provider's smallest currency unit, with no currency attached |
first_invoice | 1.21.2+: true when the refund was of the subscription's first payment. On Lemon Squeezy and Paddle the purchase's own refund_kind and refund_action describe the same refund; on Stripe they stay empty |
Retention: a resolved purchase record is kept for 400 days by default (operator-configurable, minimum 35 days) before it's automatically removed from this list. This never affects the license itself — an issued license keeps working forever and can still be found by searching issued_to in the licenses list. A purchase still awaiting payment confirmation, or an issued license that was never successfully delivered, is never pruned no matter how old — unless it was fully refunded, since a refunded purchase is never emailed. A subscription's purchase record (1.9.0+) is kept while the subscription is open, and for the same retention after it ends.
POST /v1/webhooks/storefront/:id
Your storefront calls this, not your app — it is the callback URL from the connect step, documented here for troubleshooting.
Verified against the per-webhook signing secret, using the storefront's own scheme: Stripe's Stripe-Signature header, Lemon Squeezy's X-Signature (an HMAC-SHA256 of the raw body), or Paddle's Paddle-Signature (ts=…;h1=…, an HMAC-SHA256 of ts: plus the raw body, within 5 minutes of ts). No bearer auth.
Stripe acts on checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, (1.8.0+) charge.refunded, (1.9.0+) invoice.paid and customer.subscription.created, .updated and .deleted, and (1.19.0+) invoice_payment.paid. From 1.9.0 a subscription-mode checkout (a Payment Link with a recurring price) issues a license; before 1.9.0 it was ignored.
Lemon Squeezy acts on order_created — paid and partially refunded orders issue a license, failed and fraudulent orders are recorded as error, pending and refunded orders are ignored — and (1.8.0+) order_refunded. Its signature has no timestamp, so an order whose signed created_at is more than 7 days old, or a full refund whose refunded_at is more than 7 days old, is ignored to stop a replay. From 1.9.0 it also acts on the subscription events (subscription_created, _updated, _cancelled, _resumed, _expired, _paused, _unpaused) and the payment events (subscription_payment_success, subscription_payment_recovered); one more than 7 days old is ignored only for a subscription the server doesn't already know. From 1.19.0 it acts on subscription_payment_refunded, where a full refund more than 7 days old is ignored, as for order_refunded.
Paddle (1.11.0+) acts on transaction.completed by its origin: a checkout (web, api) issues a license, and a renewal or a plan change billed straight away (subscription_recurring, subscription_update) extends the subscription's license, keeping its tier; any other transaction is ignored. It also acts on the subscription.* events listed above, on approved refund, chargeback and chargeback_warning adjustments (adjustment.created, .updated) as refunds, and on api_key.* events that name the webhook's own key. A reversed chargeback is logged, and the license isn't restored. subscription.imported is ignored: an imported subscription has no purchase to issue from.
After issuing, the server looks the buyer's email up with the webhook's API key. When it can't right now — Paddle briefly unreachable or rate limiting, or this account over its lookup budget — the answer is 429: the license is issued, and Paddle's retry sends the email. While another request is still issuing or sending the same purchase, the answer is 409. Paddle stops at any 200 and retries anything else, 4xx included. A dead key or a customer Paddle doesn't know answers 200, flags the webhook's api_key_problem, and leaves the email for send-pending.
Subscriptions (1.9.0+): a subscription keeps one license whose expiry follows what's been paid, plus the webhook's grace. Only a payment extends it — Stripe's invoice.paid for a subscription period, or a Lemon Squeezy payment event paired with the subscription's renewal date — never the subscription's own status. A cancellation keeps exactly what was paid; an end is final. The full rules are in Subscriptions.
Refunds (1.8.0+): a full refund revokes the purchase's license under the webhook's revoke policy (the default); a partial refund is recorded only. A full refund is final: a license you reinstate afterwards stays active through storefront retries and resends. Stripe refunds find their purchase by payment intent, recorded from 1.8.0 on, so a purchase made before upgrading can't be matched (revoke it by hand). From 1.19.0, a refund of a subscription payment takes back the time that payment bought, on all three storefronts; the rules are in Refunds of subscription payments. Every other event gets a 200 and is ignored.
Response:
{ "received": true }Errors:
404— unknown or disabledwebhook_id, or a revoked tenant. The response is identical either way, so it leaks no existence signal.400— invalid signature, or a body that isn't valid JSON ({ "error": "Invalid webhook payload" }either way).502— the license was issued but the confirmation email failed. Safe to let the storefront retry: issuance is idempotent per purchase and is never repeated.503(1.9.0+) — a paid Stripe invoice whose subscription period isn't known yet (its lines are paginated and no subscription event has reported the period). Stripe retries it, and it applies once the period is known.409,429(1.11.0+, Paddle) — try again later, as above.
Unauthenticated failures are rate-limited per IP; verified traffic is not rate-limited.
POST /v1/admin/storefront-webhooks/:id/send-pending
License Server 1.11.0+, Paddle only. Emails the licenses that Paddle purchases issued while the buyer's email couldn't be looked up — a revoked or expired API key, or a webhook in the wrong environment. Paddle doesn't redeliver an event it was answered, so this is the way to send them. Run it on the active Paddle webhook once its API key works.
Request Body:
{}Response:
{ "sent": 3, "skipped": 0, "not_found": 1, "failed": 0, "remaining": 2, "stopped": "time_budget" }It covers this webhook's purchases and those of the disabled Paddle webhooks it replaced in the same environment. From a replaced webhook flagged customer_not_found (set up in the wrong environment), it also covers the other environment's purchases, last: those move to this webhook's environment when found, with their subscriptions, so renewals keep extending them.
A run handles at most 25 purchases, about one lookup every 400 ms, and starts no new one after about 6 seconds; run it again while remaining is above 0. A purchase whose license was revoked, with no customer ID, already fully refunded, or already sent is left out. A customer Paddle can't find is counted in not_found and not tried in that environment again; three in a row stop the run and flag the webhook.
stopped is null when everything was sent, or why the run stopped: batch_limit or time_budget (run it again), daily_cap, (1.17.0+) suspended (the account's buyer emails are paused), send_failed (the email service), webhook_changed (the webhook was disconnected or given a new API key during the run, 1.11.1+), a reason Paddle couldn't be reached, or an api_key_problem value.
Errors: 400 for a webhook that isn't Paddle's; 409 if it's disabled, not set up yet, or a run for the account is already going, or (1.15.1+) if the server has no email provider (EMAIL_PROVIDER unset or stub), since each email would only be logged again; 404 if the id does not exist or belongs to another tenant.
POST /v1/admin/storefront-webhooks/:id/deliveries/:checkout_session_id/resend
License Server 1.16.0+, any storefront. Emails one purchase's license again, for a buyer who lost the email or never saw it. The console's Resend email (Console 1.12.0+) calls this.
Headers:
Authorization: Bearer your-tenant-api-keyNo request body. :checkout_session_id is the purchase's checkout_session_id from the same webhook's deliveries list.
Response:
{ "sent": true, "to": "buyer@example.com" }- It goes to the license's
issued_to, which for a storefront license is the buyer's email from the purchase. There is no way to send it anywhere else. (A Paddle license issued before its buyer's email could be looked up holds astorefront:placeholder there until send-pending or Paddle's redelivery fetches the email, which then replaces it (1.16.0+; anissued_toyou set yourself is never replaced). A Paddle purchase whose buyer email couldn't be fetched when it was issued, and was then sent by send-pending or Paddle's redelivery before 1.16.0, still has the placeholder, so resend refuses it withThere is no buyer email on record for this license, so it cannot be resent: send that buyer their key yourself.) - The purchase may be
sent,pending,loggedor (1.17.0+)vendor. Afterwards it issent. If the email service fails, a purchase that wassentstayssent, one that wasvendorstaysvendor, and any other goes back topending. - A purchase left to you to deliver (
vendor, 1.17.0+) can be resent: Casazium emails that buyer anyway — say, after they tell you your own email never arrived. - It carries a new portal link. Like every license email, it mints a new portal token, and the link in the buyer's earlier email stops working once the new email has been sent. A resend that fails leaves the old link working — unless the email service timed out or the connection dropped, when the email may still arrive, so the new link is kept.
- A replaced (disabled) webhook's purchases are resent through that webhook's own
:id: they stay in its deliveries list, and a resend needs no webhook credentials. - Nothing is sent twice. A storefront redelivery that arrives while a resend is sending sends nothing more.
Limits. Each resend that is sent counts toward the account's daily limit of 200 license emails (the last 24 hours, together with storefront and send-pending emails). One purchase can be resent at most 3 times in 24 hours, at least 10 minutes apart. A resend that fails counts toward neither. While the account's buyer emails are paused (1.17.0+), a resend is refused too. The admin rate limit applies as well.
Errors ({ "error": "..." }):
404—Storefront webhook not found(no such webhook, or another tenant's), orPurchase not found(not one of this webhook's purchases).409— refused:This License Server has no email provider (EMAIL_PROVIDER=stub), so nothing can be sent - set EMAIL_PROVIDER=resend firstNo license was issued for this purchase, so there is nothing to resendThis purchase was fully refunded, so its license email is not sent againThis purchase's license has been deleted, so there is nothing to resendThis purchase's license is revoked, so its license email is not sent againThis purchase's buyer email hasn't been fetched from Paddle yet - use Send pending deliveries insteadThis purchase's buyer email hasn't been fetched from Paddle yet - it will be fetched when Paddle retries the event(1.17.0+, a purchase left to you to deliver, which send-pending never picks)There is no buyer email on record for this license, so it cannot be resentThe buyer email on record for this license isn't a single plain email address, so it cannot be resent(1.29.0+: for example a list, a display name such asName <a@x.dev>, a control character, or a domain with no dot; the license was issued, but no email is ever sent to it)This purchase's license email is being sent right now - try again in a minute(a storefront redelivery or another resend)This purchase changed while resending - reload it and try again(a redelivery, refund or revocation got there first)
429— a limit above:This purchase's email has been resent 3 times today - try again tomorrowThis purchase's email was resent a few minutes ago - wait 10 minutesThis account has reached its limit of 200 license emails a day - try again tomorrowBuyer emails are paused for this account - contact support(1.17.0+)
The admin rate limiter's own
429is shaped differently: itserroris alwaysToo Many Requests, with the wait inmessageand theRetry-Afterheader.502—The email could not be sent - try again later(the email service failed).
GET /v1/admin/storefront-webhooks/:id/deliveries/:checkout_session_id
License Server 1.17.0+, any storefront. One purchase of this webhook, by the storefront's own ID for it — for a purchase your storefront has just told you about, instead of paging the deliveries list. The console's Find a purchase by its checkout or order ID (Console 1.13.0+) calls this.
Headers:
Authorization: Bearer your-tenant-api-key:checkout_session_id is written as the deliveries list shows it: the Stripe Checkout Session ID, ls_order_ followed by the Lemon Squeezy order's identifier, or paddle_txn_ followed by the Paddle transaction ID.
Response: the purchase's deliveries row, with its license_key, plus buyer_email:
{
"checkout_session_id": "cs_test_...",
"outcome": "issued",
"license_key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"delivery_status": "vendor",
"buyer_delivery": "vendor",
"delivery_held": null,
"processed_at": "2026-10-21T10:00:00.000Z",
"buyer_email": "buyer@example.com"
}(The row's other fields, as in the deliveries list, are left out here. They include, from 1.19.0, subscription_refund.) buyer_email is the license's issued_to when it is a single plain email address, otherwise null: a Paddle purchase whose buyer email hasn't been fetched from Paddle yet (wait for Paddle's retry), one with no license issued, or (1.29.0+) one whose storefront sent something else as the buyer's email.
Errors: 404 — Storefront webhook not found (no such webhook, or another tenant's) or Purchase not found (not one of this webhook's purchases).
Delivering keys yourself with this route: set the webhook's buyer_delivery to vendor (PATCH); when your storefront tells you about an order, look the purchase up here for the license_key and buyer_email; and send the key from your own system. For a link to the buyer's portal, mint one with POST /v1/admin/reissue-portal-token and link to {your-server-root}/portal/{portal_token}: each call makes a new link, and the previous one stops working. Nothing counts toward the daily email limit, since nothing is sent. If a buyer says your email never arrived, a resend emails them from Casazium.
POST /v1/webhooks/resend
License Server 1.17.0+. Not for you to call: Resend calls it with its bounce and complaint events for license emails, signed by Resend (Svix) rather than sent with a bearer token. It answers 404 unless RESEND_WEBHOOK_SECRET is set. Self-hosted, it's optional; see Self-hosted: the email needs configuring.
Error Responses
All endpoints may return the following error responses. The error message varies by route; the ones below are typical.
400 Bad Request
{
"error": "Missing required fields"
}A field of the wrong type or shape gets a validation message naming it instead.
403 Forbidden
{
"error": "Unauthorized"
}404 Not Found
{
"error": "License key not found"
}429 Too Many Requests
{
"statusCode": 429,
"error": "Too Many Requests",
"message": "Rate limit exceeded, retry in 15 minutes"
}How long to wait is in the Retry-After response header (seconds); read that rather than the message. A 429 means "slow down", not "no": don't treat it as a refused license.
500 Internal Server Error
{
"error": "Internal Server Error"
}Some routes return a more specific message.