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:read plus account:read and 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_at set to the contract end date.
  • Audit-export keys. A read-only auditor gets a key carrying audit:read and account:export with 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_key is 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