REST API
Conventions and errors
One error shape, one paging shape, idempotency on every write and a request id on every response. Learn these once and every route behaves the way you expect.
These rules hold across every route under /v1, so you write the parsing, paging and
retry code once. The unversioned paths keep the older shapes for the clients already written
against them.
The error envelope
Every failure under /v1 comes back in one shape:
{
"error": {
"code": "plan_feature",
"message": "Webhooks come with the Pro plan. Move up a plan in billing and it turns on right away.",
"status": 403,
"details": {"feature": "webhooks", "upgrade_url": "/billing"},
"request_id": "3f9a1c7e5b2d4f80a6c1e9d3b7a2f5c8"
}
}
Switch on code, show message, quote request_id to support.
details is omitted entirely when there is nothing extra, so you can test for the key
rather than for null. Retry-After arrives as a header on the refusals that have one,
and a backing-off client should read the header rather than the body.
On the unversioned paths the same refusals render the older way: {"detail": "..."}
for a plain message, {"detail": {"error": ..., "message": ...}} for the gates below,
and a list of field errors for a validation failure. Same statuses, same codes, three shapes instead
of one.
Error codes worth handling
| Code | Status | What it means |
|---|---|---|
beta_limit | 429 | A plan allowance is used up, for documents or
for questions this month. Carries limit, plan and
upgrade_url. |
ingest_quota | 429 | The daily ingest ceiling for this workspace
was reached. Carries cap, used and resets_at. |
corpus_size | 429 | One document base would hold more documents than a single base is allowed. Split the rest into another base. |
rate_limited | 429 | Too many calls per minute. Carries
retry_after, and the Retry-After header says the same thing. |
plan_feature | 403 | The workspace plan does not include this
feature. Carries feature and upgrade_url. |
ip_not_allowed | 403 | The workspace restricts source IPs and this request came from outside them. See IP allowlist. |
legal_acceptance_required | 403 | A workspace admin has to
accept the current terms first. Carries document, version and
accept_url. |
tenant_suspended | 403 | The workspace is suspended. Everything stops, on purpose. |
email_unverified | 403 | The account has not confirmed its email address yet, so it cannot add documents or create a base. |
trial_expired, subscription_canceled,
checkout_required | 402 | No active plan. See Checkout, portal and changing plans. |
past_due | 402 | A payment failed and the grace period ran out, so the workspace is read-only: questions still work, writes stop. |
duplicate_name | 409 | A document base of that name already
exists in the workspace. Carries existing_id. |
serving_unavailable | 503 | Serving cannot answer right now.
Carries retry_after and state. Ingestion is unaffected. |
A refusal never blocks you from leaving with your own data. Listing, every export, every delete and the billing routes stay open whatever the subscription says, so a workspace that lapses can still read, download and remove everything it put in.
Pagination
List routes under /v1 take limit (default 50, maximum 500) and an opaque
cursor, and answer with an envelope rather than a bare array:
curl "https://api.engramdynamics.org/v1/corpora?limit=2" \
-H "Authorization: Bearer <your key>"
{
"items": [{"id": "c_7a1f...", "name": "Support handbook"}],
"next_cursor": "Y29ycHVzX2Fi"
}
The same two facts ride as the X-Total-Count and X-Next-Cursor headers,
for a script that would rather not parse the body. Follow next_cursor until it is null.
An empty page is not the stop condition, because a filter can empty a page that still has
successors.
def every(path, params=None):
cursor = None
while True:
query = dict(params or {}, limit=500)
if cursor:
query["cursor"] = cursor
page = requests.get(f"{BASE}{path}", params=query, headers=HEADERS, timeout=30).json()
yield from page["items"]
cursor = page.get("next_cursor")
if not cursor:
return
Three routes own their own limit and push it into the database:
/v1/audit, /v1/audit/deletions and
/v1/corpora/{id}/sync-runs. They keep their own page size and return the envelope and
X-Total-Count without a cursor. A bad cursor is a 400 invalid_cursor: start
the listing again without one.
Idempotency keys
Send Idempotency-Key on any POST, PUT, PATCH or DELETE under /v1 and a
retry stops being a risk:
curl -X POST https://api.engramdynamics.org/v1/corpora \
-H "Authorization: Bearer <your key>" \
-H "Idempotency-Key: onboard-2026-09-13-run-7" \
-H "Content-Type: application/json" \
-d '{"name": "Support handbook"}'
- First call runs normally. The status, body and key headers are remembered for 24 hours.
- Same key, same body: the stored response comes back with
Idempotent-Replayed: trueand the work does not happen twice. - Same key, different body: 422
idempotency_mismatch. A key identifies one request, not one slot. - Same key, still running: 409
idempotency_in_progress, so a client that fired twice in parallel retries instead of racing itself.
Server errors release the key immediately, so a retry after a genuine 5xx does the work rather than replaying the failure for a day. The key is optional and nothing changes for a caller that does not send one. Records are scoped to the presenting credential, so one workspace can never replay another's response.
Request ids
Every response carries X-Request-Id, echoed when you send one and generated when you
do not. Under /v1 the same id is in the error body. Log it, and a support conversation
starts with an id rather than a timestamp.
Rate limits
These are fixed API constants that protect the shared serving hardware, not plan allowances. Plan allowances are on Plans and allowances, which is the one place the commercial numbers live.
| Route | Limit | Bucket |
|---|---|---|
POST /corpora/{id}/chat, /chat/stream,
/mcp/{id}/query | 30 per minute | Per workspace |
| The same three routes, again | API_KEY_QUERY_RATE, 60 per minute by
default | Per credential, so one runaway key cannot starve the others |
POST /corpora/{id}/train, /describe | 5 per minute | Per workspace |
POST /corpora/{id}/upload-credentials | 10 per minute | Per workspace |
POST /corpora/{id}/feedback | 60 per minute | Per workspace |
A refusal is a 429 with a Retry-After header and
code: "rate_limited". Ingestion routes are not rate limited this way: they queue.
Next
Create something to work with in Document bases, or jump to the endpoint index.