Error handling
Every API error code, what it means, and how to handle it: the JSON error shapes, codes such as read_only_key and plan_required, and when to retry.
By Operelio team · Updated October 30, 2026
On this page7
Error response shape
Every error response is JSON with an "error" field explaining what went wrong, plus the HTTP status code. Some errors include extra context fields (e.g. "requiredPlan": "agency" on a 403, or a "Retry-After" header on 429).
The endpoints for trails, leads, Do Not Contact, workflows, CRM and reports always add a "code" next to "error": a short, stable word to switch on, while "error" is a sentence to show a person. The file and job endpoints (/uploads, /jobs, /jobs/batch and /files) keep the shapes below.
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "Upgrade to Agency to use batch processing.",
"requiredPlan": "agency"
}Status codes
| Code | Meaning | What to do |
|---|---|---|
| 400 | Bad request | Your request body is missing required fields, has invalid values, or is too big. Common causes: malformed JSON, unknown toolType, configJson over 100 KB (on /jobs), or a body shape the route can't parse. Both /jobs and /jobs/batch return {"error": "Invalid JSON body."} for an unparseable body. |
| 401 | Unauthorized | Your API key is missing, invalid, or revoked. Check the Authorization header is set to "Bearer op_…" with a valid key. |
| 403 | Forbidden | Your plan doesn't allow this. Two sub-cases: (a) the endpoint or tool requires a higher plan, for example batch processing requires Agency (a key whose workspace has lost API access altogether gets 401, not 403). (b) A feature that saves or sends requires Starter (e.g. saved mappings, custom CRM schemas, CRM push). The requiredPlan field tells you which plan you'd need. Running out of monthly jobs is a 429, not a 403. |
| 404 | Not found | The job ID, file ID, or batch ID doesn't exist or doesn't belong to you. Double-check the ID and that you're calling with the same key that created the resource. |
| 413 | Payload too large | Your /jobs/batch request body exceeds 100 KB, or your /uploads request declares a Content-Length over 4.5 MB (the most one request can carry, whatever your plan's cap). Our error body names maxRequestBytes; the host may send its own plain 413 instead. (For /jobs, the same 100 KB configJson cap returns 400 instead. On /uploads, a file that slips past the Content-Length check but exceeds your plan's file size cap after parsing returns 400.) |
| 429 | Too many requests | One of four things: (a) burst rate limit hit (60/min), error "Rate limit exceeded" or, on the newer endpoints, code "rate_limited", Retry-After counts down the current window. Wait that many seconds and retry. (b) Monthly API call cap spent (Pro 1,000, Agency 5,000), on any request that counts (uploads, jobs, batches, pushes, trail and routing changes, Do Not Contact changes, saved workflow runs, Bridgeant questions that use a message and confirms that run, never a read), code "api_monthly_limit_reached", with limit, used, and resetAt in the body. Has a Retry-After too, but it counts down to your cycle reset. Don't sleep on it. (c) Monthly jobs used up, on /uploads, /jobs or /jobs/batch: code "job_monthly_limit_reached", with limit, used, and resetAt in the body (a batch adds requested and remaining). No Retry-After. Wait until resetAt, upgrade to the plan in requiredPlan, or send a smaller batch. (d) Concurrent job limit hit (1 Free / 2 Starter / 3 Pro / 5 Agency), error mentions "jobs running". No Retry-After. Wait for a running job to finish. Switch on the error string and the code field to tell them apart. |
| 5xx | Server error | 500/502/503/504 mean something went wrong on our end. Retry with exponential backoff. If it persists, contact support with the timestamp and the request you sent. |
Codes on the newer endpoints
Every response from the trails, leads, Do Not Contact, workflows, CRM, reports and Bridgeant endpoints that isn't a success carries an error sentence and a code, except Bridgeant's 401 and its burst-limit 429, which keep the /jobs shape. These are the codes to handle:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "This API key is read only. Make a key with read and write access for this.",
"code": "read_only_key"
}| Status | Code | What it means |
|---|---|---|
| 403 | read_only_key | A write with a read-only key. Make a key with read and write access. |
| 403 | admin_key_required | A change to a trail, a routing team, the Do Not Contact list or a CRM push, with a key whose maker isn't an admin of the workspace any more, or has no maker. Have an admin make the key. |
| 403 | plan_required | The feature needs a higher plan. The error sentence names it. |
| 403 | plan_trail_cap | Resuming would pass your plan's limit on live trails. upgradeRequired is true when a higher plan allows more. |
| 404 | trail_not_found, run_not_found, lead_not_found | No such id in this workspace. |
| 409 | status_changed | The trail's state changed at the same moment. Read it again, then decide. |
| 409 | ambiguous_workflow_name | Two saved workflows share that name. Run the one you mean by its id. |
| 409 | trail_live_change_needs_test | The trail is on. Switch it off first, or change the team in the app. |
| 409 | trail_draft_pending | The trail has routing changes on its canvas that aren't published. Publish or discard them in the app, then try again. |
| 409 | no_crm | The trail sends to no CRM, so its teams have no CRM users to name. |
| 409 | routing_refused | A rule names a territory that was deleted, or territories were being changed at that moment. The error sentence says which. |
| 422 | invalid_team | The team you sent breaks the routing rules' own limits. The error sentence says what to fix. |
| 403 | workspace_archived | The workspace is archived, so its trails can't be changed. |
| 4xx | job_refused | POST /workflows/run couldn't start the job and the job gave no code of its own. The status and the error sentence say why. |
| 409 | version_conflict | The trail changed since the ifVersion you sent. Read its routing again and send the new version. currentVersion has it. |
| 422 | no_folder, no_destination, mapping_missing, draft_pending, builder_setup_needed, schedule_invalid and the other switch-on checks | Resume failed one of the checks the Switch on button runs. The error sentence says what to fix. |
| 422 | owner_not_active | A routing team names someone who isn't an active user of the trail's CRM. ownerIds lists them. |
| 422 | cap_exceeded | The Do Not Contact entries would take the list past your plan's cap. Nothing was added. |
| 400 | invalid_input | The Bridgeant ask has no message, or its conversationId or fileId isn't text. |
| 400 | message_too_long | The message to Bridgeant is over 8,000 characters. Upload a file instead of pasting it. |
| 403 | key_has_no_creator | Bridgeant answers as the person who made the key, and this key has no maker on record. Make a new key. |
| 403 | creator_not_member | Bridgeant answers as the person who made the key, and they aren't in the workspace now. |
| 403 | bridgeant_off | Bridgeant is switched off for the key's maker or for the workspace. |
| 404 | conversation_not_found | No conversation with that id for the key's maker in this workspace. |
| 404 | action_not_found | No action with that id waiting for the key's maker. Ask Bridgeant again. |
| 409 | already_run | That action has already run. Ask Bridgeant again to run it once more. |
| 409 | skipped_quota | The trail run was skipped because the month's jobs are used up. Nothing ran, and the call didn't count. |
| 409 | plan_changed | The plan changed after Bridgeant offered to save it as a trail. Ask again. |
| 409 | not_run, or the plan's own reason | The plan didn't run. Nothing was used, and the call didn't count. |
| 429 | slow_down | The key's maker sent Bridgeant more than 30 messages in a minute, counting the chat in the app. Wait for Retry-After. |
| 400 or 409 | job_not_pushable, already_pushed, held, connection_inactive, provider_mismatch | A push couldn't start: the job isn't a finished file that can be pushed, it was pushed already, it is held, the CRM connection is off, or it's for another CRM. |
| 409 | push_in_progress | A push of this job is already running. Read its status with GET /jobs/:id/push. |
| 401 or 409 | crm_reconnect_needed | The CRM refused Operelio's sign-in: 401 on a push, 409 when reading CRM owners or changing a team. Reconnect it in the app. Your API key is fine. |
| 503 | crm_unreachable | The CRM couldn't be reached. Try again in a minute. |
What to retry, what not to
Retry strategy depends on which kind of 429 you got and on whether the response is server-side. Switch on the response shape, not just the status code:
| Response | Retry strategy |
|---|---|
| 429, error "Rate limit exceeded" or code "rate_limited" | Burst limit. Wait the number of seconds in the Retry-After header, then retry. |
| 429, code "api_monthly_limit_reached" | Monthly API call cap spent. Don't retry: the Retry-After on this one counts down to your cycle reset (the resetAt field), which can be days away. Wait for the cycle to roll or upgrade. |
| 429, code "job_monthly_limit_reached" | Monthly jobs used up. Don't retry. Wait until resetAt, upgrade your plan, or for a batch, send no more files than remaining. |
| 429 without Retry-After, error mentions "jobs running" | Concurrent job limit hit (1 Free / 2 Starter / 3 Pro / 5 Agency). Wait for a running job to finish, then retry. A short polling delay (a few seconds) usually clears it. |
| 5xx | Server error. Exponential backoff: 1s, 2s, 4s, 8s, 16s. Give up after 5 attempts. Covers 500, 502, 503, and 504. |
Don't retry these
These responses mean your request itself is the problem. Retrying without changing it fails the same way. Fix the request, the plan, or wait for next month.
| Response | Why retry won't help |
|---|---|
| 400 | The body is malformed or missing required fields. Check the error message and fix the request. |
| 401 | The key is invalid or revoked. Generate a new key and update your integration. |
| 403 (plan or feature gate) | The endpoint or sub-feature isn't on your plan. Upgrade to the plan in the requiredPlan or plan field. |
| 403 (read_only_key, admin_key_required) | The key can't write. Use a read-and-write key made by an admin. |
| 409 (status_changed) | Something changed between your read and your write. Read again, then decide whether to send it again. |
| 422 | A check failed or a cap was reached. Fix what the error sentence names first. |
| 404 | The ID doesn't exist. Verify you're sending the right ID and using the same key that owns the resource. |
| 413 | The batch request body is over 100 KB, or the upload is over 4.5 MB. Trim configJson, split the batch, or send a smaller file before retrying. |
| 429 (quota or concurrent limit) | A 429 with code "api_monthly_limit_reached" or "job_monthly_limit_reached" is a spent monthly cap: wait for next cycle or upgrade. A 429 mentioning "jobs running" is the concurrent limit: wait for one of your running jobs to finish before retrying. Only the burst-limit 429 ("Rate limit exceeded" or code "rate_limited") is worth an automatic retry. |
Retry snippet
A minimal retry helper in JavaScript that handles every retryable case correctly. It retries burst-limit 429s (honoring Retry-After) and 5xx server errors (exponential backoff with cap). It does not retry other 4xx errors, 429s without Retry-After (monthly jobs or concurrent limit), or the monthly API cap 429 (code "api_monthly_limit_reached", whose Retry-After counts down to your cycle reset and can be days long).
async function callApi(url, options, attempt = 1) {
const res = await fetch(url, options);
// Success or non-retryable client error: return as-is.
if (res.status < 500 && res.status !== 429) return res;
if (res.status === 429) {
// Three kinds of 429 aren't worth an automatic retry: the monthly API
// cap (code "api_monthly_limit_reached", whose Retry-After counts
// down to your cycle reset, which can be days), the monthly jobs
// limit (code "job_monthly_limit_reached"), and the concurrent limit
// (no Retry-After at all).
const body = await res.clone().json().catch(() => null);
if (body && body.code === "api_monthly_limit_reached") return res;
if (body && body.code === "job_monthly_limit_reached") return res;
if (!res.headers.get("Retry-After")) return res;
}
// Give up after 5 attempts.
if (attempt >= 5) return res;
// Burst 429: honor Retry-After. 5xx: exponential backoff.
const delay =
res.status === 429
? Number(res.headers.get("Retry-After")) * 1000
: Math.min(1000 * 2 ** (attempt - 1), 16_000);
await new Promise((r) => setTimeout(r, delay));
return callApi(url, options, attempt + 1);
}The helper retries burst-limit 429s (error "Rate limit exceeded" or code "rate_limited") and 5xx errors. It does not retry 400/401/403/404, 429s without Retry-After, or the monthly API cap 429, all of which need outside intervention.
Frequently asked questions
Does the API ever return errors that aren't in this list?
The standard codes above cover almost everything. Some endpoints include extra context fields alongside the error (e.g. "requiredPlan" on plan-gated 403s, "totalRows" and "maxTotalRows" on a batch row-cap rejection). Always parse the JSON body for details.
Why is the apply_fixes error a code instead of a sentence?
When you exceed the cap of 3 apply_fixes rounds per Data Health Check, the response is { "error": "apply_fixes_limit_reached", "limit": 3, "used": N }. The "error" field is a machine-readable code so your integration can switch on it directly without parsing prose. Other errors carry a machine-readable code in a separate "code" field alongside a human-readable "error": "api_monthly_limit_reached", which any write returns when the monthly API call cap is spent, "job_monthly_limit_reached", which /uploads, /jobs and /jobs/batch return when your monthly jobs are used up, and every error from the trails, leads, Do Not Contact, workflows, CRM and reports endpoints. The other file and job errors use a human-readable string.
What does a 200 response with status "failed" mean?
GET /jobs/:id always returns 200 if the job exists, with the job's status in the body. "failed" means the worker tried to process the job and ran into an error, like a corrupt file or a parsing failure. The errorMessage field explains what went wrong. This is different from a 4xx or 5xx, which means the API call itself didn't reach the worker.
How do I tell if my key was revoked vs never existed?
Both deliberately return 401 with the same "Unauthorized" message. Telling them apart would leak information about which keys exist. Check the API keys tab (open Integrations in the rail and choose API keys) to see your active and revoked keys.
Connect Operelio to your code or your assistant
Start free and add Operelio to Claude or ChatGPT today. The API and webhooks come with Pro.