Skip to content

Webhooks

A webhook endpoint is a URL on your own server and the event types it receives. When one of your licences or a machine changes, Sealcord sends the endpoint a signed POST within seconds. Use it to update your own database, tell your customer, or start a workflow, without polling the API.

This page covers adding an endpoint, the events and the request. Next: verify the signature, then read how retries and disabled endpoints work.

An owner or admin adds one in the Console, under Webhooks, Outgoing. Your backend can add one too, with an admin API key that has the webhooks:write scope (add an endpoint). An AI assistant connected through the MCP server cannot add, change or remove an endpoint, or replace its secret.

The answer that adds the endpoint carries its signing secret, whsec_…. It is shown once. Keep it in your server’s secrets. If you lose it, replace it.

The URL must:

  • use https on port 443 and name a public host;
  • carry no user name, no password and no #fragment;
  • be at most 2048 characters;
  • not be an IP address (however written), an internal name (localhost, a name without a dot, anything under .localhost, .internal, .local or .home.arpa) or anything under sealcord.com.

A URL that breaks a rule is refused when you save it. Sealcord checks again on every attempt: it resolves the host, and refuses the attempt (address_refused) if any address it gets is not public. To try a receiver on your own machine, expose it through a tunnel that gives it a public https host name.

A plan includes a number of endpoints: Free 1, Indie 3, Studio 10 and Business 25. Turned-off endpoints count. Past the number, adding one answers 402 plan_limit_reached.

An event is written in the same database transaction as the change it reports, so you hear about every change that happened and none that rolled back. Only changes to your organization’s licences are sent, and only to enabled endpoints subscribed to the event’s type.

Type When data holds
licence.issued A licence is issued: through the API, the Console or a payment integration licence
licence.revoked A licence becomes revoked: by you, or by a payment provider’s refund or subscription end licence
licence.expired A licence is expired on purpose (the API, the Console or a payment provider’s event). A key’s expires_at passing changes nothing and sends nothing licence
licence.reinstated A revoked or expired licence becomes active again licence
licence.anonymised The licence’s customer data was erased on your instruction. The licence in data is already anonymised. Erasing also revokes an active licence and frees its machines, and sends no licence.revoked or activation.deactivated for that licence
licence.trial_converted A trial was paid for: its trial_ends_at is cleared licence
activation.created A machine with no live activation on the licence is activated. Activating a machine that already holds a seat refreshes it and sends nothing activation, licence
activation.deactivated A machine’s seat is freed: by your app, the customer, or you in the Console or the API activation, licence

A change that changes nothing, such as revoking a licence that is already revoked, writes no event. licence is the licence as the API shows it, never its key. activation is the machine as the API lists it. Both are the state the change left, at the moment it happened: a retry hours later still carries that snapshot, so read the licence from the API when you need it as it is now.

Each delivery is a POST to your URL:

POST /sealcord HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Sealcord-Webhooks/1.0 (+https://sealcord.com)
webhook-id: evt_6f0c0d5e2b9a4c1e8d7f3a2b1c0d9e8f
webhook-timestamp: 1791018300
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type":"activation.created","timestamp":"2026-10-03T09:05:00.000Z","data":{"activation":{…},"licence":{…}}}
  • webhook-id is the event’s id: evt_ and 32 hex characters. It is the same on every attempt and every endpoint, so de-duplicate on it.
  • webhook-timestamp is the attempt’s time in Unix seconds. It changes on every attempt and is part of what is signed.
  • webhook-signature is v1, and a base64 signature. There is one entry per signing secret, separated by spaces: two, for 24 hours after you replace the secret.
  • The body is one line of JSON, the same bytes on every attempt, at most 64 KB. A larger event is never sent: its delivery fails with payload_too_large.

An activation.created event for Acme Notes, formatted for reading:

{
"type": "activation.created",
"timestamp": "2026-10-03T09:05:00.000Z",
"data": {
"activation": {
"id": "10000000-0000-4000-8000-000000000001",
"licence_id": "00000000-0000-4000-8000-000000000001",
"machine_id": "ada-mbp-2024",
"label": "Ada's MacBook Pro",
"os": "macos 26.1",
"app_version": "2.0.3",
"activated_at": "2026-10-03T09:05:00.000Z",
"last_seen_at": "2026-10-03T09:05:00.000Z",
"deactivated_at": null
},
"licence": {
"id": "00000000-0000-4000-8000-000000000001",
"product_id": "acme-notes",
"email": "ada@example.com",
"issued_at": "2026-10-01T12:00:00.000Z",
"expires_at": null,
"limit_kind": "machines",
"limit_count": 2,
"major": 2,
"status": "active",
"status_reason": null,
"status_changed_at": null,
"trial_ends_at": null,
"source": "admin",
"integration_id": null,
"external_ref": "order-1042",
"polar": {
"order_id": null,
"subscription_id": null,
"customer_id": null,
"checkout_id": null
},
"anonymised_at": null,
"created_at": "2026-10-01T12:00:00.000Z",
"updated_at": "2026-10-01T12:00:00.000Z"
}
}
}

timestamp in the body is when the change happened, and type is the event’s type. Fields may be added to licence and activation as the API grows: ignore the ones you do not know.

  • Answer 2xx within 5 seconds. The 5 seconds cover the lookup, the connection and your status line. Store the event, answer, and do the work afterwards. Sealcord keeps only your status code: it never reads the body of your answer.
  • A redirect is a failure. Sealcord follows none, so give the final URL.
  • The same event can arrive more than once. Delivery is at least once: a retry after a timeout you did answer, a sender that stopped mid-send, or a retry from the Console. Record each webhook-id you handled and skip a repeat. Keep them for 30 days, because a delivery can be retried by hand until its event is deleted, 30 days after it happened.
  • Events can arrive out of order after a retry. Order by timestamp in the body, or read the licence from the API.

No. An event carries the licence as the API shows it, without its key. Read the licence from the Admin API when you need the key.

Does Sealcord send a webhook when a licence’s expiry date passes?

Section titled “Does Sealcord send a webhook when a licence’s expiry date passes?”

No. A key’s expires_at passing changes no status, so it sends nothing. licence.expired is sent when a licence is expired on purpose.

Can I send webhooks to a server on my laptop?

Section titled “Can I send webhooks to a server on my laptop?”

Only through a tunnel that gives it a public https host name on port 443. Sealcord refuses localhost, IP addresses and internal names.

Sealcord retries for about 55 hours. See retries and disabled endpoints. While an endpoint is turned off, changes are not sent to it: read what changed from the API.