API REFERENCE

Error reference

Every error code the API can return, its real HTTP status, and the usual fix. The body is flat: the code is the value of the error field.

When a request fails, the API returns a JSON body whose error field is the stable code string. A human-readable detail may sit beside it. There is no nested object and no code field. Switch on error; treat detail as diagnostic text that can change between releases.

json
{
  "error": "RATE_LIMIT_EXCEEDED",
  "detail": "Retry after 37s"
}

Reading an error

error is always present on a coded failure. detail is optional: some responses, such as {"error":"PROJECT_INVALID_KEY"} and {"error":"VALUE_REQUIRED"}, carry the code alone. A handler that reads body.error.code will read undefined on every error this API produces.

javascript
const res = await fetch("https://agentifys.ai/v1/credentials", {
  method: "PUT",
  headers: {
    Authorization: "Bearer era_your_project_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify(payload),
});

if (!res.ok) {
  const body = await res.json().catch(() => ({}));
  // The code IS body.error. POST /v1/chat is the one endpoint that answers a
  // bad key with {"detail":"Invalid API key"} and no error field — see below.
  const code = body.error || (res.status === 401 ? "PROJECT_INVALID_KEY" : "UNKNOWN");

  if (code === "RATE_LIMIT_EXCEEDED") {
    const wait = Number(res.headers.get("Retry-After") || 1);
    await new Promise((r) => setTimeout(r, wait * 1000));
    // retry once here
  } else if (code.startsWith("IDENTITY_")) {
    // re-sign the user_context server-side and retry
  } else {
    throw new Error(`${code}: ${body.detail || res.statusText}`);
  }
}
One endpoint breaks the shape. POST /v1/chat answers an unrecognised key with a 401 and {"detail":"Invalid API key"} — no error field at all. Omitting the Authorization header entirely on that endpoint is a 422 validation error, not a 401, because the header is a required parameter. Treat any 401 or 422 on /v1/chat as unauthenticated regardless of the body.

Keys, origins, and sessions

FieldTypeDescription
PROJECT_INVALID_KEY401The Bearer key is missing, malformed, or does not match a project. Check that you sent Authorization: Bearer era_... and that the key is current.
ORIGIN_NOT_ALLOWED403The request Origin is not in the project allowlist. The comparison is an exact string match on scheme, host, and port, with one trailing slash trimmed.
SESSION_NOT_FOUND403The session_id belongs to a different project, or (in signed mode) to a different user. It is 403, not 404: the server will not confirm whether the id exists. Omit session_id to start a new session, or use one returned by a prior "session" event.
If an allowlisted project starts returning ORIGIN_NOT_ALLOWED from your backend, do not add a proxy hop. A server-to-server request sends no Origin header at all, so an allowlist that is set to anything rejects it. Either add the exact browser origin in Settings, or leave the allowlist empty on a project whose key is only used from your own server — an empty allowlist skips the check.

Identity and signatures

These surface on signed-mode features: private knowledge, the vault, per-user MCP OAuth, and scheduled tasks. The five IDENTITY_* codes all arrive as a 401 carrying the same detail, "This project requires a signed user_context.", so the error field is the only thing that tells them apart.

FieldTypeDescription
IDENTITY_NOT_CONFIGURED401The project is in signed mode but has no signing secret yet. Re-save identity settings in Settings to mint one.
IDENTITY_ID_REQUIRED401The signature verified but the user_context has no id. A signed identity is per-user and must carry a stable id.
IDENTITY_SIGNATURE_REQUIRED401The project is in signed mode but the user_context is missing _sig, _ts, or both. Attach the HMAC signature and the timestamp.
IDENTITY_SIGNATURE_INVALID401The _sig did not verify, or _ts was not an integer. Usually a canonicalization mismatch (sorted keys, compact separators, exact bytes) or the wrong signing secret.
IDENTITY_SIGNATURE_EXPIRED401The _ts is more than 300 seconds from server time, in either direction. Sign the user_context server-side just before the request, and check clock skew.
SIGNED_IDENTITY_REQUIRED403The project itself is in open mode and you called a signed-only endpoint (knowledge, credentials, scheduled tasks, per-user OAuth). Switching the project to signed identity is the fix; signing the payload is not enough.
USER_ID_REQUIRED400The identity verified, but its id is empty or the literal "anonymous". Private per-user endpoints need a real, non-anonymous id to own the data.
The signed bytes are the context including _ts and excluding _sig, serialized with keys sorted and the separators , and : — and with every non-ASCII character escaped as \uXXXX. That last part is the usual cause of a signature that verifies for Sara and fails for سارة: a JavaScript signer emits the raw UTF-8 character where the server hashed the escape. The signing helper is in the signed identity guide.

Rate limits and budget

FieldTypeDescription
RATE_LIMIT_EXCEEDED429A request-rate window is full. The response carries a Retry-After header in whole seconds, and detail repeats it as "Retry after Ns".
BUDGET_EXCEEDED402The monthly token budget hard stop tripped. Chat is refused until the month rolls over or you raise the cap in Settings. Because you bring your own model key, this is your own guardrail, not an Agentifys charge.

Rate limiting is on the moment a project is created, at 20 requests per minute and 500 per day. It is not opt-in; you turn it off or raise it in Settings. POST /v1/chat checks two separate buckets against those same numbers — one for the whole project, one keyed to the user id in the request — so a single user can exhaust the project window before their own.

Endpoints outside chat carry their own fixed limits, independent of the project setting:

FieldTypeDescription
POST /v1/knowledge10 rpm / 50 rpdPer user. Sets no Retry-After header; read the seconds out of detail, which reads "Retry after 37s".
PUT /v1/credentials30 rpm / 300 rpdPer user. Sets Retry-After.
POST /v1/oauth/mcp/start10 rpm / 60 rpdPer user. Sets Retry-After.
POST /v1/feedback20 rpm / 200 rpdPer session. Returns the seconds as a numeric retry_after field in the body instead of a Retry-After header.
Counters live in each API process's memory. A restart or deploy clears every window, and a deployment running several workers multiplies the effective ceiling by the worker count. Size limits as a safety net against a leaked widget key, not as exact metering.

Knowledge uploads

FieldTypeDescription
FILE_TOO_LARGE413The upload is over the 50 MB cap. Split or compress the document.
UNSUPPORTED_TYPE415The extension is not .pdf, .txt, or .md. The check is on the filename extension, so rename or convert before uploading.
DOC_CAP_REACHED409The user already has 20 documents. Delete one with DELETE /v1/knowledge/{doc_id} to free a slot.
INDEXING_PAUSED503Embedding is switched off server-wide by the INDEXING_ENABLED kill switch. Search over already-indexed documents keeps working; new uploads are refused until it is re-enabled. Retry later rather than changing the request.
NOT_FOUND404The doc_id or job_id does not exist, or is not owned by this user and project. Returned bare, with no detail.
The order of checks is: indexing switch, size, extension, rate limit, document cap. A .docx is therefore refused on its extension before it counts against the upload rate limit or the 20-document cap.

Credentials

FieldTypeDescription
INVALID_NAME400The credential name must be 1 to 64 characters of letters, digits, or _ . - with nothing else. detail repeats the rule.
VALUE_REQUIRED400The value was empty. Returned bare, with no detail. To remove a credential use DELETE /v1/credentials/{name} rather than writing an empty value.
VALUE_TOO_LARGE413The value is over 8192 bytes when UTF-8 encoded. Returned bare, with no detail.
CRED_CAP_REACHED409The user already holds 50 credentials and this name is a new one. Overwriting an existing name is always allowed.
NOT_FOUND404DELETE named a credential this user does not have.

Scheduled tasks and per-user OAuth

FieldTypeDescription
NOT_FOUND404The task_id is unknown or not owned by this user, or no per-user OAuth server exists with that server_id. On PATCH and DELETE of a task, returned bare with no detail.
OAUTH_UNAVAILABLE400The deployment has no PUBLIC_BASE_URL set, so there is no redirect URI to hand the provider. This is a server configuration gap, not a client mistake.
NOT_CONFIGURED400The MCP server is marked per-user OAuth but an admin has not finished registering the OAuth client (no authorization endpoint or client id yet).

Failures inside the chat stream

Once POST /v1/chat has answered 200 and the stream is open, a failure cannot change the status code. It arrives instead as an SSE error event followed by data: [DONE]. The event carries a single content string:

text
data: {"type":"error","content":"PROJECT_NO_API_KEY"}

data: [DONE]

Only one value of content is a stable code you can switch on:

FieldTypeDescription
PROJECT_NO_API_KEYstream onlyNo usable model key is configured, or every configured key failed authentication in turn. Add or repair a provider key (Anthropic, OpenAI, or Google) in Settings. This code never appears as an HTTP status.
Every other stream failure is an English sentence, not a code. A provider rate limit sends "The AI provider is temporarily rate-limited. Please wait a moment and try again.", an unhandled agent error sends "Agent error: ...", and anything escaping the stream wrapper sends "An unexpected error occurred. Please try again." Do not parse these. Match on the exact string PROJECT_NO_API_KEY, and show anything else to the user as-is.

Tool errors

TOOL_UNAVAILABLE and TOOL_DISABLED are never HTTP statuses and never stream error events. They are the tool's own return value, delivered inside the result of a tool_end event. The request stays 200 and the turn keeps going. Decoded, the event looks like this:

json
{
  "type": "tool_end",
  "id": "call_1",
  "name": "lookup_order",
  "result": {
    "error": "TOOL_UNAVAILABLE",
    "message": "This tool is temporarily unavailable. Please try again later.",
    "detail": "HTTP 502 (attempt 4/4): upstream gateway"
  }
}
FieldTypeDescription
TOOL_UNAVAILABLEtool resultGenuine breakage: a webhook that exhausted its retry budget, a URL blocked by the SSRF guard, a webhook that answered with a redirect, an MCP server that errored, or a static auth token the server rejected. This one is logged as a failure and fires the admin webhook alert.
TOOL_DISABLEDtool resultAn expected off-state: the MCP server is paused or unbound, a per-user OAuth account is not connected or needs reconnecting, or a {{user.creds.*}} placeholder in the auth header resolved to nothing. Deliberately not alerted, so a lapsed user token does not page an admin on every call.

Both shapes carry a message written for the end user and a detail written for whoever has to fix it. The agent sees the whole object, so it can tell the user what happened and may try another path.

A failing tool does not fail the reply, and it does not abort a write awaiting approval. A tool paused on a confirmation stays paused until the user hits Approve or Cancel in the widget. Webhook retries are conservative by design: a write is replayed only when the request provably never reached your server, so an ambiguous timeout surfaces as TOOL_UNAVAILABLE rather than risking a double-apply.