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.
The key your backend uses
Section titled “The key your backend uses”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>.
Issue once, whatever the retries
Section titled “Issue once, whatever the retries”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.
-
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
idon every attempt. A repeat answers409 conflictinstead of issuing a second licence. -
Optionally send
external_ref, your reference for the sale, such asshop: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’sconflictnames the licence indetails.licence_id. It is unique per product. -
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 Createdanswers the licence and itskey. 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. -
On
conflict, or on any answer you are unsure of (a timeout, a5xx, orplan_limit_reachedon 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 withplan_limit_reached, notconflict.If that read answers
licence_not_foundafter aconflict, 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.
An idempotent order handler
Section titled “An idempotent order handler”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 the plan has no room
Section titled “When the plan has no room”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.
Other answers
Section titled “Other answers”| 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}/revokewith 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}/expirewith a reason. When the term is known, you can also issue withexpires_atfrom the start. - A subscription resumed, a refund reversed:
POST /v1/admin/licences/{id}/reinstate. It counts like an issue, so it can answerplan_limit_reachedtoo.
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.
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" }'Test it
Section titled “Test it”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.
Questions
Section titled “Questions”How do I avoid issuing a licence twice?
Section titled “How do I avoid issuing a licence twice?”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.
How do I find a licence by order?
Section titled “How do I find a licence by order?”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.
Can I issue a licence that expires?
Section titled “Can I issue a licence that expires?”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.