Skip to content

Admin API

The Admin API is how your own backend, a script or an AI assistant works with your organization: issue a licence when someone pays, end it on a refund, find a customer’s keys, and manage products, payment mappings and webhook endpoints. This page explains the rules that hold across its routes. Every route’s body and answers are in the generated API reference.

Send an admin API key as a bearer token: Authorization: Bearer sc_<prefix>_<secret>. An owner creates keys in the Console, on the API keys page, and chooses their scopes. The secret is shown once.

Each route needs the scope in the table below. A request without a valid key answers 401 unauthorized; a key without the scope answers 403 insufficient_scope, and details.missing lists what it lacks. The errors page lists every code. Give each key the fewest scopes that do its job: licences:read reveals licence keys and customer emails.

  • The base URL is https://api.sealcord.com, and every path starts with /v1.
  • Requests and answers are JSON (content-type: application/json), and a request body is at most 64 KB. Field names are snake_case, and times in answers are RFC 3339 UTC strings.
  • Send x-request-id (8 to 64 of A-Z a-z 0-9 . _ -) to correlate a call with Sealcord’s logs. The answer echoes it, and error bodies carry it; without one, Sealcord makes a UUID.
  • Every answer carries Cache-Control: no-store.
  • Admin routes allow 300 requests a minute per API key, counted for each route on its own. A 429 rate_limited past that limit carries Retry-After-admin, the seconds to wait.
  • Every change is written to the licence’s or the organization’s audit log, with the key’s id as the actor.

A key acts on its own organization’s rows alone: its products, the licences of those products, and their activations and audit logs. Lists leave everything else out. A single row that belongs to another organization answers exactly what a missing one does (product_not_found, licence_not_found), never 403, which would confirm that it exists.

Product ids, key prefixes and licence ids are global, so choosing one that another organization holds answers 409 conflict without naming it.

Method Path Scope Success
GET /v1/admin/products products:read 200
POST /v1/admin/products products:write 201
GET /v1/admin/products/{id} products:read 200
PATCH /v1/admin/products/{id} products:write 200
POST /v1/admin/products/{id}/rotate-signing-key products:write 200
POST /v1/admin/products/{id}/retire-public-key products:write 200
POST /v1/admin/licences licences:write 201
GET /v1/admin/licences licences:read 200
GET /v1/admin/licences/counts licences:read 200
GET /v1/admin/licences/{id} licences:read 200
GET /v1/admin/licences/{id}/audit audit:read 200
POST /v1/admin/licences/{id}/revoke licences:write 200
POST /v1/admin/licences/{id}/expire licences:write 200
POST /v1/admin/licences/{id}/reinstate licences:write 200
POST /v1/admin/licences/{id}/anonymise licences:anonymise 200
POST /v1/admin/activations/{id}/deactivate licences:write 200
GET /v1/admin/integrations polar:read 200
GET /v1/admin/integrations/{id}/mappings polar:read 200
PUT /v1/admin/integrations/{id}/mappings/{polarProductId} polar:write 200
DELETE /v1/admin/integrations/{id}/mappings/{polarProductId} polar:write 204
GET /v1/admin/webhooks webhooks:read 200
POST /v1/admin/webhooks webhooks:write 201
GET /v1/admin/webhooks/{id} webhooks:read 200
PATCH /v1/admin/webhooks/{id} webhooks:write 200
DELETE /v1/admin/webhooks/{id} webhooks:write 204
POST /v1/admin/webhooks/{id}/rotate-secret webhooks:write 200
GET /v1/admin/webhooks/{id}/deliveries webhooks:read 200
POST /v1/admin/webhooks/{id}/deliveries/{deliveryId}/retry webhooks:write 202
GET /v1/admin/organization organization:read 200
GET /v1/admin/members members:read 200
GET /v1/admin/invitations members:read 200

POST /v1/admin/licences issues a licence and answers 201 with the licence and its key. The guide shows how to call it safely from a payment webhook.

Terminal window
curl https://api.sealcord.com/v1/admin/licences \
-H "authorization: Bearer $SEALCORD_TOKEN" \
-H 'content-type: application/json' \
-d '{
"product_id": "acme-notes",
"email": "ada@example.com",
"limit_kind": "machines",
"limit_count": 3
}'
Field Default Notes
product_id required One of your organization’s products
email required The customer’s address, up to 320 characters
limit_kind seats seats (people) or machines
limit_count 1 1 to 4294967295
expires_at none (perpetual) RFC 3339 with an offset, in the future
major product’s current_major The major version of your app the licence is for
id random UUID Choose the licence id, for example an order’s UUID; 409 conflict if it is taken
external_ref none Your reference for the sale, unique per product
trial false A trial: needs expires_at, and the trial ends with the key

An answer of 402 plan_limit_reached means your plan allows no more active licences. It names the plan, the limit and the count, and nothing was written; existing licences keep working.

external_ref ties a licence to the sale behind it. It is 1 to 200 printable ASCII characters, opaque to Sealcord, such as shop:order:1234. Use an id, never personal data such as an email or a name. It is stored and matched exactly as sent, with no trimming or case folding. It is not in the key. It is unique per product: issuing again with the same reference on the same product answers 409 conflict with details.licence_id, the licence it issued. The same reference on another product is a different sale and issues.

A trial is an active licence with trial_ends_at set; null means it is not a trial. Issue one with "trial": true and an expires_at: the trial ends with the key, so continuing after it means issuing a new licence. Your app sees an active licence, except that an active verdict is never valid more than a day past the trial’s end.

GET /v1/admin/licences answers { "licences": [ … ] }, newest first, at most 100. It needs at least one of email, q or external_ref, and every parameter you give applies:

Parameter Matches
q A licence whose email starts with it, whose id is it or whose external reference is exactly it; or, for a whole pasted licence key, the licence it names
email The whole address, case-insensitive
product_id One product
external_ref Exactly, within product_id; it needs product_id
state active, trial, expired or revoked, as derived below
status The stored status: active, revoked or expired
term perpetual (no expiry), term (an expiry) or subscription (issued for a payment provider’s subscription)
source admin (the Admin API, the Console) or polar

A licence has exactly one state. trial is an active licence whose trial end is ahead; active is an active licence that is not a trial; expired is expired by hand or by a payment provider, or active with an expires_at that has passed, whose key no longer activates; revoked is revoked. So status=active&state=expired finds licences past their expiry that still say active.

GET /v1/admin/licences/counts totals your licences in one call, or one product’s with product_id. since is an RFC 3339 time, seven days ago by default. The answer’s counts hold all, the four states, the three terms, by_source, live_machines (machines that can run, on active or trial licences), issued_since and revoked_since.

GET /v1/admin/licences/{id} answers the licence, its key and its activations, live ones first. The key is derived each time, under the product’s current signing key, and is null once the licence is anonymised.

GET /v1/admin/licences/{id}/audit answers the licence’s events, oldest first, each with its action, its actor and its time. It needs the separate audit:read scope.

POST /v1/admin/licences/{id}/revoke, /expire and /reinstate each take { "reason": "…" } of 1 to 500 characters, and answer the licence and changed. They are idempotent: changed is false when the licence already had that status. The change reaches /v1/activations/status at once.

A reason that starts with polar: is reserved for payment providers and answers 400 validation_failed. Reinstating counts like an issue, so it can answer plan_limit_reached; an anonymised licence answers 409 licence_anonymised.

POST /v1/admin/activations/{id}/deactivate frees one machine’s seat by its activation id.

When a customer asks you to erase their data, anonymise their licence. It cannot be undone, so it has a scope of its own, licences:anonymise, which no other route needs and an AI assistant is never granted, and the body must confirm:

Terminal window
curl -X POST https://api.sealcord.com/v1/admin/licences/$LICENCE_ID/anonymise \
-H "authorization: Bearer $SEALCORD_TOKEN" \
-H 'content-type: application/json' \
-d '{ "confirm": true }'

In one transaction, Sealcord replaces the customer’s address, clears the external reference, the payment provider’s customer and checkout ids and the status reason, anonymises every machine’s id, name, OS and app version, and reduces the licence’s audit events to ids, times, statuses and counts. An active licence is revoked, and the licence can never be reinstated. Its key stops working online, and reading it returns "key": null. The licence’s id, product, limit and dates stay. Calling it again answers changed: false.

  • POST /v1/admin/products creates a product. Sealcord generates its Ed25519 signing key, stores it encrypted and answers with the public key to embed in your app; the private key is never returned. The body takes id, name, key_prefix and current_major, and optionally machines_per_seat (default 3). The product counts against your plan, and an organization creates at most 5 products a day (429 rate_limited past that).
  • PATCH /v1/admin/products/{id} changes name, current_major or machines_per_seat; give at least one.
  • POST /v1/admin/products/{id}/rotate-signing-key and /retire-public-key change the signing key: see rotate a signing key.

Adding a product in the Console is covered in add a product.

GET /v1/admin/integrations lists your connected payment provider accounts, never a credential. PUT and DELETE on an integration’s mappings say which provider product grants which of your products, and on what terms: see map Polar products. Connecting an integration and setting its webhook secret are done in the Console, by an owner or admin, never through the Admin API.

The /v1/admin/webhooks routes add, change, remove and list the endpoints that Sealcord tells about licence and machine changes, rotate their secrets, list deliveries and retry one. The signing secret is in the answer that adds an endpoint, once. What a receiver gets and how to check it: webhooks.

GET /v1/admin/organization answers your organization’s name, slug, plan and its team places. GET /v1/admin/members and GET /v1/admin/invitations list the members with their roles and the invitations not yet accepted or revoked.

Changing the team (inviting, revoking an invitation, changing a role, removing a member) needs a person: those routes answer only an AI assistant that a member connected, acting as that member with their role. No key holds the scope. See MCP.

None. Your app calls the activation routes with the licence key itself, and ships no API key. The Admin API is for your server.

Why does a licence of another organization answer “not found”?

Section titled “Why does a licence of another organization answer “not found”?”

So that nobody can learn whether a licence or product exists. A route answers a row that is not yours exactly as it answers a row that does not exist.

Not in one call. The list route needs an email, a q or an external_ref, and answers at most 100 licences. Use /counts for totals.