← Developers

Control Plane API Reference

Spec matching api v1

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:

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.

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:

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 GET endpoints 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