Licensing REST API
The licensing authority. Three audiences, three surfaces: devices, operators, and monitoring.
Base URL for the hosted authority: https://licensing.xcaicx.com
| Surface | Auth | Endpoints |
|---|---|---|
| Devices | None at activation (the batch token is the credential); bearer afterwards | /v1/activate, /v1/usage, /v1/revocations |
| Operators | X-Admin-Key header | /v1/admin/* |
| Monitoring | None | /healthz, / |
The signing key never leaves the server process. Devices verify with the public key compiled into the SDK, so nothing on a camera can mint a licence.
Note
Interactive docs (/docs, /redoc, /openapi.json) are disabled in production. They grant no access on their own, but publishing the full admin surface tells an attacker exactly what to aim at. Set XCAICX_PUBLIC_DOCS=1 in a staging environment where browsing them is genuinely useful.
Monitoring#
GET /healthz#
{"ok": true, "public_key": "hLmLM9bA8tx9oKN4H91ycEFbZyXRJVQQ2-BUnH9X6oU", "time": 1786636101}Careful
public_key is the fingerprint of the key the server signs with. It must match the public key compiled into your SDK build. If it does not, every licence the server mints is rejected by every camera already in the field, and the camera-side symptom is a bare BadSignature that points nowhere near the cause. Check this after every deployment.
GET /#
Service index. Names the host and the device endpoints, and deliberately carries no link to the source repository and no listing of the admin surface.
Device endpoints#
POST /v1/activate#
Exchanges an OEM batch key for a unit-bound licence. This is the billing event.
{
"batch_token": "XCAICX1.eyJsaWMi…",
"device_id": "b2cd209a6826bd8b33c0fdaad6304106",
"fingerprint_source": "devicetree-serial",
"weak_binding": false,
"sdk_version": "1.0.0"
}| Field | Required | Notes |
|---|---|---|
batch_token | yes | The batch key flashed onto the camera |
device_id | yes | Hardware fingerprint, ≤ 128 chars |
fingerprint_source | no | Which source produced it; recorded for fleet visibility |
weak_binding | no | true when derived from a spoofable source such as a MAC |
sdk_version | no | Recorded per activation |
200 — new activation
{
"unit_token": "XCAICX1.eyJsaWMi…",
"license_id": "LIC-D3ACF708DB6048AB",
"reactivated": false,
"batch_units_remaining": 4999
}200 — re-activation (same device, same batch)
{"unit_token": "XCAICX1.…", "license_id": "LIC-D3ACF708DB6048AB", "reactivated": true}Idempotent by design: a device already on file gets its existing token back and consumes no additional unit. A factory reflash or an SD-card swap must not be a billing event.
Errors
| Status | Meaning |
|---|---|
400 | device_id missing or longer than 128 characters |
402 | Batch exhausted — all purchased units activated |
403 | Bad signature, expired batch key, revoked batch key, or a validly-signed key this authority never issued |
500 | Batch references a SKU not in the catalogue |
The 403 for a signed-but-unknown key is deliberate: such a token verifies cryptographically but has no seat accounting behind it, so handing out a unit would be giving away inventory.
POST /v1/usage#
Accepts spooled usage records from a device.
POST /v1/usage
Authorization: Bearer XCAICX1.<the unit token>{
"device_id": "b2cd209a6826bd8b33c0fdaad6304106",
"license_id": "LIC-D3ACF708DB6048AB",
"records": [
{"period_start": 1786500000, "period_end": 1786586400,
"inferences": 2592000, "frames": 2592000,
"stream_seconds": 86400, "events": 214}
]
}{"accepted": 1, "duplicates": 0}Notes that matter:
- The token decides whose usage this is, not
body.license_id. Trusting the body would let any device attribute its usage to somebody else's account. - A token bound to a different device than
device_idis rejected403. - Records dedupe on
(device_id, period_start, period_end), so a device that never received an acknowledgement can resend safely. - At most 1000 records are processed per call.
| Status | Meaning |
|---|---|
401 | Missing bearer token, or the token does not verify |
403 | Token bound to a different device, or licence revoked |
GET /v1/revocations#
{"revoked": ["LIC-6B1F…", "LIC-90AC…"], "as_of": 1786636101}Pulled by every online SDK on heartbeat and cached in state_dir/crl.json. Unauthenticated: it is a list of licence ids that no longer work, which is not sensitive and needs to be reachable by cameras whose own credential may already be revoked.
Admin endpoints#
All require X-Admin-Key. The comparison is constant-time — an admin key is a bearer credential and a naive == leaks its prefix to a patient attacker.
export XCAICX_ADMIN_KEY="…"
curl -s -H "X-Admin-Key: $XCAICX_ADMIN_KEY" https://licensing.xcaicx.com/v1/admin/fleetGET /v1/admin/catalogue#
Every SKU with its modules, caps, term, allowance and price tiers.
POST /v1/admin/customers#
{"name": "CamCo", "kind": "oem", "contact": "[email protected]"}kind is oem or end_user. Returns {"customer_id": "CUST-…"}. Supply id to choose your own.
POST /v1/admin/batches#
Issues an OEM batch key good for units activations.
{"customer_id": "CUST-CAMCO", "sku": "oem-industrial", "units": 5000, "term_days": null}{
"batch_id": "B-2026-1C4417D5",
"license_id": "LIC-BATCH-…",
"batch_token": "XCAICX1.…",
"units": 5000,
"unit_price": "<per your agreement>",
"order_value": "<units x unit_price>",
"expires_at": 0
}unit_price and order_value come back as real numbers from your own authority, rated from your catalogue; they are elided here because commercial terms are per programme.
The batch key is minted with dev: "*" and act: "required", which is what forces the per-unit exchange. term_days: null uses the SKU default.
| Status | Meaning |
|---|---|
404 | Unknown customer |
400 | Unknown SKU |
POST /v1/admin/licenses#
Issues a direct licence — an end-user camera, or a pre-provisioned unit.
{
"customer_id": "CUST-PLANT7",
"sku": "edge-industrial",
"device_id": "3f2a9c4e8b1d5a6f",
"term_days": 365,
"api_quota": 0,
"max_streams": null,
"activation": "optional"
}device_id: "*" leaves the licence unbound — only appropriate for a batch-style key. activation: "optional" is what makes an air-gapped unit work without ever calling home.
POST /v1/admin/revoke#
{"license_id": "LIC-D3ACF708DB6048AB", "reason": "chargeback"}Takes effect immediately for usage reporting, and at the next engine start for the camera — once it has fetched /v1/revocations.
GET /v1/admin/batches/{batch_id}#
{
"batch_id": "B-2026-1C4417D5", "customer_id": "CUST-CAMCO",
"sku": "oem-industrial", "units_purchased": 5000,
"units_activated": 1204, "units_remaining": 3796, "expires_at": 0
}GET /v1/admin/invoice/{customer_id}?period_days=30#
Rates the customer's activations and metered usage for the period. Line types: per-unit from activations, per-unit from direct licences, metered inference over the pooled allowance, and annual maintenance on perpetual SKUs when period_days >= 365.
{
"customer_id": "CUST-CAMCO",
"period_start": 1784044101, "period_end": 1786636101,
"currency": "USD",
"lines": [
{"description": "… - per unit", "quantity": 1204.0, "unit": "unit",
"unit_price": "<tier rate>", "amount": "<quantity x rate>"}
],
"total": "<sum of lines>"
}GET /v1/admin/usage/{license_id}#
{"license_id": "LIC-…", "inferences": 2592000, "frames": 2592000,
"stream_seconds": 86400, "events": 214, "records": 30}GET /v1/admin/fleet#
Every activated unit, most recent first, capped at 1000.
{
"units": [
{"device_id": "b2cd209a…", "license_id": "LIC-…", "batch_id": "B-2026-…",
"fingerprint_source": "mac", "weak_binding": 1, "sdk_version": "1.0.0",
"activated_at": 1786584928, "sku": "oem-industrial",
"customer_id": "CUST-CAMCO", "revoked": 0}
],
"count": 1
}Careful
weak_binding is the field to watch. Weakly-bound units are those whose fingerprint came from a spoofable source; a cluster of them on one customer is worth a phone call before it becomes a support case.
Error format#
FastAPI's standard shape:
{"detail": "batch B-2026-1C4417D5 is exhausted: 5000/5000 units activated. Purchase additional units to activate more cameras."}Messages are written to be shown to a human operator, including the numbers needed to act on them.
Rate limiting#
/v1/activate is rate-limited at the edge: 20 requests per 10 seconds per IP.
Note
A factory provisioning line that legitimately activates faster than that will trip it. The fix is to allowlist the line's egress IP, not to raise the global limit — the limit exists because activation is the one unauthenticated endpoint that mints credentials.
Next#
- xcaicxctl CLI — the same operations from a terminal
- Running the authority — deployment, keys, backups
- Licensing model — what the tokens mean