/seller/v1
Seller API
Sell keys, manage buyers and resellers from your own systems, scoped and never twice
Authentication
Create a key in Settings › API keys. It is shown once; only an HMAC of it is stored. Send it in the header on every call, never in the URL (a key in the query string is refused with key_in_url).
curl https://<your API host>/seller/v1/apps \
-H "Authorization: Bearer clv_live_…"
Keys can be limited to some apps, to a list of IPs or CIDR ranges, and given an expiry. Rotating a key creates a new one and keeps the old one working for 7 days; revoking is immediate.
Scopes
A key does only what its scopes allow; a key with no scope can do nothing. *:read scopes never change anything. Presets: read only, Discord bot (licenses, users, devices), store (licenses), full access.
Writes and idempotency
POST creates or acts, PATCH and PUT change, DELETE removes (licenses are soft-deleted). Send Idempotency-Key with any write: repeating it within 24 hours returns the first answer (marked Idempotent-Replayed: true) instead of acting twice. The same key with a different body is refused.
curl -X POST https://<host>/seller/v1/apps/app_…/licenses \
-H "Authorization: Bearer clv_live_…" \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{ "policyId": "pol_…", "count": 1, "email": "buyer@example.com" }'
Full license keys are returned once, in that answer. Store them or send them to the buyer right away.
Rate limits and pages
120 requests per minute per key, 600 on Premium. Every answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Lists take ?limit=50&cursor=… and answer { data, nextCursor }. Every call is logged and visible in the dashboard.
Errors
{ "error": { "code": "forbidden_scope", "message": "…", "details": {}, "requestId": "…" } }
| unauthorized | 401 | Missing, malformed, expired or revoked key |
|---|---|---|
| key_in_url | 400 | A key was sent in the query string; use the header |
| forbidden_scope | 403 | The key lacks the scope this endpoint needs |
| ip_not_allowed | 403 | The caller is outside the key's allow list |
| not_found | 404 | Unknown id, or outside the key's apps |
| validation_failed | 400 | The body or query did not match the schema |
| conflict | 409 | Duplicate (username taken, already exists) |
| idempotency_mismatch | 422 | Same Idempotency-Key, different request |
| idempotency_in_progress | 409 | The first request with this key is still running |
| rate_limited | 429 | Over the per-key limit; see RateLimit-* headers |
| plan_limit | 402 | The workspace plan does not allow this |
| insufficient_credit | 402 | A reseller order costs more than the balance |
Endpoints
The machine-readable reference is GET /seller/v1/openapi.json (OpenAPI 3.1, generated from the same schemas that validate requests).
| Method | Path | Scope |
|---|---|---|
| GET | /me | any |
| GET | /apps | any |
| GET | /apps/{appId}/licenses | licenses:read |
| POST | /apps/{appId}/licenses | licenses:write |
| GET | /licenses/{id} | licenses:read |
| POST | /licenses/lookup | licenses:read |
| POST | /licenses/{id}/actions | licenses:write |
| DELETE | /licenses/{id} | licenses:write |
| GET | /apps/{appId}/users | users:read |
| POST | /apps/{appId}/users | users:write |
| GET | /users/{id} | users:read |
| POST | /users/{id}/actions | users:write |
| DELETE | /users/{id} | users:write |
| GET | /apps/{appId}/devices | devices:read |
| GET | /devices/{id} | devices:read |
| POST | /devices/{id}/actions | devices:write |
| GET | /apps/{appId}/sessions | sessions:read |
| POST | /sessions/{id}/revoke | sessions:write |
| POST | /apps/{appId}/sessions/revoke-all | sessions:write |
| GET | /apps/{appId}/variables | variables:read |
| PUT | /apps/{appId}/variables | variables:write |
| DELETE | /variables/{id} | variables:write |
| GET | /apps/{appId}/rules | rules:read |
| POST | /apps/{appId}/rules | rules:write |
| DELETE | /rules/{id} | rules:write |
| GET | /apps/{appId}/webhooks | webhooks:read |
| POST | /apps/{appId}/webhooks | webhooks:write |
| DELETE | /webhooks/{id} | webhooks:write |
| POST | /webhooks/{id}/test | webhooks:write |
| GET | /apps/{appId}/stats | stats:read |
| GET | /apps/{appId}/protect/summary | protect:read |
| GET | /apps/{appId}/protect/events | protect:read |
| GET | /resellers | resellers:read |
| POST | /resellers/{id}/credit | resellers:write |
| GET | /resellers/{id}/ledger | resellers:read |
Webhooks
Each delivery is { id, type, createdAt, appId, data } with the header Clavis-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of t.body with your signing secret. Check the time is within 5 minutes. Failed deliveries are retried about 15 times over 3 days and can be resent from the dashboard.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Resellers
A reseller key (created by the reseller in their desk) sees and acts only on the keys that reseller issued, and every key it creates is paid from their credit in the same transaction: an order larger than the balance is refused with insufficient_credit and nothing is charged.