Self-Service API Keys
The API-key management endpoints let a tenant administer its own API keys programmatically. They are gated behind the apikeys:manage scope and operate strictly inside the authenticated tenant — cross-tenant access is not possible.
Contents: Overview · Authentication · GET /v1/apikeys · POST /v1/apikeys · DELETE /v1/apikeys/{id} · End-to-End Flow · Field Reference · Next Steps
Overview
Programmatic key management exists to support four concrete customer scenarios, all of which require issuing a key with a controlled scope set rather than handing out the tenant master key:
- Per-environment keys. Separate keys for production, staging, and dev, each with the same scope set but distinct audit trails.
- Partner read-only keys. A 3PL or carrier receives a single key with
jobs:readplusaccount:readand no conversion scope, preventing accidental token consumption. - CI bots. A Jenkins agent or GitHub Actions runner gets a key whose scope list is exactly the API calls the pipeline makes, with
expires_atset to the contract end date. - Audit-export keys. A read-only auditor gets a key carrying
audit:readandaccount:exportwith no other scope grants enforced for the rest.
Each call to these endpoints is recorded as an audit event (APIKEY_CREATED, APIKEY_REVOKED, APIKEY_LISTED).
Required scope on the caller
Every endpoint under /v1/apikeys requires the calling API key to carry the scope:
apikeys:manage
A calling key without apikeys:manage receives 403 INSUFFICIENT_SCOPE (see Authentication). The catalog entry is published at GET /v1/scopes and is also accessible via the CLI:
zplflow scopes show apikeys:manage
Sessions are tenant-scoped. The bearer token’s tenant is the one whose keys can be listed, created, and revoked — there is no way to act on another tenant’s keys even with a master apikeys:manage grant, because the tenant ID is read from the authenticated identity.
Plan-based availability
API-key creation is gated on the tenant’s plan. Only plans that include the api_key_mgmt feature can create new keys. The Free plan does not include it: a Free tenant receives a single bootstrap Default key at signup and cannot create additional keys. A creation attempt by a Free tenant is rejected with 403 FORBIDDEN (feature not enabled for this tenant). Listing and revoking the existing key remain available so Free tenants can manage their provisioned key.
Authentication
Authorization: Bearer lb_...
The bearer token is the admin key carrying apikeys:manage. Created keys are bearer tokens for the application that consumes them. Never log, echo, or commit the raw lb_ string; the API returns it exactly once and never re-displays it.
GET /v1/apikeys — List Keys
Returns the active and revoked keys belonging to the authenticated tenant. The full token material is never returned — only the first 12 characters of the public identifier (prefix) are exposed for UI display and log correlation.
Request
curl -s -X GET https://api.zplflow.io/v1/apikeys \
-H "Authorization: Bearer lb_ADMIN_KEY"
Response (200 OK)
{
"api_keys": [
{
"id": "01HZ8K3F2Q5J7M9P4R6T8X1CVA",
"prefix": "lb_3Kf2",
"label": "WMS production",
"status": "active",
"scopes": ["convert:pdf_to_zpl", "convert:zpl_to_pdf", "jobs:create", "jobs:read", "jobs:write"],
"description": "Generated by terraform on 2026-05-13",
"created_at": "2026-05-13T12:00:00Z",
"last_used_at": "2026-05-13T16:24:11Z",
"expires_at": null
},
{
"id": "01HZ8J9T1N2D5M7X8P6R4Q3VCAB",
"prefix": "lb_8J9T",
"label": "Partner read-only",
"status": "active",
"scopes": ["jobs:read", "account:read"],
"description": "3PL auditor access",
"created_at": "2026-04-01T09:00:00Z",
"last_used_at": "2026-05-12T14:01:55Z",
"expires_at": "2026-12-31T23:59:59Z"
},
{
"id": "01HZ6B7X8M3N5P1Q2R4T6V8CYD",
"prefix": "lb_6B7X",
"label": "Old CI token",
"status": "revoked",
"scopes": [],
"description": "Rotated on 2026-04-30",
"created_at": "2025-10-01T11:00:00Z",
"last_used_at": "2026-04-30T08:11:00Z",
"expires_at": null,
"revoked_at": "2026-04-30T09:00:00Z"
}
]
}
Status codes
| HTTP | Code | Meaning |
|---|---|---|
| 200 | — | Success |
| 401 | UNAUTHORIZED |
Missing or invalid bearer token |
| 401 | APIKEY_EXPIRED |
Calling key expired |
| 403 | INSUFFICIENT_SCOPE |
Calling key lacks apikeys:manage |
POST /v1/apikeys — Create an API Key
Creates a new API key for the authenticated tenant and returns the raw key exactly once.
Request
{
"label": "WMS production",
"scopes": [
"convert:pdf_to_zpl",
"convert:zpl_to_pdf",
"jobs:create",
"jobs:read",
"jobs:write"
],
"description": "Generated by terraform on 2026-05-13",
"expires_at": "2027-05-13T12:00:00Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
label |
string | yes | Human-readable identifier, ≤ 64 characters. Must be unique per tenant. Server-side validation rejects empty strings and duplicates with 400 INVALID_PARAMS. |
scopes |
array of strings | yes | Scope IDs from GET /v1/scopes. Pass [] to issue a locked key (only public endpoints reachable). Server-side validation rejects unknown IDs with 400 INVALID_PARAMS. |
description |
string | no | Free-form context (provenance, consumer, owner). |
expires_at |
string (RFC3339) | no | Optional UTC expiration. Past timestamps are rejected with 400 INVALID_PARAMS. |
The scopes array is the authoritative permission list for the new key. Once deny-by-default mode is active, only scopes in this list (plus public scopes) will be reachable. See Authentication → API Key Scopes for the full state machine.
Response (201 Created)
{
"id": "01HZ9C5P1N7X3M8V4R2Q6T0DWEY",
"prefix": "lb_5P1N",
"label": "WMS production",
"status": "active",
"scopes": [
"convert:pdf_to_zpl",
"convert:zpl_to_pdf",
"jobs:create",
"jobs:read",
"jobs:write"
],
"description": "Generated by terraform on 2026-05-13",
"created_at": "2026-05-13T17:02:09Z",
"last_used_at": null,
"expires_at": "2027-05-13T12:00:00Z",
"raw_key": "lb_b11b0df7-6571-4a24-8301-0686c847de5e"
}
Security advisory:
raw_keyis shown exactly once and is never persisted, never logged, never echoed in error responses, and never returned again by any endpoint (including GET or audit). Capture it on creation and store it in your secrets manager immediately. The platform never has the ability to retrieve it on your behalf.
Status codes
| HTTP | Code | Meaning |
|---|---|---|
| 201 | — | Key created; capture raw_key now |
| 400 | INVALID_PARAMS |
label empty / > 64 chars / not unique per tenant, scopes contains unknown IDs, expires_at malformed or in the past |
| 401 | UNAUTHORIZED |
Missing or invalid bearer token |
| 401 | APIKEY_EXPIRED |
Calling key expired |
| 403 | INSUFFICIENT_SCOPE |
Calling key lacks apikeys:manage |
| 409 | DUPLICATE_LABEL |
Tenant already has a key with the same label |
DELETE /v1/apikeys/{id} — Revoke a Key
Revokes a key by its public identifier (id, the ULID returned at creation or by GET). The key moves to status: revoked and immediately stops authenticating.
Request
curl -s -X DELETE https://api.zplflow.io/v1/apikeys/01HZ8K3F2Q5J7M9P4R6T8X1CVA \
-H "Authorization: Bearer lb_ADMIN_KEY"
{id} is the public key ID, not the raw_key string. The endpoint never accepts the raw key value; the admin never has to paste secret material into a DELETE call.
Response (200 OK)
{
"id": "01HZ8K3F2Q5J7M9P4R6T8X1CVA",
"prefix": "lb_3Kf2",
"status": "revoked",
"revoked_at": "2026-05-13T17:30:00Z"
}
Status codes
| HTTP | Code | Meaning |
|---|---|---|
| 200 | — | Key revoked |
| 401 | UNAUTHORIZED |
Missing or invalid bearer token |
| 401 | APIKEY_EXPIRED |
Calling key expired |
| 403 | INSUFFICIENT_SCOPE |
Calling key lacks apikeys:manage |
| 404 | NOT_FOUND |
No key with that id for the authenticated tenant |
| 409 | CANNOT_DELETE_SELF |
Caller attempted to revoke its own key. Self-deletion is blocked to prevent lockout. Use a different admin key to revoke the current one. |
End-to-End Flow
The standard lifecycle of a least-privilege application key, as emitted by the API:
export ADMIN_KEY="lb_ADMIN_KEY_WITH_APIKEYS_MANAGE"
export BASE="https://api.zplflow.io"
# 1. List current keys (sanity check before creating)
curl -s -X GET "$BASE/v1/apikeys" \
-H "Authorization: Bearer $ADMIN_KEY" | jq '.api_keys | length'
# 2. Create a least-privilege key
NEW=$(curl -s -X POST "$BASE/v1/apikeys" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "wms-prod",
"description": "Created from CI on 2026-05-13",
"scopes": ["convert:pdf_to_zpl", "convert:zpl_to_pdf", "jobs:create", "jobs:read", "jobs:write"]
}')
RAW=$(echo "$NEW" | jq -r '.raw_key')
ID=$(echo "$NEW" | jq -r '.id')
# 3. Use the new key to verify access (estimate is cheap, no token charge)
curl -s -X POST "$BASE/v1/estimate" \
-H "Authorization: Bearer $RAW" \
-H "Content-Type: application/json" \
-d '{"operation":"pdf_to_zpl","params":{"dpi":203},"document_count":1}'
# 4. Revoke when done
curl -s -X DELETE "$BASE/v1/apikeys/$ID" \
-H "Authorization: Bearer $ADMIN_KEY"
import os, requests
ADMIN = os.environ["ADMIN_KEY"]
BASE = "https://api.zplflow.io"
admin_h = {"Authorization": f"Bearer {ADMIN}"}
# List
keys = requests.get(f"{BASE}/v1/apikeys", headers=admin_h).json()["api_keys"]
print(f"Tenant has {len(keys)} key(s)")
# Create
new = requests.post(f"{BASE}/v1/apikeys", headers=admin_h, json={
"label": "wms-prod",
"scopes": ["convert:pdf_to_zpl", "convert:zpl_to_pdf", "jobs:create", "jobs:read", "jobs:write"],
}).json()
raw_key = new["raw_key"]
key_id = new["id"]
# Verify
app_h = {"Authorization": f"Bearer {raw_key}"}
requests.post(f"{BASE}/v1/estimate", headers=app_h, json={
"operation": "pdf_to_zpl", "params": {"dpi": 203}, "document_count": 1
})
# Revoke
requests.delete(f"{BASE}/v1/apikeys/{key_id}", headers=admin_h)
package main
import (
"bytes"
"encoding/json"
"net/http"
"os"
)
func main() {
admin := "Bearer " + os.Getenv("ADMIN_KEY")
base := "https://api.zplflow.io"
// Create
body := bytes.NewBufferString(`{
"label": "wms-prod",
"scopes": ["convert:pdf_to_zpl","convert:zpl_to_pdf","jobs:create","jobs:read","jobs:write"]
}`)
req, _ := http.NewRequest("POST", base+"/v1/apikeys", body)
req.Header.Set("Authorization", admin)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var created struct {
ID string `json:"id"`
RawKey string `json:"raw_key"`
}
json.NewDecoder(resp.Body).Decode(&created)
// Revoke
del, _ := http.NewRequest("DELETE", base+"/v1/apikeys/"+created.ID, nil)
del.Header.Set("Authorization", admin)
http.DefaultClient.Do(del)
}
Field Reference
| Field | Type | Where | Notes |
|---|---|---|---|
id |
ULID | response | Stable public identifier. Use as {id} in DELETE. Never the raw key. |
prefix |
string | response | First 12 characters of the key identifier (e.g. lb_b11b0df7...). Used for UI display and log correlation. |
label |
string | request, response | Human-readable label, unique per tenant, ≤ 64 chars. |
scopes |
string array | request, response | Scope IDs from GET /v1/scopes. Empty array is the locked state. |
description |
string (optional) | request, response | Free-form context. |
status |
enum | response | active or revoked. |
created_at |
RFC3339 string | response | When the key was issued. |
last_used_at |
RFC3339 string or null | response | Last authenticated request. Throttled to one update per 5 minutes per key. |
expires_at |
RFC3339 string or null | request (create), response | If set and past, the key returns 401 APIKEY_EXPIRED. |
revoked_at |
RFC3339 string | response (delete) | Timestamp of revocation. |
raw_key |
string | response (create only) | Returned exactly once. Store immediately in your secrets manager. |
Next Steps
- Authentication — scope model and error codes
- Best Practices → Least-Privilege API Keys — recipes for WMS / partner / CI / audit key sets
- CLI Tool → Scopes — browse the catalog without authenticating
- Conversion endpoints, Jobs API, Pipelines — full per-endpoint scope reference