Error codes
All error responses use a consistent JSON envelope:
{"error": "Human-readable description", "code": "MACHINE_READABLE_CODE"}Use the code field for programmatic handling - the error string may change
without notice. The code field is stable across API versions.
Full code table
The HTTP column is the status you get on /v1/. On /mcp it does not
apply: every error there is an HTTP 200 carrying a JSON-RPC error member,
including the authentication, entitlement and rate-limit refusals decided before
your tool runs. See MCP JSON-RPC error mappings
for the translation.
| Code | HTTP | Context | Fix |
|---|---|---|---|
MISSING_AUTH_HEADER |
401 | No Authorization header on a /v1/* or /mcp request. Over MCP this and the four codes below it arrive as JSON-RPC -32001 at HTTP 200, not as a 401 |
Add Authorization: Bearer zt_live_... header |
INVALID_AUTH_FORMAT |
401 | Header present but wrong format (not Bearer zt_live_...) |
Verify the header value and token prefix |
TOKEN_INVALID |
401 | Token not found or fails verification | Check that the token was copied correctly; create a new one if needed |
TOKEN_REVOKED |
401 | Token exists but was explicitly revoked, or it is a workspace token whose creator is no longer an Owner or Admin of the workspace | Create a new token, or ask an Owner or Admin to create one |
TOKEN_EXPIRED |
401 | Token's expires_at is in the past |
Create a new token |
PROJECT_MISMATCH |
403 | The project in the URL is not reachable by this token: another project for a project token, another workspace for a workspace token. A project that does not exist answers the same way, never 404 | Call GET /v1/projects to list the project ids the token reaches, or use a token that reaches this project |
ENTITLEMENT_DENIED |
403 | Workspace is not entitled to call this endpoint. Over MCP this arrives as JSON-RPC -32004 at HTTP 200, not as a 403 |
Contact support |
INSUFFICIENT_SCOPE |
403 | Token scope too low for this operation (e.g. read token on a write endpoint) |
Re-create the token with write or admin scope |
SUBSCRIPTION_READ_ONLY |
403 | The workspace is read-only: its subscription's paid period has ended, so every write (POST, PATCH, DELETE and the matching MCP tools) is refused while every read keeps working. GET /v1/projects/{project_id} reports it ahead of a write as write_access: false. A subscription that is past due, cancelling at period end, or awaiting payment confirmation but still inside its paid period is not affected and keeps full write access. Over MCP this arrives as JSON-RPC -32004 |
Renew the subscription in workspace billing settings. Retrying will not help until then |
RATE_LIMITED |
429 | Any rate-limit bucket exceeded. Over MCP this arrives as JSON-RPC -32003 at HTTP 200, not as a 429 (see below) |
Wait for the Retry-After seconds, then retry |
API_ADDON_INACTIVE |
403 | The API & MCP access add-on is not active for this workspace, the workspace is on a trial, or it has no subscription at all. Over MCP this arrives as JSON-RPC -32004 at HTTP 200, not as a 403 |
Enable API & MCP access in workspace billing settings |
PAID_PLAN_REQUIRED |
402 | The endpoint reads a paid-plan feature and the workspace's plan cannot be confirmed as paid. The features are website/pages, website/pages/{page_id}, brand-facts and answer-accuracy. Two cases do not get this code: a workspace on a trial is refused earlier with API_ADDON_INACTIVE (403), and a workspace whose paid plan has lapsed keeps reading the history it holds. Over MCP this arrives as JSON-RPC -32004 at HTTP 200, not as a 402 |
Subscribe to a paid plan |
PAYMENT_REQUIRED |
402 | The workspace's first payment is not yet confirmed, or its subscription has ended and the workspace is closed. A workspace in dunning keeps full access while it is inside its paid period, and one whose paid period has ended but which is still open is read-only (SUBSCRIPTION_READ_ONLY) rather than refused. Over MCP this arrives as JSON-RPC -32004 at HTTP 200, not as a 402 |
Resolve the billing issue in workspace settings |
VALIDATION_ERROR |
422 | Request parameters failed validation, including a date that is not a YYYY-MM-DD day or a date_from after today (UTC) |
Read the error field for which parameter failed |
DATE_RANGE_TOO_LARGE |
422 | date_to - date_from exceeds 365 days, or 3660 days on GET /v1/projects/{project_id}/prompts/{prompt_id} |
Narrow the date range |
NOT_FOUND |
404 | Resource ID does not exist or belongs to a different project, including a tag or URL PATCH on an id the project does not hold (a DELETE of one answers 204), a website page_id that is not a page of the project, and a property on a Search Console read that names a property the project has no active connection for |
Verify the ID; cross-project IDs return 404 not 403 |
PROJECT_NOT_FOUND |
404 | The project no longer exists upstream. Distinct from NOT_FOUND, which is about a resource inside a project, and from PROJECT_MISMATCH, which is a 403 for asking about a project the token does not reach. Returned by GET /v1/projects/{project_id}. Over MCP this arrives as JSON-RPC -32005 |
Nothing to retry. The project was deleted. A project token for it is now useless, so create one for a different project; a workspace token keeps working for the workspace's other projects, which GET /v1/projects lists |
PROMPT_ALREADY_EXISTS |
409 | The project already holds a prompt whose text matches the one sent. The comparison trims, collapses internal whitespace runs and ignores case, so Acme CRM and acme crm are one prompt, and it applies within the project only |
Use the existing prompt or update it instead. Nothing was created and no allowance was spent. existing_id names the match where the lookup reaches it, and a null there still means the text matched |
TAG_ALREADY_EXISTS |
409 | A tag with the same name already exists in this project, compared ignoring case. Returned by a create and by a rename onto another tag's name. existing_id names the tag that holds it |
Use the existing tag (existing_id) or choose another name. Nothing was created or changed |
BRAND_FACT_LIMIT_REACHED |
409 | The project already holds its 15 brand facts, paused ones included. Over MCP this arrives as JSON-RPC -32009 at HTTP 200 |
Delete or edit an existing fact, then retry |
BRAND_FACT_INVALID |
422 | The statement is empty, longer than 200 characters, or not a single claim about your own business. Over MCP this arrives as JSON-RPC -32602 at HTTP 200 |
Rewrite the statement as one short claim and retry |
PROBLEM_NOT_DISMISSABLE |
409 | Dismiss was called on an answer-accuracy problem that is not open. Over MCP this arrives as JSON-RPC -32009 at HTTP 200 |
Read the problem; only an open problem can be dismissed |
PROBLEM_NOT_DISMISSED |
409 | Undismiss was called on a problem that is not dismissed. Over MCP this arrives as JSON-RPC -32009 at HTTP 200 |
Read the problem; there is nothing to undo |
FEATURE_DISABLED |
404 | The answer-accuracy and brand-facts operations answer this while answer accuracy is not switched on. Over MCP this arrives as JSON-RPC -32005 at HTTP 200 |
Not caused by the request; retry later |
COMPETITOR_TAG_ALREADY_EXISTS |
409 | A competitor tag with the same name already exists in this project. Names are compared ignoring case, and a competitor tag may share its name with a prompt tag | Use the existing competitor tag (find it with GET /v1/projects/{project_id}/competitor-tags?q=) or choose another name |
URL_ALREADY_EXISTS |
409 | The project already tracks this URL, compared as the exact string (so https://x.com/a and https://x.com/a/ are two URLs). Returned by a create and by a PATCH onto another URL's value. existing_id names the tracked URL |
Use the existing URL (existing_id). Nothing was created or changed |
GSC_REAUTH_REQUIRED |
409 | The project's Google Search Console connection must be reconnected: Google revoked or expired its token. Returned by gsc/queries, gsc/pages and gsc/trends. A property that is not connected at all is not an error; those endpoints answer gsc_connected: false |
Reconnect the property in the ziptie.ai app. Retrying will not help until then |
ALREADY_RESOLVED |
409 | Prompt suggestion has already been accepted or dismissed, or its text matches a prompt the project already tracks under the comparison described for PROMPT_ALREADY_EXISTS |
Idempotent retries: treat as success and stop replaying the same accept/dismiss call. In the matching-text case nothing was created or billed and the suggestion is left pending, so dismiss it to stop it being offered |
INSUFFICIENT_CREDITS |
402 | The call would exceed the workspace's monitored-prompt slots for the project's check frequency (daily, weekly, or monthly). Returned by accepting a prompt suggestion, creating a prompt with monitoring_status: true, and resuming monitoring on a paused one, since each of those claims a slot. Creating a prompt with monitoring_status: false claims none and never returns this code. The workspace is entitled to write, it has simply used up the period's slots - contrast SUBSCRIPTION_READ_ONLY, which means it is not entitled at all. The body also carries check_frequency, cap, held and resets_at naming the exhausted bucket, its cap, the held count and when the window resets |
Free up slots by deleting or pausing monitored prompts, or raise the prompt counts on your subscription |
PROMPT_LIMIT_REACHED |
402 | The workspace has reached its prompt cap for the cadence the project runs on. The allowance is bought per cadence (daily, weekly, monthly) and the bucket a create spends from is the project's own check_frequency_days, so free daily slots do nothing for a weekly project. Only monitored prompts count against it, and within a cadence it is counted across every project in the workspace. Creating a prompt with monitoring_status: false is not measured against it and never returns this code, whatever the workspace has left. The body also carries check_frequency, cap, held and resets_at when the project's cadence bucket resolved |
Pause or delete monitored prompts in any project of the workspace running on that cadence, or raise that cadence's prompt count on your subscription. Paused prompts are unlimited, so pausing is always available |
TAG_LIMIT_REACHED |
402 | Reserved. No current subscription caps tags, so the API does not return this code today | No action needed |
URL_LIMIT_REACHED |
402 | Reserved. No current subscription caps URLs, so the API does not return this code today | No action needed |
IDEMPOTENCY_KEY_REUSED |
409 | Same Idempotency-Key submitted with a different request body |
Use a new key for a different request; resend the identical body to replay |
INVALID_ENGINE_NAME |
400 | A value in the engines query parameter, or an engine named by the signal_filters, changes or leader_engine prompt filter, is not a recognised public engine key |
Use one of: chatgpt, google_aio, perplexity, google_ai_mode, copilot, bing_ai_overview, gemini |
ENGINE_NOT_ENABLED |
400 | Every engine in the engines query parameter is a real engine, but none of them is enabled for the project: the workspace's subscription lacks it, or a workspace Owner or Admin switched it off for the project. Returned by every endpoint that filters on engines. A filter that names at least one enabled engine is not refused: the engines the project has not enabled are dropped, the response covers the rest, and applied.engines lists the ones it covers. On prompts/performance and prompts/summary it is also returned when a signal_filters rule, a changes token or leader_engine names an engine the project has not enabled, or when organic reads google without google_aio (or bing without bing_ai_overview) enabled for the project; the message names the engine |
Remove the engines the project does not have enabled, or omit engines; on the prompt reads, drop the filter that needs the engine. Retrying the same request will not help |
SERVICE_UNAVAILABLE |
503 | A backing dependency is temporarily unreachable. That includes the subscription check every request makes, whose failure is not cached, and the competitor marks that the competitor rankings and the overview's top_competitors apply, which are never skipped to show hidden rows |
Retry after a short delay with backoff; if it persists, open a thread in the in-app Support Center or email [email protected] |
MAINTENANCE_MODE |
503 | A declared maintenance window is in progress. Every /v1/* and /mcp request is refused for its duration; nothing is broken and no request is being served against a database that may be mid-restore |
Wait for the Retry-After seconds and retry. Retrying sooner will not succeed, and this is not worth reporting |
UPSTREAM_ERROR |
500 | A backing service returned an unexpected or malformed response | Retry once; if it persists, contact support with the request ID |
INTERNAL_ERROR |
500 | Unhandled server-side error | Retry once; if it persists, contact support with the request ID |
Maintenance windows
MAINTENANCE_MODE is the one refusal that is deliberate rather than a fault, so
it is worth handling apart from SERVICE_UNAVAILABLE. Its REST 503 and its MCP
reply (which is an HTTP 200, see below) both carry two headers:
Retry-After- seconds until the window's declared end, or300when there is no declared end (and also when the declared end has passed while the window is still open).X-Maintenance-Mode: 1
The REST body carries one key beyond the usual envelope:
{
"error": "Service is under maintenance",
"code": "MAINTENANCE_MODE",
"maintenance": {
"message": "Scheduled database upgrade",
"ends_at": "2026-08-07T03:00:00+00:00"
}
}maintenance.message is the operator's note and maintenance.ends_at is the
declared end; either can be null. Neither is a commitment - treat
Retry-After as the value to back off on.
Health and reference endpoints keep serving during a window, so /health and
/openapi.json are not a way to detect one. The 503 itself is the signal.
MCP JSON-RPC error mappings
When accessed via MCP (POST /mcp), HTTP errors are translated to JSON-RPC
error codes:
| JSON-RPC code | Name | API codes |
|---|---|---|
-32001 |
Unauthorized | MISSING_AUTH_HEADER, INVALID_AUTH_FORMAT, TOKEN_INVALID, TOKEN_REVOKED, TOKEN_EXPIRED, UNAUTHORIZED |
-32002 |
Forbidden | PROJECT_MISMATCH, INSUFFICIENT_SCOPE, FORBIDDEN |
-32003 |
RateLimited | RATE_LIMITED |
-32004 |
PaymentRequired | ENTITLEMENT_DENIED, API_ADDON_INACTIVE, PAYMENT_REQUIRED, PAID_PLAN_REQUIRED, SUBSCRIPTION_READ_ONLY, PROMPT_LIMIT_REACHED, INSUFFICIENT_CREDITS, TAG_LIMIT_REACHED, URL_LIMIT_REACHED |
-32005 |
NotFound | NOT_FOUND, PROJECT_NOT_FOUND, FEATURE_DISABLED |
-32006 |
Maintenance | MAINTENANCE_MODE |
-32009 |
Conflict | IDEMPOTENCY_KEY_REUSED, PROMPT_ALREADY_EXISTS, TAG_ALREADY_EXISTS, COMPETITOR_TAG_ALREADY_EXISTS, BRAND_FACT_LIMIT_REACHED, PROBLEM_NOT_DISMISSABLE, PROBLEM_NOT_DISMISSED, URL_ALREADY_EXISTS, ALREADY_RESOLVED, GSC_REAUTH_REQUIRED, CONFLICT, TOKEN_LIMIT_REACHED |
-32602 |
Invalid params | VALIDATION_ERROR, DATE_RANGE_TOO_LARGE, INVALID_ENGINE_NAME, ENGINE_NOT_ENABLED, BRAND_FACT_INVALID |
-32000 |
ServerError | SERVICE_UNAVAILABLE, UPSTREAM_ERROR, INTERNAL_ERROR, BAD_GATEWAY, GATEWAY_TIMEOUT |
-32601 |
Method not found | Unknown MCP tool name |
Every MCP error is an HTTP 200 carrying a JSON-RPC error member. That
includes every condition REST answers with a status code an HTTP client would
normally branch on: an authentication failure, an entitlement refusal, a
rate-limit breach and a maintenance window are all decided before the tool runs,
and all four still arrive as JSON-RPC. Branch on the JSON-RPC error.code, or
on data.code, rather than on the transport status - on /mcp there is no
status to branch on.
The data field of every MCP error contains the original {"code": "...", "error": "..."} envelope.
Two codes add keys to it so an agent can back off for the right length of time
without parsing headers:
-32003addsretry_after_seconds. TheRetry-Afterheader carries the same value, for non-agent clients.-32006addsreason,ends_atandretry_after_seconds.-32004addscheck_frequency,cap,held,resets_atandreasonwhen the code isINSUFFICIENT_CREDITSorPROMPT_LIMIT_REACHEDand the REST body carries them. The other-32004codes carry no extra keys.
-32004 groups every refusal the workspace clears by paying: the add-on
switched off, a payment not confirmed, a read-only subscription
(SUBSCRIPTION_READ_ONLY, a 403 on /v1/) and a plan cap (the four cap
codes, each a 402 on /v1/).
A code with no row in the table above follows the HTTP status it carries on
/v1/: 400 and 422 arrive as -32602, 401 as -32001, 402 as
-32004, 403 as -32002, 404 as -32005, 409 as -32009, 429 as
-32003, and any other status as -32000 ServerError. data.code always
carries the REST code, so branch on it rather than on the JSON-RPC code before
retrying, since a plain retry will not clear a -32009 or a -32004.
TOKEN_LIMIT_REACHED is listed for completeness. It is answered when a token
is created in the app, which no MCP tool does.
Getting help
status.ziptie.ai carries incident notices. It is maintained by hand, so the absence of a notice is not an automated all-clear. It is hosted off the platform it reports on, so it stays reachable through an outage large enough to take everything else down. The Terms of Service carry no contractual uptime commitment, so a 500 or 503 that survives a retry is worth reporting rather than waiting out, whatever the page says.
Responses from api.ziptie.ai carry an X-Request-ID header. Include it, the
UTC timestamp, the endpoint and the code you received, then use either:
- The in-app Support Center at
https://app.ziptie.ai/w/<workspace_id>/support- open a thread there and it arrives already tied to your workspace and subscription. - Email [email protected].
Neither channel publishes a response-time target, and there is no separate incident hotline.
A suspected security vulnerability goes to [email protected] instead, acknowledged within 5 business days. That address is also published at /.well-known/security.txt. Do not open a support thread for one: it queues behind billing questions and is read by the wrong person first.