All routes are JSON over HTTP. Base URL is CP_BASE_URL (default http://localhost:4000).
For the machine-readable spec see ../openapi.yaml. For the
event payloads these endpoints emit and ingest, see events.md;
for the tables they read and write, see data-model.md.
This reference covers the control-plane API. Protocol artifacts these
endpoints carry (manifests, TCTs, the handshake) are defined by the
AITP RFCs — these docs link to the
spec rather than restate it.
Request ID: Every response carries x-request-id. Clients may pre-set the header; the CP echoes it.
CORS:Access-Control-Allow-Origin is set to CORS_ORIGIN (defaults to http://localhost:3000; falls back to * with a warning in production if unset).
Filter key casing: List filters are accepted in both camelCase and snake_case where noted (e.g. runId or run_id). The playground emits snake_case; UI clients tend to use camelCase. Both resolve to the same column.
Public discovery (health, readyz, metrics, well-known, registry GET)
none
POST /api/registry/enroll
none — caller submits its own signed manifest; the CP verifies the signature and issues a one-time token
POST /api/registry/agents
Authorization: Bearer <enrollment-token> (the token returned by /enroll, single-use)
All other gated routes
Authorization: Bearer <API_KEY> from the API_KEYS allowlist
ENROLLMENT_SECRET is the server-side HMAC key the CP uses to mint and verify enrollment tokens. Callers never present it directly.
In production, an empty API_KEYS causes gated routes to return 503 SERVER_MISCONFIGURED — fail-safe against accidental exposure. In non-production, an empty API_KEYS disables auth on gated routes (a boot-time warning is logged).
These mutating endpoints honor an optional Idempotency-Key request header — replaying the same (endpoint, key) returns the original status and body instead of re-running the side effect:
POST /api/registry/agents, POST /api/events, POST /api/webhooks, POST /api/trust-anchors, POST /api/pinned-keys, POST /api/revocation/entries.
Cached responses are retained for IDEMPOTENCY_KEY_TTL_DAYS (default 7). An empty, over-long, or control-character key is rejected 400.
The CP verifies the manifest signature against the AID's key and returns a single-use enrollment token. Errors: 400 MANIFEST_INVALID (missing/unverifiable manifest), 400 BODY_INVALID (not JSON).
The ManifestEnvelope shape and its signature/verification are defined by the protocol — RFC-AITP-0003 (Agent Manifest) and RFC-AITP-0007 (Key Resolution). The CP caches and serves the manifest; it does not define the format. Use the aitp SDK to build and sign one.
Pass the enrollment token in Authorization: Bearer <token>. The body is the same ManifestEnvelope posted to /enroll (the CP stores the raw bytes as the cached manifest):
expires_at is Unix seconds. It must be ≥ 5 minutes in the future or you get 400 MANIFEST_EXPIRED.
Namespace is taken from the X-Aitp-Namespace header (wins) or manifest.extensions.namespace, defaulting to default.
The token is consumed atomically; a second presentation returns 401 TOKEN_REPLAYED. An invalid/expired token or AID mismatch returns 401 TOKEN_INVALID.
Without ?namespace=, results span all namespaces by design. Namespaces are a control-plane scoping convention, not a protocol boundary — initial peer discovery is operational and non-normative in AITP. The CP enforces no implicit tenant isolation, so scope your queries with ?namespace= if you need it.
manifestUrl is the CP's always-available cached copy. agentManifestHint is a best-effort guess at the agent's own .well-known URL (may 404 behind a gateway). manifestJson is present only when include_manifest=true.
aid_a / aid_b / session_id / run_id snake_case keys are also accepted (the playground emits snake_case). Unknown event types are stored as-is (never 4xx); only a known set drives projections and webhooks — see events.md.
Response 200: { "ingested": <n> }.
Limits: a single batch must be ≤ 256 KiB on the wire and each event's payload ≤ 64 KiB. Over-cap requests return 413 PAYLOAD_TOO_LARGE (the offending eventType is included when a single event is too big). Split large batches into multiple requests.
text/event-stream; each event is delivered as a data: <json>\n\n frame, replaying the in-memory backlog (MAX_AUDIT_EVENTS_MEMORY) then streaming live. Returns 503 SSE_CAPACITY once MAX_SSE_CONNECTIONS (default 500) streams are already open — back off and retry.
jti must be a UUID; reason ≤ 500 chars; revokedAt is optional ISO-8601 (defaults to now). Invalid input → 400 JTI_INVALID / 400 BODY_INVALID. Recording a revocation also flips the matching issuedTcts.revoked flag and cascades to descendant delegations. The signed list at /.well-known/aitp-revocation-list refreshes every REVOCATION_LIST_TTL_SECS seconds.
url must be http(s) and pass the SSRF guard (private/loopback/link-local ranges and hosts outside WEBHOOK_URL_ALLOWLIST are rejected 400 URL_NOT_ALLOWED). An empty/omitted events array means all deliverable event types. Only a fixed set of event types is deliverable — see events.md.
Deliveries are POSTed with header X-AITP-Signature: sha256=<hex> — an HMAC-SHA256 over the canonical body bytes using the webhook's secret. Retries follow WEBHOOK_RETRY_ATTEMPTS (default 3) with exponential backoff; a circuit breaker trips a repeatedly-failing endpoint open.
?root_jti=<uuid> (or rootJti) walks the descendant tree via a recursive CTE. Other filters: ?parent_jti= (or parentJti), ?delegator=, ?delegatee=, ?active=true, ?limit=, ?offset=. A malformed root_jti/parent_jti (not a UUID) returns 400 BAD_REQUEST.
GET /api/readyz returns 503 with { "ready": false, "reason": "shutting_down" } once the process has received SIGTERM, so a load balancer can drain the pod before it exits. GET /api/health continues to return 200 during the drain window. See operations.md.