CertifiEd

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

ContourPrefixPurposeAuthenticationRate limit
Panel API/api/v1/panel/*Management: operator and tenantcertified.auth cookie / PAT Bearer300 req/min per IP
Panel · auth/api/v1/panel/auth/*Login, 2FA, invitations, password resetssee below20 req/min per IP
Client API/api/v1/client/*The SDK embedded in the client applicationpublic; mutations via HMAC100 req/min per IP

Limits are fixed-window per IP. A rejected request answers 429 with the standard headers:

text
HTTP/1.1 429 Too Many RequestsRetry-After: 60RateLimit-Limit: 20RateLimit-Remaining: 0RateLimit-Reset: 60

Authorization model

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.

RoleCapabilities
OperatorGlobal scope: templates, webhooks, audit, root companies; bypasses every subtree restriction
OwnerFull rights on the company node and its entire subtree
AdminIssue and revoke licenses, create child companies inside the subtree
ViewerRead-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.
MethodPathPurpose
GET/api/v1/panel/api-tokensList tokens (secrets excluded)
POST/api/v1/panel/api-tokensCreate; 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:

text
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:

JSON
{  "code": "license.company_not_active",  "message": "Company is Archived.",  "status": 400}
`ErrorKind`HTTPWhen
Validation400Malformed request or a broken invariant
Unauthorized401No session / invalid or expired token
Forbidden403The role is insufficient for the scope
NotFound404Entity missing or outside the visible subtree
Conflict409State conflict (a taken slug, for example)
Failure500Internal error

Panel · Auth

MethodPathPurposeAuth
POST/api/v1/panel/auth/loginSign in; sets the certified.auth cookie
POST/api/v1/panel/auth/login/2faSecond step when TOTP is enabledticket
POST/api/v1/panel/auth/logoutSign out (204)cookie
GET/api/v1/panel/auth/meThe current usercookie / PAT
GET/api/v1/panel/auth/invite/{token}Invitation preview before sign-up
POST/api/v1/panel/auth/accept-inviteRedeem an invite: set a password and sign in
POST/api/v1/panel/auth/forgot-passwordEmail a reset link (204)
POST/api/v1/panel/auth/reset-passwordReset the password with a token (204)
POST/api/v1/panel/auth/change-passwordChange your own password (204)cookie
POST/api/v1/panel/auth/2fa/enrollStart TOTP enrollmentcookie
POST/api/v1/panel/auth/2fa/enableConfirm with a code and switch oncookie
POST/api/v1/panel/auth/2fa/disableSwitch off: password + code (204)cookie
  • login — req { email, password }LoginResponse { twoFactorRequired, ticket, user }. When twoFactorRequired is true, user is null and no cookie is issued.
  • login/2fa — req { ticket, code }AuthUserResponse { id, email, displayName, isOperator }. code is 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

MethodPathPurposeMin. role
GET/api/v1/panel/companiesList visible companiesany authenticated (scoped)
GET/{id}A single companyViewer
GET/{id}/childrenDirect descendantsViewer
GET/{id}/subtreeThe whole subtreeViewer
POST/Create a company (201)Operator (root) / Admin on the parent
POST/{id}/reparentMove a node together with its subtreeOperator
DELETE/{id}Archive (204)Owner
  • create — req { name, slug, parentId?, contactEmail? }. A slug is 1–64 characters of [a-z0-9-], must not start or end with a hyphen, and is unique among siblings. parentId: null creates a root company — operators only.
  • reparent — req { newParentId } (null moves the node to the root) → CompanyResponse. Rebuilds the ltree path and depth for the node and every descendant in one transaction.
  • CompanyResponse { id, name, slug, parentId, path, depth, status, contactEmail, createdAt }.

Panel · Templates

MethodPathPurposeAuth
GET/api/v1/panel/templatesList templatesany authenticated
GET/{id}A single templateany authenticated
POST/Create a template (auto-generates the first signing key)Operator
PUT/{id}UpdateOperator
DELETE/{id}Archive (204)Operator
GET/{id}/versionsTemplate versionsany authenticated
POST/{id}/versionsCreate a version (becomes current)Operator
GET/{id}/signing-keysSigning keysOperator
POST/{id}/signing-keys/rotateRotate the keyOperator
POST/{id}/signing-keys/{keyId}/retireRetire an inactive key (204)Operator
  • create template — req { name, productCode, description?, defaultOfflineDays, defaultValidityDays }. productCode is unique; defaultOfflineDays is clamped to 1..30.
  • create version — req { configSchema, defaults?, changelog? }; configSchema and defaults are JSON strings. The version immediately becomes currentVersionId.
  • 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

MethodPathPurposeMin. role
GET/api/v1/panel/licensesSearch licenses (paged)any authenticated (scoped)
GET/{id}A single licenseViewer
POST/Issue a license (201)Admin on the company
POST/bulkBulk issue (up to 200 items)Admin on every company
POST/{id}/revokeRevoke (204)Admin
POST/{id}/transferMove to another companyAdmin on both
POST/{id}/rebindRebind to different hardwareAdmin
POST/{id}/activations/{activationId}/resetFree a seat (204)Admin
GET/{id}/downloadDownload .ced (application/octet-stream)Viewer
GET/{id}/public-keyPublic key (hex)Viewer
GET/{id}/activationsActivations of the licenseViewer
GET/{id}/heartbeatsLatest 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

JSON
{  "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}
  • config is a JSON string (features / limits) embedded into the signed token.
  • expiresAt: nullnow + template.defaultValidityDays, and with isTrial: truenow + Licensing:DefaultTrialDays (14 days by default).
  • offlineDays is clamped to 1..Licensing:MaxOfflineDays (30 by default).
  • hwBinding is none / fixed / firstActivation — see Hardware binding. When the field is omitted the mode is inferred from hwFingerprint: present → fixed, absent → none.
  • Response LicenseIssueResponse { id, licenseKey, token, publicKey }, publicKey in 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.

JSON
{  "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 becomes fixed).
  • hwFingerprint: null releases the binding: fixed falls back to firstActivation (it re-pins on the next activation), none stays none.
  • hwBinding forces 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 activationPOST /{id}/activations/{activationId}/reset → 204. Deactivates a single activation, freeing its seat (floating seats). Idempotent; an activation belonging to another license yields activation.not_found (404).
  • download — the .ced body: { licenseKey, token, publicKey }; file name {licenseKey}.ced.
  • public-keyLicensePublicKeyResponse { 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

MethodPathPurposeMin. role
GET/api/v1/panel/users?companyId=…Users of a company and its subtreeAdmin
POST/inviteInvite a user into a companyAdmin (Owner to grant the Owner role)
POST/{id}/resend-inviteRe-send the invitation (204)Admin
DELETE/{id}Deactivate the account (204)Admin (Operator for an operator)
  • invite — req { email, companyId, role, displayName? }; role is one of owner / admin / viewer. Creates a pending account and emails an /accept-invite?token=… link that lives 7 days.
  • UserResponse { id, email, displayName, isOperator, totpEnabled, status, companyId, role, lastLoginAt, createdAt }; status is pending / active / revoked.

Panel · Operator

The whole group is operator-only — anyone else receives auth.operator_required (403).

MethodPathPurpose
GET/api/v1/panel/operator/overviewPlatform-wide counters
GET/tenantsRoot companies with their subtree size (paged, ?search)
GET/licensesEvery license on the platform (paged, ?status, ?templateId, ?companyId, ?trial)
GET/usersEvery operator account
POST/users/inviteInvite a new operator
POST/users/{id}/promoteGrant operator rights to an existing user
POST/users/{id}/demoteRevoke operator rights
  • overviewOperatorOverviewResponse { totalCompanies, rootCompanies, licenses { active, suspended, revoked, expired, total }, trialLicenses, activeActivations, licensesExpiringIn30Days, templates, signingKeys { … }, webhookEndpoints, deliveryHealthLast24h { pending, failed, success } }.
  • tenantsOperatorTenantResponse { id, name, slug, status, contactEmail, descendantCount, licenseCount, createdAt }.
  • licensesOperatorLicenseResponse { id, licenseKey, companyId, companyName, templateId, productCode, status, isTrial, issuedAt, expiresAt, activatedAt, revokedAt }.
  • usersOperatorUserResponse { id, email, displayName, status, totpEnabled, lastLoginAt, createdAt }; status is pending / active / revoked.
  • users/invite — req { email, displayName? }OperatorUserResponse with status: "pending". If the user already exists and is active you get user.already_exists (409): promote them instead of inviting.

Panel · Analytics

MethodPathPurposeAuth
GET/api/v1/panel/analytics/usageDaily activation / heartbeat / issuance seriesany authenticated (scoped)
GET/api/v1/panel/analytics/distributionCross-sections by template and companyany authenticated (scoped)
  • Scope: an operator without companyId sees the whole platform; everyone else sees the union of their visible subtrees. A companyId narrows 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 > to yields analytics.invalid_range).
  • distribution — query companyId?{ licensesByTemplate[], activationsByTemplate[], topCompaniesByActivations[] }; the first two are { templateId, productCode, templateName, count }, the third is { companyId, companyName, count } (top 10).
GET /analytics/usage
{  "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

MethodPathPurposeAuth
GET/api/v1/panel/webhooksList endpointsOperator
GET/{id}A single endpointOperator
POST/Create (201)Operator
PATCH/{id}UpdateOperator
DELETE/{id}Delete (204)Operator
GET/{id}/deliveriesDelivery history (?limit, 1..200)Operator
POST/deliveries/{deliveryId}/replayRe-send (204)Operator
GET/event-typesCatalogue of event typesany authenticated
  • create — req { url, secret, description, events[] }. url is an absolute URI; secret is at least 16 characters (it is the HMAC key for deliveries); events come from event-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

MethodPathPurposeMin. role
GET/api/v1/panel/api-tokensTokens inside the visible subtree (no secrets)any authenticated (scoped)
POST/Create a tokenAdmin 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-secret prefix.
  • 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

MethodPathPurposeAuth
GET/api/v1/panel/auditQuery the audit logOperator
GET/api/v1/panel/audit/exportDownload 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, plus format=csv|json (csv by default). The response is streamed as an attachment, Content-Disposition: attachment; filename="audit-YYYYMMDD.csv", with no pagination. CSV uses the header timestamp,actor,action,entityType,entityId,meta and RFC 4180 escaping. An unknown format yields audit.invalid_format (400).
  • The export itself is recorded in the log as the audit.exported action.
  • AuditLogResponse { id, action, actorEmail, entityType, entityId, meta, ipAddress, occurredAt }.

Client API

MethodPathPurposeAuth
POST/api/v1/client/activateActivate a machinelicense key in the body
POST/api/v1/client/heartbeatCheck-in + a fresh offline markerHMAC
POST/api/v1/client/deactivateDrop 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 same hwFingerprint) 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 in DisposeAsync. Idempotent.
  • public-keyClientPublicKeyResponse { 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:

ModeBehaviour on activation
noneAny machine; hwFingerprint is optional
fixedOnly the machine whose fingerprint was set at issuance or on a rebind
firstActivationThe 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).