CertifiEd

API Reference

Два контура в одном процессе: panel API для управления и client API для встроенного SDK. Все примеры — против http://localhost:5100.

Контуры и rate limits

КонтурПрефиксНазначениеАутентификацияRate limit
Panel API/api/v1/panel/*Управление: оператор и тенантcookie certified.auth / PAT Bearer300 req/мин на IP
Panel · auth/api/v1/panel/auth/*Логин, 2FA, приглашения, сброс паролясм. ниже20 req/мин на IP
Client API/api/v1/client/*Встроенный SDK клиентского приложенияпубличный; мутации по HMAC100 req/мин на IP

Лимиты — fixed-window по IP. При отказе ответ 429 несёт стандартные заголовки:

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

Модель авторизации

Логин POST /api/v1/panel/auth/login выставляет cookie certified.auth (HttpOnly, SameSite=Lax, sliding 7 дней). Если у аккаунта включён TOTP, вместо cookie возвращается короткоживущий ticket — сессия открывается только после POST /auth/login/2fa.

РольЧто может
OperatorГлобальный скоуп: шаблоны, webhooks, аудит, корневые компании; обходит все ограничения по поддереву
OwnerПолные права на узле компании и всём его поддереве
AdminВыпуск и отзыв лицензий, создание дочерних компаний в пределах поддерева
ViewerТолько чтение в пределах поддерева

Panel: personal access token (Bearer)

Для машинной интеграции вместо cookie можно предъявить персональный токен в заголовке Authorization: Bearer cfed_pat_…. Формат токена — cfed_pat_ + base64url(32 случайных байта); в базе хранится только SHA-256-хеш и короткий несекретный префикс для поиска.

  • Токен привязан к компании и действует с правами Admin внутри её поддерева — глобальных операторских прав он не даёт.
  • Отозванный (revokedAt) или просроченный (expiresAt) токен отклоняется.
  • Секрет показывается один раз — при создании; далее доступны только метаданные.
МетодПутьНазначение
GET/api/v1/panel/api-tokensСписок токенов (без секретов)
POST/api/v1/panel/api-tokensСоздать; req { name, companyId, expiresInDays? }{ token, secret }
DELETE/api/v1/panel/api-tokens/{id}Отозвать токен

Client: HMAC-подпись

Client-эндпоинты публичны. activate использует сам license key как общий секрет. heartbeat и deactivate требуют подписи запроса:

text
X-CertifiEd-Timestamp: <unix_seconds>X-CertifiEd-Signature: v1=<hex( HMAC_SHA256(key = licenseKey, msg = timestamp) )>

Timestamp вне окна ±5 минут отклоняется — это защита от replay. Схему реализует HmacRequestValidator; SDK делает подпись самостоятельно.

Формат ошибок

Единый JSON во всех эндпоинтах обоих контуров:

JSON
{  "code": "license.company_not_active",  "message": "Company is Archived.",  "status": 400}
`ErrorKind`HTTPКогда
Validation400Некорректный запрос, нарушение инварианта
Unauthorized401Нет сессии / неверный или просроченный токен
Forbidden403Роли недостаточно для скоупа
NotFound404Сущность не найдена или вне видимого поддерева
Conflict409Конфликт состояния (например, занятый slug)
Failure500Внутренняя ошибка

Panel · Auth

МетодПутьНазначениеAuth
POST/api/v1/panel/auth/loginВход; ставит cookie certified.auth
POST/api/v1/panel/auth/login/2faВторой шаг при включённом TOTPticket
POST/api/v1/panel/auth/logoutВыход (204)cookie
GET/api/v1/panel/auth/meТекущий пользовательcookie / PAT
GET/api/v1/panel/auth/invite/{token}Превью приглашения перед регистрацией
POST/api/v1/panel/auth/accept-inviteПринять приглашение: задать пароль и войти
POST/api/v1/panel/auth/forgot-passwordПисьмо со ссылкой на сброс (204)
POST/api/v1/panel/auth/reset-passwordСброс пароля по токену (204)
POST/api/v1/panel/auth/change-passwordСмена своего пароля (204)cookie
POST/api/v1/panel/auth/2fa/enrollНачать enrollment TOTPcookie
POST/api/v1/panel/auth/2fa/enableПодтвердить кодом и включитьcookie
POST/api/v1/panel/auth/2fa/disableВыключить: пароль + код (204)cookie
  • login — req { email, password }LoginResponse { twoFactorRequired, ticket, user }. При twoFactorRequired: true поле user пустое и cookie не выставляется.
  • login/2fa — req { ticket, code }AuthUserResponse { id, email, displayName, isOperator }. code — живой TOTP либо неиспользованный recovery-код; ticket живёт 5 минут.
  • invite/{token}InvitePreviewResponse { email, companyName, role, expiresAt }. accept-invite — req { token, password }; пароль от 8 символов, токен приглашения живёт 7 дней.
  • forgot-password — req { email }, всегда 204: существование адреса не раскрывается. reset-password — req { token, newPassword }; change-password — req { currentPassword, newPassword }.
  • 2fa/enroll{ secret, otpauthUri } (base32-секрет и URI для QR). 2fa/enable — req { code }{ recoveryCodes[] }, показываются один раз. 2fa/disable — req { password, code }.

Panel · Companies

МетодПутьНазначениеМин. роль
GET/api/v1/panel/companiesСписок видимых компанийлюбой аутентифицированный (scoped)
GET/{id}Одна компанияViewer
GET/{id}/childrenПрямые потомкиViewer
GET/{id}/subtreeВсё поддеревоViewer
POST/Создать компанию (201)Operator (корень) / Admin на родителе
POST/{id}/reparentПеренести узел вместе с поддеревомOperator
DELETE/{id}Архивировать (204)Owner
  • create — req { name, slug, parentId?, contactEmail? }. slug — 1–64 символа [a-z0-9-], не начинается и не заканчивается дефисом, уникален среди сиблингов. parentId: null создаёт корневую компанию — только Operator.
  • reparent — req { newParentId } (null — вынести в корень) → CompanyResponse. Перестраивает ltree-path и depth для узла и всех потомков в одной транзакции.
  • CompanyResponse { id, name, slug, parentId, path, depth, status, contactEmail, createdAt }.

Panel · Templates

МетодПутьНазначениеAuth
GET/api/v1/panel/templatesСписок шаблоновлюбой аутентифицированный
GET/{id}Один шаблонлюбой аутентифицированный
POST/Создать шаблон (авто-генерит первый signing key)Operator
PUT/{id}ОбновитьOperator
DELETE/{id}Архивировать (204)Operator
GET/{id}/versionsВерсии шаблоналюбой аутентифицированный
POST/{id}/versionsСоздать версию (станет current)Operator
GET/{id}/signing-keysКлючи подписиOperator
POST/{id}/signing-keys/rotateРотация ключаOperator
POST/{id}/signing-keys/{keyId}/retireВывести неактивный ключ (204)Operator
  • create template — req { name, productCode, description?, defaultOfflineDays, defaultValidityDays }. productCode уникален; defaultOfflineDays зажимается в диапазон 1..30.
  • create version — req { configSchema, defaults?, changelog? }; configSchema и defaultsстроки JSON. Версия сразу становится 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

МетодПутьНазначениеМин. роль
GET/api/v1/panel/licensesПоиск лицензий (paged)любой аутентифицированный (scoped)
GET/{id}Одна лицензияViewer
POST/Выпустить лицензию (201)Admin на компании
POST/bulkМассовый выпуск (до 200 шт.)Admin на каждой компании
POST/{id}/revokeОтозвать (204)Admin
POST/{id}/transferПеренести в другую компаниюAdmin на обеих
POST/{id}/rebindПерепривязать к другому железуAdmin
POST/{id}/activations/{activationId}/resetОсвободить место (204)Admin
GET/{id}/downloadСкачать .ced (application/octet-stream)Viewer
GET/{id}/public-keyПубличный ключ (hex)Viewer
GET/{id}/activationsАктивации лицензииViewer
GET/{id}/heartbeatsПоследние heartbeat'ы (?limit, 1..1000)Viewer

Поиск

GET / — query companyId?, templateId?, status? (Active / Revoked / Expired …), search?, page=1, pageSize=50 (максимум 200) → PagedResult<LicenseResponse> { items, total, page, pageSize }. Неизвестный статус — license.invalid_status (400), компания вне скоупа — auth.company_out_of_scope (403).

Выпуск

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строка JSON (features / limits), встраивается в подписанный токен.
  • expiresAt: nullnow + template.defaultValidityDays, а при isTrial: truenow + Licensing:DefaultTrialDays (по умолчанию 14 дней).
  • offlineDays зажимается в 1..Licensing:MaxOfflineDays (по умолчанию 30).
  • hwBindingnone / fixed / firstActivation, см. Привязка к железу. Если поле опущено, режим выводится из hwFingerprint: есть — fixed, нет — none.
  • Ответ LicenseIssueResponse { id, licenseKey, token, publicKey }, publicKey — hex.

Массовый выпуск

POST /bulk — req { templateId, items: [{ companyId, config? }], config?, expiresAt?, offlineDays?, hwFingerprint?, isTrial?, hwBinding? }. Item-level config перекрывает батчевый, остальные поля общие. Максимум 200 элементов (license.bulk_too_large), пустой список — license.bulk_empty.

Ошибка отдельного элемента (нет прав, компания архивирована, битый config) не роняет батч — она возвращается в его строке результата. Успешные лицензии сохраняются одной транзакцией.

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    }  ]}

Перенос

POST /{id}/transfer — req { newCompanyId }LicenseTransferResponse { licenseId, licenseKey, companyId, currentVersion, token, publicKey, deactivatedActivations }.

Лицензиат (sub) зашит в подписанный токен, поэтому перенос подписывает новую версию лицензии (тот же license key, currentVersion + 1, config сохраняется) и деактивирует все активные активации — их места освобождаются. Клиенту нужно заново скачать `.ced`. Требует Admin на исходной и целевой компании.

Перепривязка к оборудованию

POST /{id}/rebind — req { hwFingerprint, reason?, hwBinding? }LicenseResponse.

  • hwFingerprint: "<новый отпечаток>" — закрепить за новой машиной (режим становится fixed).
  • hwFingerprint: null — снять привязку: fixed откатывается в firstActivation (закрепится на следующей активации), none остаётся none.
  • hwBinding явно задаёт итоговый режим вместо выводимого.

Все активные активации деактивируются, лицензия переподписывается новой версией — клиенту нужно заново скачать .ced. Требует Admin на компании лицензии (оператор проходит всегда). Событие вебхука — license.rebound. Подробно — Привязка к железу.

Прочее и модели

  • reset activationPOST /{id}/activations/{activationId}/reset → 204. Точечно деактивирует одну активацию, освобождая место (floating-seat). Повторный вызов идемпотентен, чужая активация — activation.not_found (404).
  • download — тело .ced: { licenseKey, token, publicKey }; имя файла {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

МетодПутьНазначениеМин. роль
GET/api/v1/panel/users?companyId=…Пользователи компании и её поддереваAdmin
POST/inviteПригласить пользователя в компаниюAdmin (Owner — чтобы выдать роль Owner)
POST/{id}/resend-inviteПереотправить приглашение (204)Admin
DELETE/{id}Деактивировать аккаунт (204)Admin (Operator — для оператора)
  • invite — req { email, companyId, role, displayName? }; roleowner / admin / viewer. Создаёт pending-аккаунт и шлёт письмо со ссылкой /accept-invite?token=… (живёт 7 дней).
  • UserResponse { id, email, displayName, isOperator, totpEnabled, status, companyId, role, lastLoginAt, createdAt }; statuspending / active / revoked.

Panel · Operator

Вся группа доступна только оператору — иначе auth.operator_required (403).

МетодПутьНазначение
GET/api/v1/panel/operator/overviewПлатформенные счётчики
GET/tenantsКорневые компании с размером поддерева (paged, ?search)
GET/licensesВсе лицензии платформы (paged, ?status, ?templateId, ?companyId, ?trial)
GET/usersВсе операторские аккаунты
POST/users/inviteПригласить нового оператора
POST/users/{id}/promoteВыдать операторские права существующему пользователю
POST/users/{id}/demoteСнять операторские права
  • 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 }; statuspending / active / revoked.
  • users/invite — req { email, displayName? }OperatorUserResponse со status: "pending". Если пользователь уже существует и активен — user.already_exists (409): его нужно promote, а не приглашать.

Panel · Analytics

МетодПутьНазначениеAuth
GET/api/v1/panel/analytics/usageДневные ряды активаций / heartbeat'ов / выпусковлюбой аутентифицированный (scoped)
GET/api/v1/panel/analytics/distributionСрезы по шаблонам и компаниямлюбой аутентифицированный (scoped)
  • Скоуп: оператор без companyId видит всю платформу, остальные — объединение видимых поддеревьев. companyId сужает до поддерева этой компании (нужен Viewer на ней).
  • usage — query from?, to?, companyId?. Окно по умолчанию — последние 30 дней, максимум 366 дней (analytics.range_too_large, а при from > toanalytics.invalid_range).
  • distribution — query companyId?{ licensesByTemplate[], activationsByTemplate[], topCompaniesByActivations[] }; первые два — { templateId, productCode, templateName, count }, третий — { companyId, companyName, count } (топ-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

МетодПутьНазначениеAuth
GET/api/v1/panel/webhooksСписок эндпоинтовOperator
GET/{id}Один эндпоинтOperator
POST/Создать (201)Operator
PATCH/{id}ОбновитьOperator
DELETE/{id}Удалить (204)Operator
GET/{id}/deliveriesИстория доставок (?limit, 1..200)Operator
POST/deliveries/{deliveryId}/replayПереотправить (204)Operator
GET/event-typesКаталог типов событийлюбой аутентифицированный
  • create — req { url, secret, description, events[] }. url — абсолютный URI; secret не короче 16 символов (это ключ HMAC-подписи доставок); events — значения из 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 }.
  • Каталог событий (GET /event-types): license.issued, license.revoked, license.expired, license.activated, license.deactivated, license.heartbeat_missed, license.transferred, `license.rebound`.

Формат доставки и проверка подписи — в разделе Webhooks.

Panel · API tokens

МетодПутьНазначениеМин. роль
GET/api/v1/panel/api-tokensТокены в видимом поддереве (без секретов)любой аутентифицированный (scoped)
POST/Создать токенAdmin на целевой компании
DELETE/{id}Отозвать (204)Admin
  • create — req { name, companyId, expiresInDays? } (1..3650) → CreatedApiTokenResponse { token, secret }. `secret` показывается один раз; в базе только SHA-256-хеш и несекретный prefix.
  • ApiTokenResponse { id, name, companyId, prefix, createdAt, lastUsedAt, expiresAt, revokedAt }.
  • Коды: api_token.name_required, api_token.invalid_expiry (400); api_token.not_found (404).

Panel · Audit

МетодПутьНазначениеAuth
GET/api/v1/panel/auditЗапрос журнала аудитаOperator
GET/api/v1/panel/audit/exportВыгрузка журнала файлом (CSV / JSON)Operator
  • GET / — query action?, actorEmail?, entityType?, entityId?, from?, until?, limit=100 (максимум 500), offset=0.
  • GET /export — те же фильтры без limit / offset, плюс format=csv|json (по умолчанию csv). Ответ стримится вложением Content-Disposition: attachment; filename="audit-YYYYMMDD.csv", без пагинации. CSV — шапка timestamp,actor,action,entityType,entityId,meta, экранирование по RFC 4180. Неизвестный формат — audit.invalid_format (400).
  • Сам факт экспорта попадает в журнал как действие audit.exported.
  • AuditLogResponse { id, action, actorEmail, entityType, entityId, meta, ipAddress, occurredAt }.

Client API

МетодПутьНазначениеAuth
POST/api/v1/client/activateАктивация машиныlicense key в теле
POST/api/v1/client/heartbeatCheck-in + новый offline-маркерHMAC
POST/api/v1/client/deactivateСнять активацию (204)HMAC
GET/api/v1/client/public-key/{licenseKey}Публичный ключ по ключу лицензии
  • activate — req { licenseKey, hwFingerprint?, machineName? }ActivateResponse { activationId, heartbeatToken, heartbeatIntervalSeconds, maxOfflineDays }. Повторная активация с той же машины (тот же hwFingerprint) переиспользует активацию и не занимает второе место.
  • heartbeat — req { licenseKey, activationId } + HMAC-заголовки → HeartbeatResponse { heartbeatToken } (новый подписанный offline-маркер).
  • deactivate — req { licenseKey, activationId } + HMAC-заголовки → 204. Освобождает место (floating-seat) — SDK делает это в DisposeAsync. Идемпотентен.
  • public-keyClientPublicKeyResponse { publicKey } (hex).

heartbeatToken из activate / heartbeat — это offline-маркер вида base64url(payload).base64url(signature), см. Протокол лицензий. SDK кэширует его как <license>.ced.hb.

Привязка к железу и места

Активация проверяет две независимые вещи. Первая — режим привязки license.hwBinding:

РежимПоведение при активации
noneЛюбая машина; hwFingerprint необязателен
fixedТолько машина с отпечатком, заданным при выпуске или перепривязке
firstActivationПервая активация обязана прислать hwFingerprint и закрепляет его на лицензии; дальше — как fixed

Коды: activation.fingerprint_mismatch (400, чужая машина), activation.fingerprint_required (400, firstActivation без отпечатка), activation.not_bound (409, fixed со снятым отпечатком — ждёт перепривязки). Подробно — Привязка к железу.

Вторая — seat-лимит limits.maxSeats из подписанного config. Положительное целое ограничивает число активных активаций; отсутствие поля, ноль или нечисловое значение означают «без ограничения». Место занимает только новая активация; повторная с тем же отпечатком — нет. Освобождают место deactivate (клиент), сброс активации, transfer и rebind (панель).