Skip to content

Issue licences from your backend

Your backend learns that someone paid, then asks Sealcord for the licence. Sealcord takes no payment and holds no customer card, so this works with any payment provider, or with none. If you sell through Polar you can connect your own Polar and let its webhooks do this for you.

An organization owner creates an admin API key in the Console, on the API keys page, with the scopes licences:read and licences:write (scopes). The key belongs to your organization and reaches only its products and licences. Keep it on your server, in its secrets: never in your app, a web page or a repository. Every call sends it as Authorization: Bearer sc_<prefix>_<secret>.

Payment webhooks are delivered more than once, and your call to Sealcord can time out after it succeeded. Make every attempt for one order the same request, so Sealcord issues one licence.

  1. Choose the licence id when you record the order. Store a UUID with the order (or use the order’s own id, if your provider’s ids are UUIDs) and send it as id on every attempt. A repeat answers 409 conflict instead of issuing a second licence.

  2. Optionally send external_ref, your reference for the sale, such as shop:order:1234: 1 to 200 printable ASCII characters, never an email or a name. It lets you find the licence by order later, and a repeat’s conflict names the licence in details.licence_id. It is unique per product.

  3. Call POST /v1/admin/licences:

    Terminal window
    curl https://api.sealcord.com/v1/admin/licences \
    -H "authorization: Bearer $SEALCORD_TOKEN" \
    -H 'content-type: application/json' \
    -d '{
    "id": "7d4c1d52-2a0e-4c0b-9a51-5d3f8e0b6c21",
    "product_id": "acme-notes",
    "email": "ada@example.com",
    "limit_kind": "seats",
    "limit_count": 1,
    "external_ref": "shop:order:1234"
    }'

    201 Created answers the licence and its key. Show the key on your thank-you page or email it to the buyer, and store the licence id with the order. The key is the customer’s secret: do not log it.

  4. On conflict, or on any answer you are unsure of (a timeout, a 5xx, or plan_limit_reached on a retry), read the licence by its id before deciding it was not issued. GET /v1/admin/licences/{id} answers the licence with its key when an earlier attempt issued it. The plan limit is checked before the id, so an organization at its limit answers a repeat of an order it already issued with plan_limit_reached, not conflict.

    If that read answers licence_not_found after a conflict, the id belongs to another organization’s licence, because licence ids are global. Store a new random UUID for the order and issue again.

Every field, its default and every answer are in the Admin API page and the reference.

This TypeScript function issues the licence for an order, or returns the one an earlier attempt issued. It needs Node 18 or later for fetch.

const BASE = 'https://api.sealcord.com';
const headers = {
authorization: `Bearer ${process.env.SEALCORD_TOKEN ?? ''}`,
'content-type': 'application/json',
};
interface Order {
id: string; // your order id
licenceId: string; // a UUID you stored with the order
email: string;
}
/** The licence key for an order; throws when it could not be issued yet. */
export async function keyForOrder(order: Order): Promise<string> {
const issued = await fetch(`${BASE}/v1/admin/licences`, {
method: 'POST',
headers,
body: JSON.stringify({
id: order.licenceId,
product_id: 'acme-notes',
email: order.email,
limit_kind: 'seats',
limit_count: 1,
external_ref: `shop:order:${order.id}`,
}),
});
if (issued.status === 201) {
return ((await issued.json()) as { key: string }).key;
}
// A conflict, a plan limit, a timeout or a 5xx: an earlier attempt may have issued it.
const existing = await fetch(`${BASE}/v1/admin/licences/${order.licenceId}`, { headers });
if (existing.ok) {
const { key } = (await existing.json()) as { key: string | null };
if (key) return key;
}
// Not issued: keep the order pending and retry later with the same licence id.
throw new Error(`licence not issued for order ${order.id} (HTTP ${String(issued.status)})`);
}

Answer your provider’s webhook with a success only once the licence is issued, or the order is safely pending, so that the provider retries otherwise.

When your organization’s plan allows no more active licences, issuing answers 402 plan_limit_reached. Nothing was issued, and every licence you already issued keeps working. details names the plan, the limit and the number active; after a grace period, it also says when the grace period ended.

Do not lose the order. After step 4 shows it was not issued, keep it as pending, tell the buyer the key will follow, and tell yourself with an alert or an email. Issue the pending orders, with the same ids, once you have upgraded the plan in the Console or revoked or expired licences you no longer need. A Polar integration does this for you; your own backend has to.

Answer What to do
400 validation_failed Fix the listed field (details.issues); retrying the same body fails again
401 unauthorized The key is wrong, revoked or expired: alert, do not retry
403 insufficient_scope The key lacks details.missing: create one with licences:read and licences:write
404 product_not_found The product id is wrong, or the product is not your organization’s
429 rate_limited Wait Retry-After-admin seconds, then retry with the same id
503 product_unavailable The product cannot sign right now and nothing was written: retry later with the same id
500, 503, timeout, no answer Retry later with the same id, after step 4

Every error has the same shape and a stable code; the full list is on the errors page.

On a refund, a chargeback or a cancellation

Section titled “On a refund, a chargeback or a cancellation”
  • Refund or chargeback: POST /v1/admin/licences/{id}/revoke with a reason, for example "refund #1234". The app stops working at its next status check on every machine.
  • A subscription or term that ended: POST /v1/admin/licences/{id}/expire with a reason. When the term is known, you can also issue with expires_at from the start.
  • A subscription resumed, a refund reversed: POST /v1/admin/licences/{id}/reinstate. It counts like an issue, so it can answer plan_limit_reached too.

Each takes { "reason": "…" } of 1 to 500 characters, and is idempotent: it answers changed: false when the licence already had that status, so a repeated webhook is harmless. Each change is written to the licence’s audit log. Find the licence by the id you stored with the order, or by its external_ref.

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

Every licence you issue is real: its key activates, and while it is active it counts against your plan’s limit. Test in a workspace of your own that you keep for it, which has its own plan, or with a product you create for testing if your plan has room for another product. Use your payment provider’s test mode for the payments. Revoke the licences you issued while testing: a licence you revoke stops counting at once.

Send the same id (and the same external_ref) on every attempt for one order. A repeat answers 409 conflict instead of issuing, and you read the licence you already have.

With external_ref: GET /v1/admin/licences?product_id=acme-notes&external_ref=shop:order:1234 answers the product’s licence for that reference, or none. If you stored the licence id, read GET /v1/admin/licences/{id} instead.

What if my server crashes after Sealcord issued the licence?

Section titled “What if my server crashes after Sealcord issued the licence?”

Nothing is lost. Your provider retries the webhook, your handler sends the same request, Sealcord answers conflict, and you read the licence and its key by id.

Yes: send expires_at, an RFC 3339 time in the future. The key carries the expiry, and the licence stops activating when it passes. A perpetual licence has no expires_at.