CertifiEd

Quickstart

The end-to-end path: company → template → version → license issue → SDK integration. Every command is verified against the API at http://localhost:5100 with the seeded operator admin@certified.local / changeme.

0. Prerequisites

  • The CertifiEd API is running with CERTIFIED_MASTER_KEY set (base64, 32 bytes).
  • curl is installed, plus openssl for the HMAC-signed heartbeat.
bash
BASE=http://localhost:5100
curl -s $BASE/health# {"status":"ok", ...}

1. Operator login

The panel API runs on the HttpOnly cookie certified.auth; store it in the cookie jar cj.txt.

bash
curl -s -c cj.txt -X POST $BASE/api/v1/panel/auth/login \  -H 'Content-Type: application/json' \  -d '{"email":"admin@certified.local","password":"changeme"}'
200 OK
{  "id": "019f69e4-2a5d-7c3c-bb39-93d6829d92a5",  "email": "admin@certified.local",  "displayName": "Operator",  "isOperator": true}

From here on, add -b cj.txt to every panel request.

2. The licensee company

A root company can only be created by an operator (parentId: null).

bash
curl -s -b cj.txt -X POST $BASE/api/v1/panel/companies \  -H 'Content-Type: application/json' \  -d '{"name":"Globex Inc","slug":"globex-inc","parentId":null,       "contactEmail":"it@globex.example"}'
201 Created
{  "id": "019f6b45-927c-7c2b-978c-91e7689fe7f3",  "name": "Globex Inc",  "slug": "globex-inc",  "parentId": null,  "path": "c019f6b45927c7c2b978c91e7689fe7f3",  "depth": 0,  "status": "Active",  "contactEmail": "it@globex.example",  "createdAt": "2026-07-16T14:12:29.43+00:00"}

Keep the id as COID. A slug is 1–64 characters of [a-z0-9-], must not start or end with a hyphen, and is unique among siblings.

3. The product template

Creating a template automatically generates the first active Ed25519 signing key.

bash
curl -s -b cj.txt -X POST $BASE/api/v1/panel/templates \  -H 'Content-Type: application/json' \  -d '{"name":"Curl Demo","productCode":"curl-demo","description":"demo",       "defaultOfflineDays":7,"defaultValidityDays":365}'
201 Created
{  "id": "019f6b45-92bf-758a-8955-131b7c9cc364",  "name": "Curl Demo",  "productCode": "curl-demo",  "defaultOfflineDays": 7,  "defaultValidityDays": 365,  "status": "Active",  "currentVersionId": null,  "createdAt": "2026-07-16T14:12:29.50+00:00"}

Keep the id as TPLID. currentVersionId is still null — there is no version yet.

4. The template version

A version carries the JSON schema of the config plus its defaults. configSchema and defaults are JSON strings (inner quotes escaped). A newly created version immediately becomes the current one.

bash
curl -s -b cj.txt -X POST $BASE/api/v1/panel/templates/$TPLID/versions \  -H 'Content-Type: application/json' \  -d '{"configSchema":"{\"type\":\"object\"}",       "defaults":"{\"features\":[]}","changelog":"v1"}'
201 Created
{  "id": "019f6b45-92f8-71e5-bd7a-aa95cf5644a1",  "templateId": "019f6b45-92bf-758a-8955-131b7c9cc364",  "version": 1,  "configSchema": "{\"type\":\"object\"}",  "defaults": "{\"features\":[]}",  "signingKeyId": "019f6b45-92c3-7a09-8bdf-24b8ef0b8ba5",  "changelog": "v1",  "createdAt": "2026-07-16T14:12:29.56+00:00"}

5. Issuing a license

The crucial detail: config is a JSON string that gets embedded into the signed token. It is exactly what HasFeature() and GetConfig<T>() read later. Here we ship the export / api features and the limits.maxSeats = 25 limit.

bash
curl -s -b cj.txt -X POST $BASE/api/v1/panel/licenses \  -H 'Content-Type: application/json' \  -d '{        "companyId": "'"$COID"'",        "templateId": "'"$TPLID"'",        "config": "{\"features\":[\"export\",\"api\"],\"limits\":{\"maxSeats\":25}}",        "offlineDays": 7      }'
201 Created
{  "id": "019f6b45-9312-7c41-b1fb-2e2539eaac9d",  "licenseKey": "CFED-3NCR-4ZER-BAEA-YREY",  "token": "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoi...<three segments>...",  "publicKey": "67619cd9aac91085cb5be3b93a9fbca97e5b06c21fcef6a7fdf3b182e16487d8"}

We left expiresAt unset → the expiry becomes now + defaultValidityDays of the template. publicKey comes back as hex. Keep licenseKey as KEY and id as LICID.

6. Download the .ced file and the public key

bash
# License file: JSON envelope { licenseKey, token, publicKey }curl -s -b cj.txt $BASE/api/v1/panel/licenses/$LICID/download -o license.ced
# Public key on its own (hex)curl -s -b cj.txt $BASE/api/v1/panel/licenses/$LICID/public-key# {"publicKey":"67619cd9aac9...87d8"}

license.ced can be handed to the client application as is — the SDK reads both the JSON envelope and a bare single-line token.

7. The client side: SDK

Normally the SDK does the verification and performs steps 8–9 on its own.

C#
using CertifiEd.Client;
var options = new CertifiEdClientOptions{    ServerUrl       = "http://localhost:5100",    LicenseFilePath = "license.ced",    PublicKey       = Convert.FromHexString(        "67619cd9aac91085cb5be3b93a9fbca97e5b06c21fcef6a7fdf3b182e16487d8"),};
await using var license = new CertifiEdLicenseClient(options);if (!await license.InitializeAsync())    return;                                       // missing file / bad signature / expired
Console.WriteLine(license.Status);                // ActiveConsole.WriteLine(license.HasFeature("export"));  // TrueConsole.WriteLine(license.GetConfig<int>("limits.maxSeats")); // 25

Details live in the Client SDK section. Below are the same client calls done by hand through the client API.

8. Activation (client API, no HMAC)

activate uses the license key itself as the shared secret — no HMAC signature required.

bash
curl -s -X POST $BASE/api/v1/client/activate \  -H 'Content-Type: application/json' \  -d '{"licenseKey":"'"$KEY"'","hwFingerprint":"demo-fp-001",       "machineName":"demo-host"}'
200 OK
{  "activationId": "019f6b45-9362-78f1-aec3-1e3e105a3bf8",  "heartbeatToken": "eyJ0eXAiOiJjZXJ0aWZpZWQt...",  "heartbeatIntervalSeconds": 3600,  "maxOfflineDays": 7}

heartbeatToken is the signed offline marker; the SDK caches it next to the license as license.ced.hb. Keep activationId as ACTID.

9. Heartbeat (HMAC-signed)

heartbeat and deactivate are signed with X-CertifiEd-Signature: v1=hex(HMAC_SHA256(key = licenseKey, msg = timestamp)), where the timestamp is in Unix seconds within a ±5 minute window.

bash
TS=$(date +%s)SIG=$(printf '%s' "$TS" | openssl dgst -sha256 -hmac "$KEY" -hex | sed 's/^.*= //')
curl -s -X POST $BASE/api/v1/client/heartbeat \  -H 'Content-Type: application/json' \  -H "X-CertifiEd-Timestamp: $TS" \  -H "X-CertifiEd-Signature: v1=$SIG" \  -d '{"licenseKey":"'"$KEY"'","activationId":"'"$ACTID"'"}'
200 OK
{ "heartbeatToken": "eyJ0eXAiOiJjZXJ0aWZpZWQt..." }

HTTP 200 and a fresh marker. Deactivation works the same way through POST /api/v1/client/deactivate and returns 204.

Checking from the panel side

bash
curl -s -b cj.txt $BASE/api/v1/panel/licenses/$LICID/activationscurl -s -b cj.txt "$BASE/api/v1/panel/licenses/$LICID/heartbeats?limit=10"

What's next