API reference
Two contours inside one process: the panel API for management and the client API for the embedded SDK. All examples run against http://localhost:5100.
Contours and rate limits
| Contour | Prefix | Purpose | Authentication | Rate limit |
|---|---|---|---|---|
| Panel API | /api/v1/panel/* | Management: operator and tenant | certified.auth cookie / PAT Bearer | 300 req/min per IP |
| Panel · auth | /api/v1/panel/auth/* | Login, 2FA, invitations, password resets | see below | 20 req/min per IP |
| Client API | /api/v1/client/* | The SDK embedded in the client application | public; mutations via HMAC | 100 req/min per IP |
Limits are fixed-window per IP. A rejected request answers 429 with the standard headers:
HTTP/1.1 429 Too Many RequestsRetry-After: 60RateLimit-Limit: 20RateLimit-Remaining: 0RateLimit-Reset: 60Authorization model
Panel: session cookie
POST /api/v1/panel/auth/login sets the certified.auth cookie (HttpOnly, SameSite=Lax, sliding 7 days). When the account has TOTP enabled the response carries a short-lived ticket instead — the session only opens after POST /auth/login/2fa.
| Role | Capabilities |
|---|---|
| Operator | Global scope: templates, webhooks, audit, root companies; bypasses every subtree restriction |
| Owner | Full rights on the company node and its entire subtree |
| Admin | Issue and revoke licenses, create child companies inside the subtree |
| Viewer | Read-only within the subtree |
Panel: personal access token (Bearer)
For machine integrations you can present a personal access token instead of a cookie: Authorization: Bearer cfed_pat_…. The token format is cfed_pat_ followed by base64url of 32 random bytes; the database stores only the SHA-256 hash and a short non-secret lookup prefix.
- A token is bound to a company and acts with Admin authority inside that subtree — it never grants global operator rights.
- A revoked (
revokedAt) or expired (expiresAt) token is rejected. - The secret is shown once, at creation; afterwards only metadata is available.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/panel/api-tokens | List tokens (secrets excluded) |
| POST | /api/v1/panel/api-tokens | Create; req { name, companyId, expiresInDays? } → { token, secret } |
| DELETE | /api/v1/panel/api-tokens/{id} | Revoke a token |
Client: HMAC signature
Client endpoints are public. activate uses the license key itself as the shared secret. heartbeat and deactivate require a signed request:
X-CertifiEd-Timestamp: <unix_seconds>X-CertifiEd-Signature: v1=<hex( HMAC_SHA256(key = licenseKey, msg = timestamp) )>A timestamp outside the ±5 minute window is rejected — that is the replay protection. The scheme is implemented by HmacRequestValidator; the SDK signs requests for you.
Error format
One JSON shape across every endpoint of both contours:
{ "code": "license.company_not_active", "message": "Company is Archived.", "status": 400}| `ErrorKind` | HTTP | When |
|---|---|---|
| Validation | 400 | Malformed request or a broken invariant |
| Unauthorized | 401 | No session / invalid or expired token |
| Forbidden | 403 | The role is insufficient for the scope |
| NotFound | 404 | Entity missing or outside the visible subtree |
| Conflict | 409 | State conflict (a taken slug, for example) |
| Failure | 500 | Internal error |
Panel · Auth
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/v1/panel/auth/login | Sign in; sets the certified.auth cookie | — |
| POST | /api/v1/panel/auth/login/2fa | Second step when TOTP is enabled | ticket |
| POST | /api/v1/panel/auth/logout | Sign out (204) | cookie |
| GET | /api/v1/panel/auth/me | The current user | cookie / PAT |
| GET | /api/v1/panel/auth/invite/{token} | Invitation preview before sign-up | — |
| POST | /api/v1/panel/auth/accept-invite | Redeem an invite: set a password and sign in | — |
| POST | /api/v1/panel/auth/forgot-password | Email a reset link (204) | — |
| POST | /api/v1/panel/auth/reset-password | Reset the password with a token (204) | — |
| POST | /api/v1/panel/auth/change-password | Change your own password (204) | cookie |
| POST | /api/v1/panel/auth/2fa/enroll | Start TOTP enrollment | cookie |
| POST | /api/v1/panel/auth/2fa/enable | Confirm with a code and switch on | cookie |
| POST | /api/v1/panel/auth/2fa/disable | Switch off: password + code (204) | cookie |
- login — req
{ email, password }→LoginResponse { twoFactorRequired, ticket, user }. WhentwoFactorRequiredis true,useris null and no cookie is issued. - login/2fa — req
{ ticket, code }→AuthUserResponse { id, email, displayName, isOperator }.codeis a live TOTP or an unused recovery code; the ticket lives 5 minutes. - invite/{token} —
InvitePreviewResponse { email, companyName, role, expiresAt }. accept-invite — req{ token, password }; the password needs 8+ characters and the invite token lives 7 days. - forgot-password — req
{ email }, always 204: address existence is never disclosed. reset-password — req{ token, newPassword }; change-password — req{ currentPassword, newPassword }. - 2fa/enroll →
{ secret, otpauthUri }(base32 secret and the QR URI). 2fa/enable — req{ code }→{ recoveryCodes[] }, shown once. 2fa/disable — req{ password, code }.
Panel · Companies
| Method | Path | Purpose | Min. role |
|---|---|---|---|
| GET | /api/v1/panel/companies | List visible companies | any authenticated (scoped) |
| GET | /{id} | A single company | Viewer |
| GET | /{id}/children | Direct descendants | Viewer |
| GET | /{id}/subtree | The whole subtree | Viewer |
| POST | / | Create a company (201) | Operator (root) / Admin on the parent |
| POST | /{id}/reparent | Move a node together with its subtree | Operator |
| DELETE | /{id} | Archive (204) | Owner |
- create — req
{ name, slug, parentId?, contactEmail? }. Aslugis 1–64 characters of[a-z0-9-], must not start or end with a hyphen, and is unique among siblings.parentId: nullcreates a root company — operators only. - reparent — req
{ newParentId }(nullmoves the node to the root) →CompanyResponse. Rebuilds the ltreepathanddepthfor the node and every descendant in one transaction. CompanyResponse { id, name, slug, parentId, path, depth, status, contactEmail, createdAt }.
Panel · Templates
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/v1/panel/templates | List templates | any authenticated |
| GET | /{id} | A single template | any authenticated |
| POST | / | Create a template (auto-generates the first signing key) | Operator |
| PUT | /{id} | Update | Operator |
| DELETE | /{id} | Archive (204) | Operator |
| GET | /{id}/versions | Template versions | any authenticated |
| POST | /{id}/versions | Create a version (becomes current) | Operator |
| GET | /{id}/signing-keys | Signing keys | Operator |
| POST | /{id}/signing-keys/rotate | Rotate the key | Operator |
| POST | /{id}/signing-keys/{keyId}/retire | Retire an inactive key (204) | Operator |
- create template — req
{ name, productCode, description?, defaultOfflineDays, defaultValidityDays }.productCodeis unique;defaultOfflineDaysis clamped to 1..30. - create version — req
{ configSchema, defaults?, changelog? };configSchemaanddefaultsare JSON strings. The version immediately becomescurrentVersionId. LicenseTemplateResponse { id, name, productCode, description, defaultOfflineDays, defaultValidityDays, status, currentVersionId, createdAt }.TemplateVersionResponse { id, templateId, version, configSchema, defaults, signingKeyId, changelog, createdAt }.SigningKeyResponse { id, status, notBefore, notAfter, createdAt, publicKeyHex }.
Panel · Licenses
| Method | Path | Purpose | Min. role |
|---|---|---|---|
| GET | /api/v1/panel/licenses | Search licenses (paged) | any authenticated (scoped) |
| GET | /{id} | A single license | Viewer |
| POST | / | Issue a license (201) | Admin on the company |
| POST | /bulk | Bulk issue (up to 200 items) | Admin on every company |
| POST | /{id}/revoke | Revoke (204) | Admin |
| POST | /{id}/transfer | Move to another company | Admin on both |
| POST | /{id}/rebind | Rebind to different hardware | Admin |
| POST | /{id}/activations/{activationId}/reset | Free a seat (204) | Admin |
| GET | /{id}/download | Download .ced (application/octet-stream) | Viewer |
| GET | /{id}/public-key | Public key (hex) | Viewer |
| GET | /{id}/activations | Activations of the license | Viewer |
| GET | /{id}/heartbeats | Latest heartbeats (?limit, 1..1000) | Viewer |
Search
GET / — query companyId?, templateId?, status? (Active / Revoked / Expired …), search?, page=1, pageSize=50 (max 200) → PagedResult<LicenseResponse> { items, total, page, pageSize }. An unknown status yields license.invalid_status (400); a company outside your scope yields auth.company_out_of_scope (403).
Issuing
{ "companyId": "019f7ef0-0ca2-73b1-8830-f016df1bb6d0", "templateId": "019f7ef0-0d1c-7207-9c20-21d14619ed4a", "config": "{\"features\":[\"export\"],\"limits\":{\"maxSeats\":25}}", "expiresAt": null, "offlineDays": 7, "isTrial": false, "hwBinding": "none", "hwFingerprint": null}configis a JSON string (features / limits) embedded into the signed token.expiresAt: null→now + template.defaultValidityDays, and withisTrial: true→now + Licensing:DefaultTrialDays(14 days by default).offlineDaysis clamped to1..Licensing:MaxOfflineDays(30 by default).hwBindingisnone/fixed/firstActivation— see Hardware binding. When the field is omitted the mode is inferred fromhwFingerprint: present →fixed, absent →none.- Response
LicenseIssueResponse { id, licenseKey, token, publicKey },publicKeyin hex.
Bulk issuance
POST /bulk — req { templateId, items: [{ companyId, config? }], config?, expiresAt?, offlineDays?, hwFingerprint?, isTrial?, hwBinding? }. An item-level config overrides the batch-wide one; the remaining fields apply to every item. At most 200 items (license.bulk_too_large); an empty list yields license.bulk_empty.
A failing item (missing rights, archived company, malformed config) does not abort the batch — the error is reported in that item's row. Successful licenses are persisted in a single transaction.
{ "requested": 2, "succeeded": 2, "failed": 0, "results": [ { "companyId": "019f7ef0-0ca2-73b1-8830-f016df1bb6d0", "licenseId": "019f7ef0-48f7-7e69-afda-dbe061cd6da8", "licenseKey": "CFED-N2XH-SMPJ-VCF2-G2GB", "errorCode": null, "errorMessage": null }, { "companyId": "019f7ef0-0ce1-708d-9621-f89c477b7faf", "licenseId": "019f7ef0-48fc-74f7-9bd9-cf15478c5ae4", "licenseKey": "CFED-RY8S-S4F8-Y9ZJ-UDBZ", "errorCode": null, "errorMessage": null } ]}Transfer
POST /{id}/transfer — req { newCompanyId } → LicenseTransferResponse { licenseId, licenseKey, companyId, currentVersion, token, publicKey, deactivatedActivations }.
The licensee (sub) is baked into the signed token, so a transfer signs a new license version (same license key, currentVersion + 1, config preserved) and deactivates every active activation, freeing their seats. The customer must download the `.ced` again. Requires Admin on both the source and the target company.
Hardware rebinding
POST /{id}/rebind — req { hwFingerprint, reason?, hwBinding? } → LicenseResponse.
hwFingerprint: "<new fingerprint>"pins the license to the new machine (the mode becomesfixed).hwFingerprint: nullreleases the binding:fixedfalls back tofirstActivation(it re-pins on the next activation),nonestaysnone.hwBindingforces the resulting mode instead of the inferred one.
Every active activation is deactivated and the license is re-signed as a new version — the customer must download the .ced again. Requires Admin on the license's company (operators always qualify). Webhook event — license.rebound. Details in Hardware binding.
Everything else, and the models
- reset activation —
POST /{id}/activations/{activationId}/reset→ 204. Deactivates a single activation, freeing its seat (floating seats). Idempotent; an activation belonging to another license yieldsactivation.not_found(404). - download — the
.cedbody:{ licenseKey, token, publicKey }; file name{licenseKey}.ced. - public-key —
LicensePublicKeyResponse { publicKey }(hex). LicenseResponse { id, licenseKey, companyId, templateId, status, config, offlineDays, currentVersion, expiresAt, issuedAt, activatedAt, lastHeartbeatAt, revokedAt, revocationReason, isTrial, hwBinding, hwFingerprint }.ActivationResponse { id, hwFingerprint, machineName, status, activatedAt, lastHeartbeatAt, deactivatedAt }.HeartbeatRecordResponse { id, activationId, occurredAt }.
Panel · Users
| Method | Path | Purpose | Min. role |
|---|---|---|---|
| GET | /api/v1/panel/users?companyId=… | Users of a company and its subtree | Admin |
| POST | /invite | Invite a user into a company | Admin (Owner to grant the Owner role) |
| POST | /{id}/resend-invite | Re-send the invitation (204) | Admin |
| DELETE | /{id} | Deactivate the account (204) | Admin (Operator for an operator) |
- invite — req
{ email, companyId, role, displayName? };roleis one ofowner/admin/viewer. Creates apendingaccount and emails an/accept-invite?token=…link that lives 7 days. UserResponse { id, email, displayName, isOperator, totpEnabled, status, companyId, role, lastLoginAt, createdAt };statusispending/active/revoked.
Panel · Operator
The whole group is operator-only — anyone else receives auth.operator_required (403).
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/panel/operator/overview | Platform-wide counters |
| GET | /tenants | Root companies with their subtree size (paged, ?search) |
| GET | /licenses | Every license on the platform (paged, ?status, ?templateId, ?companyId, ?trial) |
| GET | /users | Every operator account |
| POST | /users/invite | Invite a new operator |
| POST | /users/{id}/promote | Grant operator rights to an existing user |
| POST | /users/{id}/demote | Revoke operator rights |
- overview —
OperatorOverviewResponse { totalCompanies, rootCompanies, licenses { active, suspended, revoked, expired, total }, trialLicenses, activeActivations, licensesExpiringIn30Days, templates, signingKeys { … }, webhookEndpoints, deliveryHealthLast24h { pending, failed, success } }. - tenants —
OperatorTenantResponse { id, name, slug, status, contactEmail, descendantCount, licenseCount, createdAt }. - licenses —
OperatorLicenseResponse { id, licenseKey, companyId, companyName, templateId, productCode, status, isTrial, issuedAt, expiresAt, activatedAt, revokedAt }. - users —
OperatorUserResponse { id, email, displayName, status, totpEnabled, lastLoginAt, createdAt };statusispending/active/revoked. - users/invite — req
{ email, displayName? }→OperatorUserResponsewithstatus: "pending". If the user already exists and is active you getuser.already_exists(409): promote them instead of inviting.
Panel · Analytics
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/v1/panel/analytics/usage | Daily activation / heartbeat / issuance series | any authenticated (scoped) |
| GET | /api/v1/panel/analytics/distribution | Cross-sections by template and company | any authenticated (scoped) |
- Scope: an operator without
companyIdsees the whole platform; everyone else sees the union of their visible subtrees. AcompanyIdnarrows results to that company's subtree (requires Viewer on it). - usage — query
from?,to?,companyId?. The default window is the last 30 days, the maximum is 366 days (analytics.range_too_large;from > toyieldsanalytics.invalid_range). - distribution — query
companyId?→{ licensesByTemplate[], activationsByTemplate[], topCompaniesByActivations[] }; the first two are{ templateId, productCode, templateName, count }, the third is{ companyId, companyName, count }(top 10).
{ "from": "2026-06-20T09:53:33.90+00:00", "to": "2026-07-20T09:53:33.90+00:00", "activations": [ { "date": "2026-07-20", "count": 3 } ], "heartbeats": [], "licensesIssued": [ { "date": "2026-07-20", "count": 4 } ]}Panel · Webhooks
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/v1/panel/webhooks | List endpoints | Operator |
| GET | /{id} | A single endpoint | Operator |
| POST | / | Create (201) | Operator |
| PATCH | /{id} | Update | Operator |
| DELETE | /{id} | Delete (204) | Operator |
| GET | /{id}/deliveries | Delivery history (?limit, 1..200) | Operator |
| POST | /deliveries/{deliveryId}/replay | Re-send (204) | Operator |
| GET | /event-types | Catalogue of event types | any authenticated |
- create — req
{ url, secret, description, events[] }.urlis an absolute URI;secretis at least 16 characters (it is the HMAC key for deliveries);eventscome fromevent-types. - update — req
{ url?, secret?, description?, isActive?, events? }. WebhookEndpointResponse { id, url, description, isActive, events, lastDeliveryAt, createdAt }.WebhookDeliveryResponse { id, endpointId, eventType, payload, status, attemptCount, responseStatusCode, responseBody, lastError, nextAttemptAt, lastAttemptAt, createdAt }.- The event catalogue (
GET /event-types):license.issued,license.revoked,license.expired,license.activated,license.deactivated,license.heartbeat_missed,license.transferred, `license.rebound`.
The delivery format and signature verification live in the Webhooks section.
Panel · API tokens
| Method | Path | Purpose | Min. role |
|---|---|---|---|
| GET | /api/v1/panel/api-tokens | Tokens inside the visible subtree (no secrets) | any authenticated (scoped) |
| POST | / | Create a token | Admin on the target company |
| DELETE | /{id} | Revoke (204) | Admin |
- create — req
{ name, companyId, expiresInDays? }(1..3650) →CreatedApiTokenResponse { token, secret }. The `secret` is shown once; the database keeps only the SHA-256 hash and a non-secretprefix. ApiTokenResponse { id, name, companyId, prefix, createdAt, lastUsedAt, expiresAt, revokedAt }.- Codes:
api_token.name_required,api_token.invalid_expiry(400);api_token.not_found(404).
Panel · Audit
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/v1/panel/audit | Query the audit log | Operator |
| GET | /api/v1/panel/audit/export | Download the log as a file (CSV / JSON) | Operator |
- GET / — query
action?,actorEmail?,entityType?,entityId?,from?,until?,limit=100(max 500),offset=0. - GET /export — the same filters minus
limit/offset, plusformat=csv|json(csvby default). The response is streamed as an attachment,Content-Disposition: attachment; filename="audit-YYYYMMDD.csv", with no pagination. CSV uses the headertimestamp,actor,action,entityType,entityId,metaand RFC 4180 escaping. An unknown format yieldsaudit.invalid_format(400). - The export itself is recorded in the log as the
audit.exportedaction. AuditLogResponse { id, action, actorEmail, entityType, entityId, meta, ipAddress, occurredAt }.
Client API
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/v1/client/activate | Activate a machine | license key in the body |
| POST | /api/v1/client/heartbeat | Check-in + a fresh offline marker | HMAC |
| POST | /api/v1/client/deactivate | Drop an activation (204) | HMAC |
| GET | /api/v1/client/public-key/{licenseKey} | Public key by license key | — |
- activate — req
{ licenseKey, hwFingerprint?, machineName? }→ActivateResponse { activationId, heartbeatToken, heartbeatIntervalSeconds, maxOfflineDays }. Re-activating from the same machine (the samehwFingerprint) reuses the existing activation and does not consume a second seat. - heartbeat — req
{ licenseKey, activationId }plus the HMAC headers →HeartbeatResponse { heartbeatToken }(a fresh signed offline marker). - deactivate — req
{ licenseKey, activationId }plus the HMAC headers → 204. Frees the seat (floating seats) — the SDK does this inDisposeAsync. Idempotent. - public-key —
ClientPublicKeyResponse { publicKey }(hex).
The heartbeatToken returned by activate / heartbeat is the offline marker shaped as base64url(payload).base64url(signature) — see the License protocol. The SDK caches it as <license>.ced.hb.
Hardware binding and seats
Activation checks two independent things. The first is the binding mode license.hwBinding:
| Mode | Behaviour on activation |
|---|---|
none | Any machine; hwFingerprint is optional |
fixed | Only the machine whose fingerprint was set at issuance or on a rebind |
firstActivation | The first activation must send a hwFingerprint and pins it on the license; from then on it behaves like fixed |
Codes: activation.fingerprint_mismatch (400, wrong machine), activation.fingerprint_required (400, firstActivation with no fingerprint), activation.not_bound (409, a fixed license whose fingerprint was released — it awaits a rebind). Details in Hardware binding.
The second is the seat limit limits.maxSeats inside the signed config. A positive integer caps the number of active activations; a missing field, zero or a non-numeric value means no limit. Only a brand-new activation consumes a seat — re-activating the same fingerprint does not. Seats are freed by deactivate (client), an activation reset, transfer and rebind (panel).