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.
{
"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.
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}`);
}
}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
| Field | Type | Description |
|---|---|---|
PROJECT_INVALID_KEY | 401 | The 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_ALLOWED | 403 | The 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_FOUND | 403 | The 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. |
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.
| Field | Type | Description |
|---|---|---|
IDENTITY_NOT_CONFIGURED | 401 | The project is in signed mode but has no signing secret yet. Re-save identity settings in Settings to mint one. |
IDENTITY_ID_REQUIRED | 401 | The signature verified but the user_context has no id. A signed identity is per-user and must carry a stable id. |
IDENTITY_SIGNATURE_REQUIRED | 401 | The project is in signed mode but the user_context is missing _sig, _ts, or both. Attach the HMAC signature and the timestamp. |
IDENTITY_SIGNATURE_INVALID | 401 | The _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_EXPIRED | 401 | The _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_REQUIRED | 403 | The 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_REQUIRED | 400 | The 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. |
_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
| Field | Type | Description |
|---|---|---|
RATE_LIMIT_EXCEEDED | 429 | A request-rate window is full. The response carries a Retry-After header in whole seconds, and detail repeats it as "Retry after Ns". |
BUDGET_EXCEEDED | 402 | The 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:
| Field | Type | Description |
|---|---|---|
POST /v1/knowledge | 10 rpm / 50 rpd | Per user. Sets no Retry-After header; read the seconds out of detail, which reads "Retry after 37s". |
PUT /v1/credentials | 30 rpm / 300 rpd | Per user. Sets Retry-After. |
POST /v1/oauth/mcp/start | 10 rpm / 60 rpd | Per user. Sets Retry-After. |
POST /v1/feedback | 20 rpm / 200 rpd | Per session. Returns the seconds as a numeric retry_after field in the body instead of a Retry-After header. |
Knowledge uploads
| Field | Type | Description |
|---|---|---|
FILE_TOO_LARGE | 413 | The upload is over the 50 MB cap. Split or compress the document. |
UNSUPPORTED_TYPE | 415 | The extension is not .pdf, .txt, or .md. The check is on the filename extension, so rename or convert before uploading. |
DOC_CAP_REACHED | 409 | The user already has 20 documents. Delete one with DELETE /v1/knowledge/{doc_id} to free a slot. |
INDEXING_PAUSED | 503 | Embedding 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_FOUND | 404 | The doc_id or job_id does not exist, or is not owned by this user and project. Returned bare, with no detail. |
.docx is therefore refused on its extension before it counts against the upload rate limit or the 20-document cap.Credentials
| Field | Type | Description |
|---|---|---|
INVALID_NAME | 400 | The credential name must be 1 to 64 characters of letters, digits, or _ . - with nothing else. detail repeats the rule. |
VALUE_REQUIRED | 400 | The 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_LARGE | 413 | The value is over 8192 bytes when UTF-8 encoded. Returned bare, with no detail. |
CRED_CAP_REACHED | 409 | The user already holds 50 credentials and this name is a new one. Overwriting an existing name is always allowed. |
NOT_FOUND | 404 | DELETE named a credential this user does not have. |
Scheduled tasks and per-user OAuth
| Field | Type | Description |
|---|---|---|
NOT_FOUND | 404 | The 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_UNAVAILABLE | 400 | The 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_CONFIGURED | 400 | The 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:
data: {"type":"error","content":"PROJECT_NO_API_KEY"}
data: [DONE]Only one value of content is a stable code you can switch on:
| Field | Type | Description |
|---|---|---|
PROJECT_NO_API_KEY | stream only | No 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. |
"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:
{
"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"
}
}| Field | Type | Description |
|---|---|---|
TOOL_UNAVAILABLE | tool result | Genuine 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_DISABLED | tool result | An 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.
TOOL_UNAVAILABLE rather than risking a double-apply.