A contract, not a surprise

One HTTP API, described by a live OpenAPI document, with the operational headers you would expect from something you are going to put in a retry loop.

  • 141 documented paths
  • Idempotency-Key on 6 write routes
  • HMAC-signed webhooks
  • Request ID in every audit row

The response is the record

Responses are plain JSON objects with named fields, generated from the same Pydantic models the server validates against. There is no wrapper to unwrap and no envelope that hides the useful part. A chat turn returns the answer, the memory IDs that informed it, the ID of the episode it wrote, and — when you ask for it — the retrieval trace behind the whole thing.

Errors use FastAPI semantics: a detail field with the reason, and the status code doing the work. 400 for a missing or malformed tenant, 401 for a bad token or a token with no tenant claim, 402 with the exhausted metric when a plan quota is hit, 403 for a missing permission, 413 with the byte ceiling when a body is too large, 422 for schema violations, 429 with Retry-After.

The document that describes all of it is served by the running instance rather than published from a branch, so it cannot drift from the code: the Swagger UI and the raw OpenAPI JSON.

POST /v1/chat

The response body, in full

response
The assistant message, deanonymised for display.
used_memory_ids
["m_77e1", "m_18b3"]
stored_episode_id
m_9f02 — or null under DO_NOT_STORE
note
DO_NOT_STORE honored (not written to memory)
trace
Retrieval trace, present only when debug: true

Idempotency-Key replays this exact body, byte for byte

The headers that matter

Nine of them carry real behaviour. Everything else is ordinary HTTP.

HeaderDirectionWhat it does
X-Tenant-ID request Tenant scope for the call. Also accepted as a body or query tenant_id. Under Auth0 the verified token claim wins and the header is ignored.
Authorization: Bearer … request An Auth0 RS256 access token, or a pcnaid_ API key. Keys resolve to a service role with the scopes stored on the key.
Idempotency-Key request Replays the first response for this tenant and key instead of performing the write again. Honoured on six write routes, listed below.
X-API-Token request Shared-token gate for read and chat routes when API_TOKEN is configured. Compared in constant time.
X-Admin-Token request Required by admin operations when no admin or owner role is present in the token. Constant-time compared as well.
X-Request-ID both Supplied or generated. Sanitised to [A-Za-z0-9_.:-] and 64 characters, echoed on the response, and written into every audit row the request produces.
X-RateLimit-Remaining response Requests left in the tightest bucket that matched this route.
Retry-After response Seconds until the exhausted bucket refills. Sent with every 429.
X-Pcnaid-Signature webhook On outbound deliveries when the subscription has a secret: sha256=<hmac> over the canonical JSON body.

Retry without writing twice

Send an Idempotency-Key and the first response is stored in idempotency_keys alongside its method, path, status code and a SHA-256 hash of the body. A repeat of the same key in the same tenant returns that stored response rather than running the write again. The insert is ON CONFLICT (tenant_id, key) DO NOTHING, so two simultaneous retries cannot both win.

Keys are tenant-scoped, which means one tenant cannot probe or collide with another tenant's keyspace. The table lives in Postgres, so idempotency is a property of the deployment rather than of one process — unlike the rate limiter below.

RouteWhat is replayed
POST /v1/chat A chat turn, including whatever it wrote to memory.
POST /v1/memory Creating a semantic memory.
POST /v1/facts/override Replacing the current value of a fact.
POST /v1/billing/checkout Opening a Stripe checkout session.
POST /v1/billing/portal Opening the Stripe customer portal.
POST /v1/support/tickets Raising a support ticket from the widget.

Rate limits, honestly described

Buckets are chosen by route and method, and both an IP bucket and a tenant bucket can apply to the same call. A rejection returns 429 with Retry-After and the name of the bucket that tripped; a success carries X-RateLimit-Remaining for the tightest bucket that matched.

Two things you should know before relying on it. The limiter is in-process: counters live in the worker's memory and are not shared between replicas, so N replicas mean N times the stated ceiling. And it is off unless RATE_LIMIT_ENABLED=true. For a hosted deployment, enforce at the edge and keep this as a second floor.

RouteBucketDefault
/v1/chat per IP 120 / minute
RATE_LIMIT_CHAT_PER_IP_PER_MIN
/v1/chat per tenant 60 / minute
RATE_LIMIT_CHAT_PER_TENANT_PER_MIN
/v1/memory writes per tenant 120 / minute
RATE_LIMIT_MEMORY_WRITE_PER_TENANT_PER_MIN
/v1/kb writes per tenant 10 / hour
RATE_LIMIT_KB_REFRESH_PER_TENANT_PER_HOUR
/v1/admin per IP 30 / minute
RATE_LIMIT_ADMIN_PER_IP_PER_MIN
everything else per IP 120 / minute
RATE_LIMIT_PER_MINUTE

One identifier from the edge to the audit row

The thing that makes a support conversation short.

The request-ID middleware runs outermost. It takes your X-Request-ID if you sent one, sanitises it to [A-Za-z0-9_.:-] and 64 characters, or mints a UUID if you did not. That value goes into a context variable for the life of the request, is echoed back on the response, and is stamped onto every audit event the request writes.

The dashboard's support widget captures the latest X-Request-ID it saw and attaches it to the ticket automatically. So a customer reporting "the answer looked wrong at about two o'clock" arrives with the exact identifier needed to pull the audit rows for that call.

The support desk and audit tooling

Webhooks: two endpoints and a signature

Register a target, choose the events, and receive them signed. There is no console step and no separate webhook service.

POST /v1/webhooks registers a subscription: an event_types array — a literal "*" matches everything, and an empty list does too — a target_url, an optional secret, and a meta object for whatever you need to correlate on your side. GET /v1/webhooks lists them.

When a subscription carries a secret, each delivery is signed: X-Pcnaid-Signature: sha256=<hmac>, computed over the canonical sorted-key JSON of the body. Verify it before you trust the payload. Every attempt — delivered or refused — is recorded in webhook_deliveries with the response status and the first kilobyte of the response body, so a broken endpoint is visible rather than silent.

WEBHOOK_DELIVERY_ENABLED=false is a kill switch. Subscriptions stay registered, matching still happens, and the emit call returns the count it would have delivered with delivery_enabled: false. Useful when a downstream system is on fire and you would rather not add to it.

The events the server emits

This is the complete list. If an event name is not here, nothing in the codebase fires it.

EventFired when
session.ingested A transcript was captured through POST /v1/sessions/ingest.
media.voice_memo.transcribed A voice memo was transcribed — or a fingerprint was stored when no provider was configured.
media.multimodal.ingested A multimodal item was stored with its text and media hashes.
media.{source}.summarized Text was summarised into memory. {source} is the caller-supplied source name.
fabric.{type}.created A Fabric entry was written. {type} is one of decision, task, review, outcome, procedure, handoff or note.

Fabric alone therefore produces seven distinct event names, one per entry type. Everything else in the system — memory writes, fact changes, knowledge-base refreshes — is observable through the audit log and the outbox rather than through a webhook.

Engineering notes

Three things to know before you design around this.

Emit your own events
POST /v1/webhooks/emit takes any event_type string and delivers it to matching subscriptions. Our own documentation uses memory.fact.changed as the example payload — that is an example of what you can emit, not something we fire. No emitter for it exists in the codebase. Treat the table above as the list of automatic events and this endpoint as the way to publish your own.
Delivery is best-effort today
There is no retry worker and no backoff schedule. A delivery is attempted once, with a timeout of WEBHOOK_TIMEOUT_SECONDS (10 by default), and the outcome is written to webhook_deliveries. If your endpoint was down, the row records the failure and nothing re-attempts it. Poll the deliveries table, or design your consumer to reconcile from /v1/fabric/timeline and /v1/sessions.
No SDKs yet
There is no published TypeScript or Python client. The API is ordinary JSON over HTTP with an OpenAPI document, so generating a client is a one-line job — but we are not going to imply a maintained SDK exists when it does not. For agents, the MCP server is the shipped integration path.

Give your agents a memory you can audit

Run the whole system on your own hardware under the MIT licence, or ask us about hosted access. Both start from the same place.