API Keys
Tenant-facing API key management. Mint named keys for each integration (frontend, ERP, mobile app, sandbox test rig, etc.), list them, and revoke leaked or unused ones.
GET /v1/keys
POST /v1/keys
DELETE /v1/keys/:id
GET /v1/keys/:id/usageAuthentication
Authorization: Bearer <api-key> — any active key for the tenant with the keys:manage scope. Every endpoint on this page manages keys themselves, so a narrower key (e.g. a documents:read-only key) cannot list, create, revoke, or view usage for keys. Holding keys:manage alone still isn't enough to escalate privilege — see Mint a new key's privilege containment rule below. See Scopes below.
Scopes
Every key carries a scopes array. A request is only allowed through if the key's scopes include the scope the target route requires — see Insufficient Scope for the error shape when it doesn't.
| Scope | Covers |
|---|---|
documents:write | All document mutations (POST /v1/documents, /send, /rebuild, /email-retry, etc.) and GET /:accessKey/authorize (it triggers a live SRI call and can send an email, so it's treated like a write). Also POST /v1/tenants/retry-failed-documents. |
documents:read | Every other GET under /v1/documents (list, get, RIDE, XML, events, credit notes, SRI responses, stats). |
issuers:read | Every GET under /v1/issuers (list, get, document types, sequentials). |
issuers:write | Every POST/PATCH/DELETE under /v1/issuers (branch creation, updates, logo, certificate renewal, document types, sequentials). |
keys:manage | This entire /v1/keys router. |
billing:manage | GET /v1/subscriptions/me, GET /v1/payments/:id/proofs, and GET /v1/payments/:id/proofs/:proofId — the only three /v1/subscriptions//v1/payments endpoints a key can call directly. Every other endpoint in those two routers (starting a subscription, changing tier/seats, cancelling, submitting or deleting proof, card payments) can only be called by the Comprobify web app itself — see Your subscription & billing. |
webhooks:manage | /v1/webhooks in full. |
tenant:manage | PATCH /v1/tenants/language. (It also governs accepting the legal agreements, which is only done from the web app.) |
tenant:promote | Going to production — split out from tenant:manage since it mints/revokes every one of the tenant's keys and flips sandbox→production irreversibly. That action is only done from the web app, so a key of your own with this scope cannot perform it on its own. |
A tenant's very first key (minted automatically at registration) always gets all nine — full access, identical to how every key behaved before scopes existed. Minting an additional key via POST /v1/keys is different: omitting scopes there does not default to full access — it clones whatever scopes the key making that call already has (see Mint a new key below for the full rule). You can still always create a key without passing scopes at all; you just get a copy of your own key's scopes rather than a blanket full-access grant. Scoping down further is opt-in: pass an explicit, narrower scopes array. Basic identity reads (GET /v1/tenants/me, /agreements, /events) and notification/catalog endpoints are scope-exempt — any active key can call them regardless of its scopes array.
List keys
GET /v1/keysReturns every active key for the tenant. The plaintext token is never returned — only labels, environments, and ids.
Response
{
"ok": true,
"keys": [
{
"id": "00000000-0000-0000-0000-000000000017",
"label": "frontend-prod",
"environment": "production",
"scopes": ["documents:write", "documents:read", "issuers:read", "issuers:write", "keys:manage", "billing:manage", "webhooks:manage", "tenant:manage", "tenant:promote"],
"active": true,
"createdAt": "2026-03-01T12:00:00.000Z",
"revokedAt": null,
"lastUsedAt": "2026-08-07T14:22:10.000Z",
"requestCount": 15832
},
{
"id": "00000000-0000-0000-0000-000000000018",
"label": "dashboard-readonly",
"environment": "production",
"scopes": ["documents:read"],
"active": true,
"createdAt": "2026-04-12T09:30:00.000Z",
"revokedAt": null,
"lastUsedAt": null,
"requestCount": 0
}
],
"limit": { "max": 5, "used": 2 }
}lastUsedAt (nullable, null if the key has never authenticated a request) and requestCount (lifetime counter, not windowed — for time-boxed volume use the structured request logs or an APM tool) update on every request that key successfully authenticates. scopes reflects what that key is currently permitted to do — see Scopes above.
limit.max is how many active keys you can have in total before POST /v1/keys returns 402 API_KEY_LIMIT_REACHED, and limit.used is your current count — it matches your plan's own maximum exactly (see Subscription tiers). On Free/Solo/Lite max is 0: those plans don't sell additional keys via self-service, and the internal keys the web app uses to run your dashboard never count toward this or appear in this listing.
Errors
| Status | Code | When |
|---|---|---|
401 | UNAUTHORIZED | Missing or invalid API key |
403 | INSUFFICIENT_SCOPE | The key making this request doesn't have the keys:manage scope |
Mint a new key
POST /v1/keysCreates a new tenant-scoped key. The plaintext token is shown once in the response and never stored — record it immediately.
Request body
{
"label": "dashboard-readonly",
"environment": "sandbox",
"scopes": ["documents:read"]
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | No | null | Human-readable name for the integration (max 100 chars). Highly recommended for observability. |
environment | string | No | "sandbox" | Either "sandbox" or "production". Production keys can only be minted after the tenant has been promoted to production. |
scopes | string[] | No | a copy of the requesting key's own scopes | Non-empty array, each entry one of the 9 values listed in Scopes above. Omitting it does not default to full access — it clones whatever scopes the key making this call already has. |
Privilege containment: every entry in scopes must already be held by the key making this request — you cannot mint a key broader than yourself, even if you hold keys:manage. A full-access key can mint any combination (including another full-access key); a key with only ["keys:manage", "documents:read"] can mint a key with ["documents:read"] but not one with ["documents:write"].
Plan limit: each tier caps how many active keys a tenant can have at once (see Subscription tiers). Revoke an unused key via DELETE /v1/keys/:id to free up a slot, or upgrade your plan.
Response
201 Created
{
"ok": true,
"apiKey": "a3f8c2bd9e10...",
"scopes": ["documents:read"]
}The plaintext token (apiKey) is shown once; scopes echoes back what was actually granted (useful to confirm the default was applied when the field was omitted).
Errors
| Status | Code | When |
|---|---|---|
400 | VALIDATION_FAILED | label too long, environment invalid, or scopes is present but not a non-empty array of valid scope strings |
401 | UNAUTHORIZED | Missing or invalid API key |
403 | FORBIDDEN | Tenant email not verified, OR attempting to mint a production key before any issuer has been promoted |
403 | INSUFFICIENT_SCOPE | The key making this request doesn't have the keys:manage scope |
403 | SCOPE_ESCALATION_FORBIDDEN | scopes includes an entry the requesting key doesn't itself hold — see Privilege containment above |
402 | API_KEY_LIMIT_REACHED | Tenant already has the maximum number of active keys their plan allows — see Subscription tiers |
Revoke a key
DELETE /v1/keys/:idMarks the key as inactive. The key cannot be used to authenticate any future request.
Path parameters
| Parameter | Description |
|---|---|
id | UUID of the key (from GET /v1/keys) |
Response
200 OK
{ "ok": true }Errors
| Status | Code | When |
|---|---|---|
400 | BAD_REQUEST | Attempting to revoke the same key you are using to make this request — use a different key, or coordinate with admin support |
401 | UNAUTHORIZED | Missing or invalid API key |
403 | INSUFFICIENT_SCOPE | The key making this request doesn't have the keys:manage scope |
404 | NOT_FOUND | Key id does not exist or already revoked, or belongs to a different tenant |
Daily key usage
GET /v1/keys/:id/usageReturns a daily series of authenticated requests for that key, with exactly one entry per day in the range — idle days are included with requestCount: 0 rather than omitted, so there are no gaps to backfill before charting the series.
Path parameters
| Parameter | Description |
|---|---|
id | UUID of the key (from GET /v1/keys) |
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
days | integer | No | 30 | How many days back to include (1–365), counting today. |
GET /v1/keys/00000000-0000-0000-0000-000000000501/usage?days=7
Authorization: Bearer <your-api-key>Response
200 OK
{
"ok": true,
"usage": [
{ "date": "2026-08-05", "requestCount": 0 },
{ "date": "2026-08-06", "requestCount": 128 },
{ "date": "2026-08-07", "requestCount": 342 }
]
}The series is zero-filled — there are always exactly days entries, one per day in the range, even for days the key was never used. The id may belong to an already-revoked key (ownership, not active status, gates access), so a revoked key's history stays queryable.
Errors
| Status | Code | When |
|---|---|---|
400 | VALIDATION_FAILED | id is not a valid UUID, or days is outside the 1–365 range |
401 | UNAUTHORIZED | Missing or invalid API key |
403 | INSUFFICIENT_SCOPE | The key making this request doesn't have the keys:manage scope |
404 | NOT_FOUND | Key id does not exist or belongs to a different tenant |
Key environment + targeted issuer
When a key is used on a document request, the resolveIssuer middleware validates that the key's environment matches the targeted issuer's effective environment. The sandbox flag lives on the tenant — resolveIssuer reads tenant.sandbox and rejects any key/issuer mismatch:
| Key environment | Tenant sandbox | Result |
|---|---|---|
sandbox | true | OK |
sandbox | false | 401 — sandbox key cannot address a production tenant |
production | true | 401 — production key cannot address a sandbox tenant |
production | false | OK |
This is the only safeguard preventing accidental cross-environment requests; treat the environment as part of the key's identity, not as a separate detail from it.