Browse the library
Documentation librarySelling
Selling with the Seller API
The Seller API lets your store, Discord bot or internal system create and manage licenses over HTTP, without opening the dashboard. This guide goes from creating a safe API key, through authentication, idempotent writes and rate limits, to a complete example that sells licenses from a store backend.
11 min read
Before you start
- API keys live at Business › Seller API keys. Only the Owner and Admins of the workspace can create, rotate and revoke them.
- Every endpoint sits under
/seller/v1on your API address. Examples here usehttps://api.example.com; the dialog that shows a new key includes a samplecurlcommand with the real address. - Each key belongs to one workspace and can only reach that workspace's apps.
- The number of keys depends on your plan, by default 2 on Basic and 25 on Premium. The figure for your workspace is in the footnote of the keys page and at Account & organization › Plan & usage. The limit counts every key that is not revoked.
Creating an API key
- Open Business › Seller API keys and click Create key.
- In the New API key panel, fill in the fields in the table below.
- Click Create key.
- The Your new key dialog shows the full key. Copy it into your secrets manager and click Stored it.
| Field | Meaning |
|---|---|
| Key name | Up to 64 characters. Name it after the system that uses it, for example "Shopify store". The name is recorded with every write the key makes. |
| Scopes | Four presets and a grid of read / write boxes per resource group. The panel opens on the Store preset. At least one scope is required. |
| Limit to apps | Tick the apps the key may use, up to 50. None selected means every app of the workspace, including apps created later. |
| Allowed IPs | IPv4, IPv6 or CIDR ranges, separated by commas or spaces, up to 50 entries. Empty means calls are accepted from anywhere. |
| Expires on | Optional. The key stops working at 23:59:59 UTC on the chosen day. |
Scopes
A key can only do what its scopes allow, and a :read scope never changes data. GET /seller/v1/me and GET /seller/v1/apps need no scope.
| Group | read | write |
|---|---|---|
licenses | List, details, lookup by key | Create keys, actions, delete |
users | End users | Create, ban, unban, reset password, sign out everywhere, delete |
devices | Devices, transfer history | Remove, block, unblock |
sessions | Live sessions | End one session or every session of an app |
variables | Variables (secret values come back as null) | Set and delete variables |
releases | Releases | Publish drafts, block, unblock |
rules | Access rules | Add and remove rules |
webhooks | Webhooks | Create, delete, send a test |
stats | Licenses by status, users, sessions, validations in the last 24 hours | |
protect | Protect 24/7 summary and events | |
resellers | Resellers, balances, ledger | Top up or deduct credit |
The presets: Read only (every :read scope), Discord bot (licenses, users, devices, read and write), Store (licenses:read and licenses:write, enough to sell and look up keys) and Full access (every scope).
The key is shown once
After that, each row in the list shows the name, the first 13 characters of the key, the scopes, the number of allowed IPs, the number of limited apps and Last used with the calling IP (updated at most once a minute). The clv_live_ prefix also helps secret scanners spot a key that was pushed to a repository by mistake.
Rotating and revoking
- Rotate (the circular arrow on the key row) creates a new key at once with the same name, scopes, apps, IPs and expiry date, shown once in the Your new key dialog. The old key keeps working for 7 more days so you can update your servers; its row says Grace ends with the time. After 7 days the old key is refused.
- Revoke asks for confirmation, then the key is refused from the next call on. This cannot be undone.
- Both buttons only appear on Active keys. Scopes, apps, IPs and expiry cannot be edited after creation; to change them, create a new key and revoke the old one.
Authentication
Send the key in the Authorization header on every call:
curl https://api.example.com/seller/v1/me \
-H "Authorization: Bearer $CLAVIS_API_KEY"
/me returns the key's ID, name, prefix, scopes, allowed apps, key type (seller) and workspaceId. It is the quickest way to check a key is set up correctly. When a call is refused:
| Situation | HTTP | Error code |
|---|---|---|
The key is in the URL (api_key, apikey, key, token, access_token, secret in the query string) | 400 | key_in_url |
| Missing, malformed, unknown, revoked or expired key, or more than 7 days after a rotation | 401 | unauthorized |
| The calling IP is not in Allowed IPs | 403 | ip_not_allowed |
| Over the rate limit | 429 | rate_limited |
| The key lacks the endpoint's scope | 403 | forbidden_scope |
| The app or object does not exist, or is outside Limit to apps | 404 | not_found |
Every error has the same shape. Log the requestId so you can trace a call later:
{ "error": { "code": "forbidden_scope", "message": "This key needs the \"licenses:write\" scope", "requestId": "…" } }
Idempotent writes
The network can drop right after the server has created a key; calling again without protection creates a second key. The Idempotency-Key header solves this for every write (POST, PUT, DELETE):
- Use a value tied to the business event, for example the order number
order-1042, up to 255 characters. - For 24 hours, sending the same key with the same method, path and body returns the first response again, with the header
Idempotent-Replayed: true, and nothing is done twice. - Same key with a different body:
422 idempotency_mismatch. First call still running:409 idempotency_in_progress; wait a moment and send it again. - 5xx responses are not stored, so retrying with the same key runs the action again. Errors returned by the action itself, such as
plan_limitorunknown_policy, are stored and replayed; once you fix the cause, use a new key. - Idempotency records belong to each API key. A new key after a rotation does not see the old key's records.
Rate limits and pagination
- Each key gets 120 calls per minute, 600 if the workspace is on Premium. The counter resets at the start of each minute.
- Responses to calls that passed the key and IP checks carry
RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset(seconds until the reset). Going over returns429 rate_limited; wait forRateLimit-Resetand call again. - Large lists (licenses, users, devices, sessions) accept
limitfrom 1 to 100, default 50, pluscursor, and return{ "data": [...], "nextCursor": "…" }. PassnextCursorascursoruntil it isnull.
Example: selling licenses from a store backend
The scenario: a customer pays, the payment provider notifies your backend, the backend creates a key, sends it to the customer and stores the license ID on the order.
Setup
- Create a key with the Store preset, tick the right app in Limit to apps and enter the store server's IP.
- Store the key in the
CLAVIS_API_KEYenvironment variable on the server. Never put the key in code that runs in a browser or in software you ship to customers. - Get the App ID
app_…from the app's Overview page, or callGET /seller/v1/apps. - Pick a license plan. Leaving out
policyIduses the app's default plan. The dashboard does not show plan IDs yet; to get thepol_…of another plan, create a key with that plan at Licensing & releases › Licenses, then look that key up withPOST /seller/v1/licenses/lookup: the response includespolicyId.
Creating a key with curl
curl -X POST https://api.example.com/seller/v1/apps/app_…/licenses \
-H "Authorization: Bearer $CLAVIS_API_KEY" \
-H "Idempotency-Key: order-1042" \
-H "Content-Type: application/json" \
-d '{"policyId": "pol_…", "count": 1, "email": "buyer@example.com", "note": "Order 1042", "metadata": {"orderId": "1042"}}'
Success returns 201, with keys and ids in the same order:
{
"data": {
"keys": ["PRO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"],
"ids": ["lic_…"],
"warnings": []
}
}
| Field | Meaning |
|---|---|
count | Number of keys, from 1 to your plan's "Keys per batch" limit. |
policyId | The license plan; it must belong to the app in the path, otherwise unknown_policy. |
durationSeconds | Overrides the plan's duration, from 60 seconds to 10 years, null for lifetime. Counted from the first activation. |
maxDevices | Overrides the device count, no higher than "Devices per key". |
prefix | 1 to 12 capital letters or digits, for example PRO. |
note | A note, up to 500 characters. The order number is a good choice. |
email | The buyer's email, used for the customer portal and expiry reminders. |
metadata | A flat object whose values are strings, numbers or booleans; returned by GET /seller/v1/licenses/{id}. |
expiresAt | An ISO 8601 date, Seller API only. An unactivated key is refused after this date (plus the plan's grace). On first activation the expiry is recalculated from the key's duration, or becomes lifetime if the key has none. To sell by period, use durationSeconds. |
test | true for a TEST key: outside the daily limit, up to 10 at a time, ends 24 hours after it is made. Refused with app_released once the app is publicly released. |
The same thing in JavaScript
The code below runs on Node.js 18 or later. The clavis function sends the key, adds Idempotency-Key and retries on 429, 5xx errors or idempotency_in_progress:
const API = "https://api.example.com/seller/v1";
const APP_ID = "app_…";
async function clavis(path, { method = "GET", body, idempotencyKey } = {}) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(API + path, {
method,
headers: {
Authorization: `Bearer ${process.env.CLAVIS_API_KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const json = await res.json();
if (res.ok) return json;
const retry = res.status === 429 || res.status >= 500 || json.error?.code === "idempotency_in_progress";
if (!retry || attempt === 3) {
throw new Error(`${json.error?.code}: ${json.error?.message} (requestId ${json.error?.requestId})`);
}
const wait = res.status === 429 ? Number(res.headers.get("RateLimit-Reset") ?? 1) : 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}
// Call when the payment provider confirms the order is paid
export async function fulfilOrder(order) {
const { data } = await clavis(`/apps/${APP_ID}/licenses`, {
method: "POST",
idempotencyKey: `order-${order.id}`,
body: {
policyId: order.policyId,
count: 1,
email: order.email,
note: `Order ${order.id}`,
metadata: { orderId: String(order.id) },
},
});
if (data.warnings.length) console.warn("Clavis:", data.warnings);
return { licenseId: data.ids[0], key: data.keys[0] };
}
// Refund: revoke the key
export async function refundOrder(order) {
await clavis(`/licenses/${order.licenseId}/actions`, {
method: "POST",
idempotencyKey: `refund-${order.id}`,
body: { action: "revoke" },
});
}
// Renew for 30 days, one Idempotency-Key per renewal
export async function renewLicense(licenseId, renewalId) {
await clavis(`/licenses/${licenseId}/actions`, {
method: "POST",
idempotencyKey: `renew-${renewalId}`,
body: { action: "extend", seconds: 30 * 86400 },
});
}
Common errors when selling
unknown_policy(400): thepolicyIddoes not belong to this app.plan_limit: the batch is over "Keys per batch" or the device count is over "Devices per key" (400), or the daily key limit is used up on Basic (402). On Premium the keys are still created andwarningscontainsdaily_key_limit_exceeded. See Basic, Premium and limits.validation_failed(400): the body does not match the schema;detailsnames the field.
Looking up and acting on a license
A support bot or your own admin page usually starts from the key a customer sends you:
curl -X POST https://api.example.com/seller/v1/licenses/lookup \
-H "Authorization: Bearer $CLAVIS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"key": "PRO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"}'
- The key goes in the body, never in the URL. The server compares hashes, ignoring case and extra spaces; no match returns
404 not_found. - Send
{"mark": "0123456789.abcdef"}instead of a key to find the license behind a buyer mark (Forensic Watermarking). The answer addsmarkDevice(id,name,platform,active) when the mark's second half matches a machine's current device key, otherwisenull. - The license returned has
id,preview,status,maxDevices,devices(machines holding a seat),activatedAt,expiresAt,note,email,policyIdandappId.statusis the effective status: an active key past its expiry comes back asexpired. GET /seller/v1/licenses/{id}also returnspolicy(id,name,entitlements) andmetadata.GET /seller/v1/apps/{appId}/licensesfilters bystatus(unused,active,expired,suspended,revoked),policyIdandq(partial match on note or email, case-insensitive).
Actions use POST /seller/v1/licenses/{id}/actions and return the license after the change:
action | Extra fields | Effect |
|---|---|---|
suspend | Suspends an Unused or Active key and ends every session. | |
reinstate | Back to Active if it was ever activated, otherwise back to Unused. | |
revoke | Stops the key for good and ends every session. | |
extend | seconds, from 60 seconds to 10 years | Extends the key, same rules as the Extend button in the dashboard. |
reset_devices | Removes every device and ends every session. | |
note | note, up to 500 characters, null to clear | Changes the note. |
email | email, null to clear | Sets or changes the buyer email. |
DELETE /seller/v1/licenses/{id} deletes a key: it is revoked, every session ends, it disappears from lists, and the API returns 204. To remove a single machine instead of the whole key, use POST /seller/v1/devices/{id}/actions with {"action": "revoke"} (scope devices:write); this removal is recorded as done by the seller and does not use up the customer's transfers.
Request log
On every key row, revoked keys included, click the Request log icon to open the Request log panel:
- The 50 most recent calls, newest first, refreshed every 10 seconds.
- Each row shows the method, the path (without the query string), the status code (red from 400 up) and the processing time in ms.
- Only calls that passed the key, IP and rate limit checks are logged. Calls refused with
key_in_url,unauthorized,ip_not_allowedorrate_limiteddo not appear; if an integration fails and the log is empty, check those causes first.
The full list of endpoints, error codes and the OpenAPI document (GET /seller/v1/openapi.json) is in the Seller API reference.