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

CodeStatusWhat it means
beta_limit429A plan allowance is used up, for documents or for questions this month. Carries limit, plan and upgrade_url.
ingest_quota429The daily ingest ceiling for this workspace was reached. Carries cap, used and resets_at.
corpus_size429One document base would hold more documents than a single base is allowed. Split the rest into another base.
rate_limited429Too many calls per minute. Carries retry_after, and the Retry-After header says the same thing.
plan_feature403The workspace plan does not include this feature. Carries feature and upgrade_url.
ip_not_allowed403The workspace restricts source IPs and this request came from outside them. See IP allowlist.
legal_acceptance_required403A workspace admin has to accept the current terms first. Carries document, version and accept_url.
tenant_suspended403The workspace is suspended. Everything stops, on purpose.
email_unverified403The account has not confirmed its email address yet, so it cannot add documents or create a base.
trial_expired, subscription_canceled, checkout_required402No active plan. See Checkout, portal and changing plans.
past_due402A payment failed and the grace period ran out, so the workspace is read-only: questions still work, writes stop.
duplicate_name409A document base of that name already exists in the workspace. Carries existing_id.
serving_unavailable503Serving 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"}' 

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.

RouteLimitBucket
POST /corpora/{id}/chat, /chat/stream, /mcp/{id}/query30 per minutePer workspace
The same three routes, againAPI_KEY_QUERY_RATE, 60 per minute by defaultPer credential, so one runaway key cannot starve the others
POST /corpora/{id}/train, /describe5 per minute Per workspace
POST /corpora/{id}/upload-credentials10 per minute Per workspace
POST /corpora/{id}/feedback60 per minutePer 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.