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.
| Header | Direction | What 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.
| Route | What 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.
| Route | Bucket | Default |
|---|---|---|
/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.
X-Request-ID ingest-batch-41
// response
X-Request-ID ingest-batch-41
// audit_events row
operation INGEST
memory_id m_9f02
request_id ingest-batch-41
prev_hash 7f2a41…
row_hash 9c4108…
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.
{
"event_types": ["fabric.decision.created",
"session.ingested"],
"target_url": "https://you.example/hook",
"secret": "…"
}
// delivered body
{
"event_type": "fabric.decision.created",
"tenant_id": "…",
"user_id": "local",
"payload": { … },
"created_at": "2026-08-16T09:14:02Z"
}
The events the server emits
This is the complete list. If an event name is not here, nothing in the codebase fires it.
| Event | Fired 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.
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.
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.
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.