Errors
Every failure returns the same JSON envelope, whatever went wrong:
{
"error": {
"code": "missing_field",
"message": "Mandatory attribute missing",
"errors": [
{ "field": "channel", "code": "missing_field", "message": "Mandatory attribute missing" }
]
}
}
| Field | Notes |
|---|---|
error.code | A stable, machine-readable code. Branch on this, never on the message |
error.message | A human-readable sentence, safe to log. Wording may change; treat it as prose |
error.errors[] | Per-field detail, present only when the failure is attributable to specific fields. Each entry has field, code and message |
error.existing_id | Present only on a 409 duplicate_value. The id of the record that already holds your value — see the API reference |
Internal diagnostics are never exposed. There is no stack trace, no Sentry id and no internal metadata in a public response.
Error codes
| Code | HTTP | Means |
|---|---|---|
missing_field | 400 | A required attribute was not supplied |
invalid_value | 400 | An attribute was supplied but its value is not acceptable |
datatype_mismatch | 400 | An attribute has the wrong type |
invalid_field | 400 | An attribute is not part of this request's contract |
invalid_json | 400 | The request body is not parseable JSON |
invalid_credentials | 401 | The Authorization header is missing, malformed, or the key is unknown, revoked or expired |
token_expired | 401 | The presented credential is no longer valid for time-based reasons |
access_denied | 403 | Authenticated, but not allowed: a missing scope, or credentials sent in the query string |
feature_not_enabled | 403 | The operation depends on a feature that is not switched on for this account or AI agent |
not_found | 404 | No such record in your account. Also returned instead of 403 where confirming existence would leak information |
duplicate_value | 409 | A uniqueness constraint was violated. The response carries the existing record id |
immutable_state | 409 | The record is in a state that does not allow this change |
inconsistent_state | 409 | The request conflicts with related data, such as deleting something that still has dependents |
unsupported_type | 415 | The content type or file type is not accepted |
file_too_large | 413 | An upload exceeded the permitted size |
rate_limit_exceeded | 429 | The key's per-minute ceiling was hit. Read Retry-After |
server_error | 500 | Something failed on our side. Safe to retry with backoff |
Validation failures
Validation is applied to the query string and the body before a handler runs, and reports the offending field:
curl -X POST https://api.kaily.ai/v1/ai-agents \
-H "Authorization: Bearer kly_live_a1b2c3d4e5f6_s3cr3tvalue" \
-H "Content-Type: application/json" \
-d '{}'
{
"error": {
"code": "missing_field",
"message": "Mandatory attribute missing",
"errors": [
{ "field": "name", "code": "missing_field", "message": "Mandatory attribute missing" }
]
}
}
A value that exists but is not acceptable comes back as invalid_value, and the message names the acceptable set where there is one:
{
"error": {
"code": "invalid_value",
"message": "enabled must be a boolean",
"errors": [
{ "field": "enabled", "code": "invalid_value", "message": "enabled must be a boolean" }
]
}
}
Where a value looks like a fixed list, check the reference before assuming it is one. Several of them are configured per account, so the valid set differs between accounts and changes over time. A client built around a handful of values breaks the moment an admin adds one.
Duplicates are safe to retry
A create that collides with a record you already made returns 409 duplicate_value with the existing record's id attached:
{
"error": {
"code": "duplicate_value",
"message": "A record with these values already exists",
"existing_id": "cop-7Hs2Kd"
}
}
This is the intended mechanism for safe retries rather than an error to be surprised by. If your first attempt timed out but actually succeeded, the retry tells you so and hands you the id.
Handling errors in practice
| Class | What to do |
|---|---|
400 — any code | A bug in your request. Fix the payload; retrying unchanged will fail identically |
401 invalid_credentials | Stop and alert. Rotating or restoring the key is a human action; retrying will not help |
403 access_denied | Read the message — it names the missing scope. Add it to the key |
404 not_found | Confirm the id belongs to your account, and that you are not using an id from another environment |
409 duplicate_value | Treat as success and adopt existing_id |
409 immutable_state / inconsistent_state | Re-read the record; the state you assumed no longer holds |
429 rate_limit_exceeded | Sleep for Retry-After seconds, then retry. Do not retry sooner |
5xx | Retry with exponential backoff and a jitter |
A minimal client that gets this right:
async function kaily(path, init = {}, attempt = 1) {
const response = await fetch(`https://api.kaily.ai/v1${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.KAILY_API_KEY}`,
'Content-Type': 'application/json',
...init.headers
}
});
if (response.ok) return response.json();
const body = await response.json().catch(() => ({ error: { code: 'server_error' } }));
const { code, message, existing_id: existingId } = body.error;
// A duplicate is not a failure: adopt the record we already created.
if (code === 'duplicate_value' && existingId) return { data: { id: existingId }, duplicate: true };
const retryable = response.status === 429 || response.status >= 500;
if (retryable && attempt < 5) {
const retryAfter = Number(response.headers.get('Retry-After'));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 2 ** attempt * 250 + Math.random() * 250;
await new Promise(resolve => setTimeout(resolve, waitMs));
return kaily(path, init, attempt + 1);
}
throw new Error(`Kaily ${response.status} ${code}: ${message}`);
}