The CLV1 protocol is now publicRead the spec
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/v1 on your API address. Examples here use https://api.example.com; the dialog that shows a new key includes a sample curl command 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

  1. Open Business › Seller API keys and click Create key.
  2. In the New API key panel, fill in the fields in the table below.
  3. Click Create key.
  4. The Your new key dialog shows the full key. Copy it into your secrets manager and click Stored it.
FieldMeaning
Key nameUp 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.
ScopesFour 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 appsTick the apps the key may use, up to 50. None selected means every app of the workspace, including apps created later.
Allowed IPsIPv4, IPv6 or CIDR ranges, separated by commas or spaces, up to 50 entries. Empty means calls are accepted from anywhere.
Expires onOptional. 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.

Groupreadwrite
licensesList, details, lookup by keyCreate keys, actions, delete
usersEnd usersCreate, ban, unban, reset password, sign out everywhere, delete
devicesDevices, transfer historyRemove, block, unblock
sessionsLive sessionsEnd one session or every session of an app
variablesVariables (secret values come back as null)Set and delete variables
releasesReleasesPublish drafts, block, unblock
rulesAccess rulesAdd and remove rules
webhooksWebhooksCreate, delete, send a test
statsLicenses by status, users, sessions, validations in the last 24 hours
protectProtect 24/7 summary and events
resellersResellers, balances, ledgerTop 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:

SituationHTTPError code
The key is in the URL (api_key, apikey, key, token, access_token, secret in the query string)400key_in_url
Missing, malformed, unknown, revoked or expired key, or more than 7 days after a rotation401unauthorized
The calling IP is not in Allowed IPs403ip_not_allowed
Over the rate limit429rate_limited
The key lacks the endpoint's scope403forbidden_scope
The app or object does not exist, or is outside Limit to apps404not_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_limit or unknown_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-Remaining and RateLimit-Reset (seconds until the reset). Going over returns 429 rate_limited; wait for RateLimit-Reset and call again.
  • Large lists (licenses, users, devices, sessions) accept limit from 1 to 100, default 50, plus cursor, and return { "data": [...], "nextCursor": "…" }. Pass nextCursor as cursor until it is null.

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

  1. Create a key with the Store preset, tick the right app in Limit to apps and enter the store server's IP.
  2. Store the key in the CLAVIS_API_KEY environment variable on the server. Never put the key in code that runs in a browser or in software you ship to customers.
  3. Get the App ID app_… from the app's Overview page, or call GET /seller/v1/apps.
  4. Pick a license plan. Leaving out policyId uses the app's default plan. The dashboard does not show plan IDs yet; to get the pol_… of another plan, create a key with that plan at Licensing & releases › Licenses, then look that key up with POST /seller/v1/licenses/lookup: the response includes policyId.

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": []
  }
}
FieldMeaning
countNumber of keys, from 1 to your plan's "Keys per batch" limit.
policyIdThe license plan; it must belong to the app in the path, otherwise unknown_policy.
durationSecondsOverrides the plan's duration, from 60 seconds to 10 years, null for lifetime. Counted from the first activation.
maxDevicesOverrides the device count, no higher than "Devices per key".
prefix1 to 12 capital letters or digits, for example PRO.
noteA note, up to 500 characters. The order number is a good choice.
emailThe buyer's email, used for the customer portal and expiry reminders.
metadataA flat object whose values are strings, numbers or booleans; returned by GET /seller/v1/licenses/{id}.
expiresAtAn 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.
testtrue 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): the policyId does 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 and warnings contains daily_key_limit_exceeded. See Basic, Premium and limits.
  • validation_failed (400): the body does not match the schema; details names 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 adds markDevice (id, name, platform, active) when the mark's second half matches a machine's current device key, otherwise null.
  • The license returned has id, preview, status, maxDevices, devices (machines holding a seat), activatedAt, expiresAt, note, email, policyId and appId. status is the effective status: an active key past its expiry comes back as expired.
  • GET /seller/v1/licenses/{id} also returns policy (id, name, entitlements) and metadata.
  • GET /seller/v1/apps/{appId}/licenses filters by status (unused, active, expired, suspended, revoked), policyId and q (partial match on note or email, case-insensitive).

Actions use POST /seller/v1/licenses/{id}/actions and return the license after the change:

actionExtra fieldsEffect
suspendSuspends an Unused or Active key and ends every session.
reinstateBack to Active if it was ever activated, otherwise back to Unused.
revokeStops the key for good and ends every session.
extendseconds, from 60 seconds to 10 yearsExtends the key, same rules as the Extend button in the dashboard.
reset_devicesRemoves every device and ends every session.
notenote, up to 500 characters, null to clearChanges the note.
emailemail, null to clearSets 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_allowed or rate_limited do 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.

How can we help?

Frequently asked

What is ClavisAUH?

A licensing and authentication service for desktop software: license keys, device binding, end-user sign-in and signed responses. All run from one dashboard

Why can't a cracker fake your server?

The SDK only trusts answers signed with your app's private key. That key never leaves our servers, so a fake server can't sign and gets rejected

What ends up in my binary?

Your app id and public key. Nothing secret, because each device makes its own key pair on first run

Does it work offline?

Yes. Every plan gets a signed offline token for 7 days, and your app checks it locally. Premium lets you choose how long. A revoked license can keep working offline for that time, unless Online-Only Mode is on

Which languages are supported?

JavaScript and TypeScript today. C++, C# and Python are next, on the same open protocol

Is Basic really free?

Yes. No time limit, no card. Move to Premium when you need more

Get in touch