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.
Authentication
Section titled “Authentication”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.
Conventions
Section titled “Conventions”- 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 aresnake_case, and times in answers are RFC 3339 UTC strings. - Send
x-request-id(8 to 64 ofA-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_limitedpast that limit carriesRetry-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.
Your organization only
Section titled “Your organization only”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.
Routes
Section titled “Routes”| 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 |
Issue a licence
Section titled “Issue a licence”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.
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 reference
Section titled “External reference”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.
Trials
Section titled “Trials”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.
Find licences
Section titled “Find licences”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.
Count licences
Section titled “Count licences”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.
Read a licence and its audit log
Section titled “Read a licence and its audit log”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.
Revoke, expire and reinstate
Section titled “Revoke, expire and reinstate”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.
Anonymise a licence
Section titled “Anonymise a licence”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:
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.
Products
Section titled “Products”POST /v1/admin/productscreates 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 takesid,name,key_prefixandcurrent_major, and optionallymachines_per_seat(default 3). The product counts against your plan, and an organization creates at most 5 products a day (429 rate_limitedpast that).PATCH /v1/admin/products/{id}changesname,current_majorormachines_per_seat; give at least one.POST /v1/admin/products/{id}/rotate-signing-keyand/retire-public-keychange the signing key: see rotate a signing key.
Adding a product in the Console is covered in add a product.
Payment integrations
Section titled “Payment integrations”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.
Webhook endpoints
Section titled “Webhook endpoints”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.
Organization and team
Section titled “Organization and team”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.
Questions
Section titled “Questions”Which key do I use in my app?
Section titled “Which key do I use in my app?”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.
Can I list every licence?
Section titled “Can I list every licence?”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.