Skip to main content

Troubleshooting

Two facts make API debugging tractable. Every non-2xx response is the same JSON envelope — { "error": "...", "code": "...", ...extra } — and the code vocabulary is closed and stable. So the debugging loop is always: read code, look it up here, apply the fix. Don't diagnose from the HTTP status alone (two different 404s exist) and don't match on the error text (it's allowed to change).

When a run is involved, also capture the executionId from the response or the run's row in the dev tools — it's the identifier that lets anyone (including contact@zerowidth.ai) find exactly what happened.

"401 — my key is rejected"

auth_missing — the request never presented a credential the API could see. The only accepted shape is the header:

Authorization: Bearer zw_live_…

Check the header name, the Bearer prefix (with the space), and that your HTTP client actually sends the header. There is no query-param or cookie fallback on the API.

auth_invalid — a credential arrived but didn't authenticate. This one response covers several causes. Work through them:

  • Truncated or mistyped key. The whole string is the credential — the readable prefix alone authenticates nothing. Re-copy it; watch for trailing whitespace or a shell variable that lost characters in quoting.
  • Revoked. Revocation is immediate — the key stops working on the very next request. Check the key's status at accounts.zerowidth.ai → Workspace → API keys.
  • Expired personal token. Personal access tokens always carry an expiry (up to 365 days). An expired token gets the same auth_invalid as a revoked one — mint a new token.
  • Wrong credential kind for the endpoint. Most /1.0 endpoints authenticate with a workspace API key (zw_live_…). Personal tokens (zw_pat_…) are accepted on the run endpoint and the MCP server; elsewhere, use an API key.

The secret half of a key is shown exactly once at mint and can't be recovered. If it's lost, revoke the key and mint a new one.

"403 — authenticated, but denied"

scope_missing — the credential is valid but wasn't minted with the scope this route requires. The response tells you which one:

{ "error": "…", "code": "scope_missing", "requiredScope": "workbench:flows:run" }

Add the named scope to the key from the dashboard (a key's scopes are editable after mint), or mint a new key with the right preset — see the scope catalog. The secret doesn't change when scopes do, so widening a scope doesn't force a redeploy.

forbidden — right scope, wrong kind of credential. Some routes only accept a specific credential shape — for example, the Caliper eval endpoints take Workspace-wide keys, not Personal ones. The message says which kind to mint.

"It says not found — but I'm looking right at it"

The workspace boundary is the usual culprit. An API key belongs to exactly one workspace, and it can only see that workspace's resources. A flow or eval that exists in workspace A returns 404 not_found to a key minted in workspace B — the response is identical whether the resource is missing or merely invisible.

  • Check which workspace minted the key against which workspace owns the resource. Same account, different workspace, still a 404.
  • Personal tokens follow your membership. A personal token addressing a workspace you don't belong to gets the same 404 as a workspace that doesn't exist. If you expected access, check the slug and your membership, or use a key minted inside that workspace.
  • Check you're using the right id. The run endpoint takes the flow's Flow ID (a UUID, from the dev drawer → Quickstart), not the id in the editor URL. Pasting the editor-URL id fails validation — a UUID from the wrong place fails with not_found.
  • flow_not_published is a different 404. The flow exists, but the version you asked for doesn't: { "kind": "published" } on a flow that's never been published, or a pinned version / revisionId that doesn't exist. Publish a version, or fix the pin. This is the classic "works in the editor, 404s in CI" failure — see ship to production.

"402 — a spend or plan ceiling"

Something metered ran out. Two codes, three ceilings.

plan_limit — a metered limit was reached. In order of likelihood:

  1. The key's own cost cap. Keys can carry a lifetime spend ceiling as a blast-radius limit. Check the key's usage in the dashboard; raise the cap or mint a fresh key. (Cost cap)
  2. The workspace's inference pools. Runs draw down the plan's included monthly inference first, then the prepaid balance. When both hit zero, every run stops, whichever credential made it. Top up from the billing page at accounts.zerowidth.ai — pick an amount, pay the one-time checkout, and runs resume once the balance lands. Or wait for the monthly reset. (Spend & credits)
  3. A per-plan count cap — the plan allows only so many of something. The response names the limit that tripped.

plan_gate — the feature isn't metered at all; the current plan doesn't include it. Upgrading is the fix.

In a user-facing product, catch both and show an "add credit / upgrade" path instead of a generic error — the error-handling guide shows the pattern.

"400 — validation"

validation — the request shape is wrong before any work happens. The details field lists the offending paths when the body failed schema validation. The frequent offenders:

  • The input envelope is missing its kind discriminator — it must be { "kind": "form", "values": {...} } or { "kind": "chat", "messages": [...] }. (Run a flow)
  • timeoutMs above the 600,000 ms maximum.
  • A path parameter with the wrong shape — e.g. the editor-URL flow id where a UUID belongs.

Note the near-miss that is not a validation error: form values whose keys don't match the flow's input nodes. That request is well-formed, so it runs — and returns status: "partial" with a message explaining what was missing. See the Workbench troubleshooting page.

workspace_required — a personal-token call needed a workspace and none resolved. Pass "workspace": "<slug>" in the request body, or set a default workspace on the token.

"429 — rate limited"

Each API key has two request-rate windows checked in parallel — a per-minute burst cap and a per-hour sustained cap, sized by plan. The response tells you everything needed to recover:

{ "error": "…", "code": "rate_limited", "retryAfterSeconds": 31, "details": { "window": "minute", "limit": 60, "plan": "starter" } }
  • Honor Retry-After. It's the number of seconds until the tripped window resets. Sleeping that long and retrying always works; hammering sooner never does.
  • Pace proactively. Every response — including successes — carries RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers for the tighter of your two remaining budgets. A client that watches RateLimit-Remaining never sees a 429.
  • Read details.window. "minute" means a burst — smooth the traffic (queue, add jitter, cap concurrency). "hour" means sustained volume above the tier — spread the work, or move to a plan with more headroom.

Rate limits are per key, so splitting unrelated workloads onto separate keys also separates their budgets.

"504 — the run timed out"

A run gets a hard time budget: timeoutMs, default 60,000 ms (1 minute), maximum 600,000 ms (10 minutes). Exceed it and the API returns 504 timeout, with whatever partial per-node timeline accumulated in details.

  • If the flow legitimately needs longer — long model calls, deep chains — pass a higher timeoutMs on the request. The default is deliberately conservative; the cap is 10 minutes.
  • If it shouldn't take this long, find the stall: the partial timeline shows which nodes completed, and streaming shows node events live, so the node that never emits node_complete is your hang.
  • Don't blind-retry a timeout — the same request will usually time out the same way. Change something first.
  • The timeout is also the spend ceiling for a runaway run, since cost accrues while nodes execute. Raising it raises the worst-case cost of one call.

"5xx — something failed on the other side"

service_unavailable (503) — something the run depends on wasn't available at that moment. Transient: retry with backoff.

internal_error (500) — an unexpected failure on ZeroWidth's side. Also safe to retry with exponential backoff. If it persists, send the executionId (or the request's timestamp and endpoint) to contact@zerowidth.ai.

For anything that costs money — a flow run above all — pair retries with an Idempotency-Key, so a retry after an ambiguous failure replays the original result instead of running (and billing) the work twice.

Streaming gotchas

The streaming surface has three behaviors that surprise people:

The error came back as JSON, not as a stream

Correct — and useful. Everything that can be rejected up front (auth, scope, validation, unknown flow, unpublished version) fails before the stream opens, as a normal JSON error envelope with a real HTTP status. Check the response status and Content-Type before attaching an SSE parser: text/event-stream means events are coming; application/json means read the envelope and treat it like any other error on this page.

The HTTP status is 200 but the run failed

Once a stream opens, the status line is already committed — a failure mid-run cannot change it. The terminal truth arrives as an event: run_complete (success or partial) or run_error (failure, with error.kind such as timeout or quota_exceeded), followed by the done sentinel. A streaming client must treat run_error as its failure signal, never the HTTP status. This includes running out of credit mid-run — on a stream, that's a run_error, not a 402.

No events arrive, or they all arrive at once

Something between you and the API is buffering. With curl, pass -N to disable output buffering. In code, use a client that exposes the response as a stream rather than waiting for the full body. Also expect comment lines (: heartbeat) every 15 seconds on quiet stretches — they keep the connection alive through proxies, and spec-compliant SSE parsers ignore them; don't treat them as malformed events.

Also: a node_error event doesn't necessarily end the run — later nodes may still execute and the run may settle partial. Only run_complete / run_error are terminal.

Flow-level problems

Runs that succeed but do the wrong thing — partial statuses, timeouts you want to diagnose node-by-node, draft-vs-published drift, scheduled runs — are covered in Workbench troubleshooting. Eval and review problems live in Caliper troubleshooting.

9 min read