The Control Plane exposes a single versioned API under /api/v1. Three trust boundaries, three different credentials — never mix them.
Edge channel — Bearer <edge api_key> + x-edge-signature
Used by edge nodes. The API key is issued at bootstrap claim and rotated on every re-claim; the Control Plane stores only its SHA-256 hash. Every authenticated call also updates the fleet heartbeat (last_poll_at).
Nodes with a registered node_pubkey must additionally sign every request: header x-edge-signature: ed25519.<b64> computed over the exact raw HTTP body bytes — reserialized JSON fails verification. Unsigned signed-node → 403 EDGE_SIGNATURE_REQUIRED; bad signature → EDGE_SIGNATURE_INVALID. Nodes without a pubkey (legacy rollout) are accepted unsigned. Replay is a no-op by dedup, so signatures carry no timestamp. Node identification via x-edge-node; optional x-kernel-version heartbeat.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/sync/ingest |
CDC outbox drain: batched business events, idempotent on event id. Max 1000 events per request — beyond that 413 with code: BATCH_TOO_LARGE (split the batch) |
POST |
/api/v1/sync/edge-audit |
OT operational audit: NDJSON batch from the edge agent (Content-Type: application/x-ndjson), idempotent on record id, store-and-forward. Max 5000 lines per request |
GET |
/api/v1/sync/manifest |
Latest active manifest + Ed25519 signature. Served from the Control Plane’s in-memory sync cache (TTL ~60s, invalidated instantly on publish) — routine polling never hits the database |
GET |
/api/v1/sync/licenses |
Plugin licenses + tenant contract license (signed). Same sync cache as the manifest — invalidated on subscription/license changes |
Errors use stable codes (LICENSE_SUSPENDED, EDGE_CAP_EXCEEDED, DRAINING, BATCH_TOO_LARGE, EDGE_SIGNATURE_REQUIRED, EDGE_SIGNATURE_INVALID, SCOPE_INSUFFICIENT…). A 503 with code: DRAINING means the node is shutting down — retry later, do not alert.
Signed ingest batches are preserved verbatim in cp_ingest_batches (raw body + signature + node); every stored event links back via batch_id — this is what the public oracle re-verifies.
Wire contract (v1)
Canonical payload shapes — mirrored by src/contracts/edge-cloud.sync.v1.ts in the kernel repo. The tenant identity always comes from the Bearer key: tenant_id fields sent inside bodies are tolerated but ignored.
// POST /sync/ingest — request
{ "events": [{
"id": "uuid", // global dedup key (cp_event_dedup)
"aggregate_type": "string",
"aggregate_id": "uuid",
"event_type": "string",
"payload": { /* event data */ },
"created_at": "ISO-8601" // optional, edge-side timestamp
}]}
// → 201 { status, processed_count, duplicate_count, latest_manifest_version }
// POST /sync/edge-audit — one NDJSON line per record
{ "id": "uuid", "ts": 1735000000.0, "node_id": "edge-01",
"action": "string", "actor": "string", "status": "string",
"details": { /* free-form */ } }
// → 201 { status, received_count, duplicate_count }
// GET /sync/licenses → 200
{ "licenses": [{ "plugin_code": "string", "version": "string",
"expires_at": 1735689600000, // epoch ms
"signature": "ed25519 hex" }], // signs "tenant:plugin:expiresMs"
"revoked": ["plugin_code"],
"tenant_license": { // null for legacy/dev tenants
"license": { // ← nested! status lives HERE
"type": "ax_tenant_license", "v": 1, "tenant_id": "uuid",
"status": "trial | active | past_due | canceled",
"plan": "string", "max_edges": 25, "monthly_event_cap": 25000000,
"features": ["..."], "issued_at": "ISO-8601",
"valid_until": "ISO-8601", "grace_offline_hours": 168
},
"signature": "ed25519 hex" // signs stable-stringify(license)
}
}
// Read tenant_license.license.status — NOT tenant_license.status.
// `canceled` or expired valid_until + grace → edge must stop accepting writes.
// GET /sync/manifest → 200
{ "version": 12, "manifest_raw": "{...}", // signature covers the raw bytes
"signature": "ed25519 hex", "migrations": [...] }
Key scopes (key_scope)
Enforced at middleware, before the heartbeat — a denied call never counts as a sign of life. Denied → 403 SCOPE_INSUFFICIENT.
| Scope | Capability |
|---|---|
full |
Everything (back-compat default) |
sync |
/api/v1/sync/* only — issued at bootstrap claim |
read-only |
GET/HEAD/OPTIONS on every surface |
ingest-only |
POST /sync/ingest + /sync/edge-audit only |
Public endpoints — no credentials
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/bootstrap/claim |
Exchange a single-use bt_* token + node_pubkey (Ed25519 SPKI DER, b64) for tenant_id + node_id + API key. Optional self-reported hw_meta (hostname, arch, mac_hash, cpu_model) — audit metadata, not hardware attestation. Single-use, TTL-bound, rate-limited, transactional |
GET |
/api/v1/public/verify/event/:id |
Unauthenticated integrity oracle: verifies the event’s preserved signed batch (signature valid + membership + stored payload untampered). Zero-leakage — never exposes tenant_id, credentials or payload. Rate-limited 30/min |
POST |
/api/v1/waitlist |
Public waitlist signup (CORS-scoped via WAITLIST_ALLOWED_ORIGIN) |
POST |
/api/v1/webhooks/stripe |
Stripe events — signature verified byte-per-byte, deduplicated on event.id |
GET |
/api/v1/live /ready /health |
Liveness / readiness probes |
GET |
/metrics |
Prometheus exposition |
Tenant portal — Bearer <portal-session>
The customer-facing surface (human users), served by the Control Plane on the same /api/v1 base URL. Two interchangeable credentials:
- Portal session — a UUID Bearer token. Obtained by claiming a single-use
pi_*invite (/portal/invites/claim, arrives by email) or by/portal/login. TTL 12h, individually revocable, bound to one portal user and one tenant. - Device API key
ak_*+tenant_id— back-compat: works on this surface only with scopefull. Keys issued at bootstrap claim are scopesync→403here by design.
One email may exist on multiple tenants (multi-customer consultants): if the password matches more than one account, login answers 401 + code: TENANT_AMBIGUOUS — retry passing tenant_id. The response never reveals which tenants the email belongs to.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/portal/login |
{email, password, tenant_id?} → {token, tenant_id, email, display_name, role}. Rate-limited (10/min) |
POST |
/api/v1/portal/logout |
Revoke current session (idempotent) |
POST |
/api/v1/portal/invites/claim |
Public, single-use: {token: "pi_<48 hex>", password ≥10, display_name?} → opens a session immediately. Re-invite = password reset |
GET |
/api/v1/portal/me |
Current session identity: {kind: "portal_user", tenant_id, email, display_name, role, last_login_at} |
GET |
/api/v1/tenant/usage |
One JSON snapshot: tenant info, subscription (status, plan, max_edges, monthly_event_cap, valid_until, grace_offline_hours, stripe_portal_available), edge nodes with heartbeat, monthly event count, last backup |
POST |
/api/v1/tenant/portal |
→ {url} Stripe Customer Portal session (plan change, payments). 503 when Stripe is not configured for the tenant |
GET |
/api/v1/tenant/invoices |
Stripe invoices (PDF links). 503 when not configured — gate the call on usage.subscription.stripe_portal_available |
POST |
/api/v1/tenant/import-jobs |
Guardian Gate migration workbench: {input ≤100k chars, mode?} → 202 {id, status:"queued", redactions}. Input is redacted before persistence. X-Idempotency-Key header → safe retry (replay 200 with idempotent_replay). Rate: 10/15min |
GET |
/api/v1/tenant/import-jobs /…/:id |
List last 50 / detail with draft_manifest, report, guidance |
POST |
/api/v1/tenant/import-jobs/:id/approve |
Tenant-side draft approval (unlocks ops publish). owner role only — member → 403; only from draft_ready → else 409 |
Roles: owner (full) / member (read + submit, no approvals).
Audit trail — hash-chained export + SIEM webhooks
Every admin mutation on the tenant lands in cp_admin_audit — an append-only, hash-chained journal (SHA-256 prev_hash → hash per tenant, genesis-anchored). The tenant surface exposes it read-only for SIEM collectors; chain verification is self-service.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/tenant/audit?since_seq=&limit= |
NDJSON export (Content-Type: application/x-ndjson), cursor-paginated on seq. limit ≤ 5000 (default 1000). ?format=json_array for non-NDJSON tooling. 400 on malformed/negative cursors — never silently rewinds to 0. Readable with read-only scope |
GET |
/api/v1/tenant/audit/verify |
Re-verifies the tenant’s chain → {ok, checked, head, brokenAt?} |
GET POST DELETE |
/api/v1/tenant/audit-webhooks /…/:id |
Push registry: HTTPS URLs only, owner role for mutations. Each event fans out to one outbox row per webhook (same transaction as the audit write), signed X-Axiom-Trace-Audit-Signature: ed25519:<hex128> over the raw body — verify with the CP public key. 15s timeout, 5 retries with exponential backoff → dead_letter (never retried again) |
// GET /tenant/audit — one NDJSON line per event
{ "audit_version": "1.0",
"seq": "100523", // 64-bit BIGSERIAL as STRING — JS parsers
// lose precision beyond 2^53; Number()-coerce
"event_id": "adm_100523",
"timestamp": "ISO-8601",
"tenant_id": "uuid",
"actor": { "id": "…", "ip": "…", "request_id": "…" },
"action": "TENANT_PROVISIONED | SUBSCRIPTION_PATCHED | SYSTEM_RETENTION_PRUNED | …",
"target": "…",
"details": { /* event data */ },
"integrity": { "prev_hash": "sha256 hex", "hash": "sha256 hex" }
}
Export rate limit: 60 req/min per tenant. Ingesters should keep since_seq = last seq seen and poll incrementally.
Tenant data access — projections via data-link
There are no hard-coded business endpoints on the tenant surface. Vertical data (lots, measurements, orders…) lives in the tenant biz_* tables declared by the manifest’s data_link_engine, and reads go through the same engine: read queries declared in the manifest are addressable as action triggers that return rows. The synced event stream in cp_events is inspectable via the admin surface (GET /api/v1/admin/tenants/:id/events /aggregates).
CORS
/waitlist is the only always-CORS-enabled route. Portal/tenant routes use a hybrid model:
PORTAL_ALLOWED_ORIGINS(env): our cloud origins — valid for any tenant (e.g. a cockpit hosted oncp.axiom-trace.eu).cp_tenants.portal_origin(per-tenant, via adminPATCH …/portal-origin): the customer’s on-prem domain — bound to that tenant. A request from tenant A’s origin carrying tenant B’s session →403. Registered origins are cached ~60s; the admin PATCH invalidates instantly.
OPTIONS preflight is answered from the env ∪ DB union (no credentials on preflight); the per-tenant binding is enforced on the real request. No Origin header → no CORS at all: curl, server-to-server and same-origin proxies are unaffected.
Try it live — the API Playground is a read-only console on this origin: sign in with a portal account and call the
GETendpoints above against your tenant. New here? Read The Contract first, then the end-to-end examples.
Admin — Bearer <admin-session>
Sessions come from POST /api/v1/admin/login (rate-limited, scrypt passwords, expiry enforced). Every mutating admin call is recorded in cp_admin_audit.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/admin/login /logout |
Session lifecycle |
POST |
/api/v1/admin/tenants |
Create a tenant |
GET POST |
/api/v1/admin/tenants/:id/bootstrap-tokens |
Issue single-use provisioning tokens |
GET POST |
/api/v1/admin/tenants/:id/manifests |
Active manifest / publish a new signed version |
GET POST DELETE |
/api/v1/admin/tenants/:id/licenses |
Plugin license management (Ed25519-signed) |
GET PATCH |
/api/v1/admin/tenants/:id/subscription |
Contract state, caps, metering vs. plan |
PATCH |
/api/v1/admin/tenants/:id/plan |
Founding Partner plan flags (plan_type, is_billing_exempt, compute_pass_through, founding_expires_at) |
POST |
/api/v1/admin/tenants/:id/api-key |
Rotate tenant API key (`key_scope: full |
PATCH |
/api/v1/admin/tenants/:id/portal-origin |
Per-tenant CORS origin for the customer cockpit ({origin: "https://…" | null}) |
GET POST DELETE |
/api/v1/admin/tenants/:id/portal-invites /…/portal-users |
Human portal accounts: invite issue/revoke (pi_* + email), user list/deactivate |
GET POST |
/api/v1/admin/tenants/:id/import-jobs, /api/v1/admin/import-jobs/:id/requeue /…/publish |
Guardian Gate ops: submit/inspect/requeue jobs, publish approved drafts as signed manifests |
GET |
/api/v1/admin/tenants/:id/events /aggregates |
Synced data inspection |
GET |
/api/v1/admin/fleet |
All tenants + edge health, contract status, heartbeat |
GET POST |
/api/v1/admin/notifications /…/:id/retry |
Notification outbox inspection + dead-letter retry |
GET |
/api/v1/admin/waitlist |
Waitlist leads |
GET POST DELETE |
/api/v1/admin/users |
Multi-user admin management + self password change |
GET |
/api/v1/admin/audit |
Append-only admin action log |
GET |
/api/v1/admin/audit/verify |
Global hash-chain verification → {ok, checked, head, brokenAt?} |
GET POST DELETE |
/api/v1/admin/audit-webhooks /…/:id |
Global SIEM webhook registry (same signed-push model as tenant webhooks) |
GET |
/api/v1/admin/edge-audit |
Operational audit received from edge nodes |
Stripe webhook — Stripe-Signature verified
Handled event types: checkout.session.completed (self-service tenant provisioning + bootstrap token + welcome email), customer.subscription.* (contract state machine), invoice.paid / invoice.payment_failed (reactivation / dunning + grace). Unsigned endpoint → 503 when STRIPE_WEBHOOK_SECRET is unset — fail-closed.
Conventions
requestIdon every response for edge ↔ cloud correlation; include it in support reports.- Idempotency: ingest dedups on event
id; safe to retry any sync call. - Rate limits: global ceiling plus per-route buckets on public endpoints, plus per-tenant buckets on
/sync/ingestand/sync/edge-auditso one tenant’s post-outage flush cannot starve the fleet. A429carries aRetry-Afterheader — honor it (jittered backoff), do not retry-storm.