Get started · Errors

Errors

Every response body on this page was captured from api.esy.com, not invented. Two things are worth knowing up front: errors always arrive under a detail key, and a run that fails is not an API error — it is a successful request describing unsuccessful work.

The shape

Esy is a FastAPI service, so every error body has a single top-level detail. It is sometimes a string and sometimes an object — always check the type before reading into it.

the simple casejson
{ "detail": "Workflow template not found" }

Status codes

CodeMeaningWhat to do
400The request is understood but the target refuses it — a deprecated template, an invalid provider override.Read detail.code and fix the request. Retrying will not help.
401Missing or invalid credentials.Check the header format. See Authentication.
402A budget refused the run before anything was spent.Raise the budget, wait for the period to roll over, or lower the estimate.
403Authenticated, but not for this workspace — usually a workspace-bound key reaching elsewhere.Use a key bound to the right workspace, or omit workspaceId.
404No such template, run, or artifact — or it is outside your workspace.Check the id. A 404 can mean "not yours" as well as "not there".
422The payload did not satisfy the intake contract or the endpoint schema.Read detail.fields and fix the named inputs.
5xxSomething broke on our side.Retry idempotent reads with backoff. Do not blind-retry a POST.

422 — the one you will hit most

There are two different 422s and they look nothing alike. The first is the intake contract: your intake did not match what the workflow declares. detail.fields names each problem in plain language.

POST /v1/runs — missing required intakejson
HTTP 422

{
  "detail": {
    "error": "intake_invalid",
    "fields": [
      "intake.prompt is required",
      "intake.categories is required"
    ]
  }
}

The second is FastAPI’s own request validation, for a malformed query string or body. Here detail is an array, and loc tells you where the problem is.

GET /v1/budgets — missing query parameterjson
HTTP 422

{
  "detail": [
    {
      "type": "missing",
      "loc": ["query", "workspaceId"],
      "msg": "Field required",
      "input": null
    }
  ]
}
Check the type of detail
detail can be a string, an object, or an array depending on which layer rejected you. A client that assumes one shape will throw while handling an error, which is the worst moment to throw. Branch on typeof / Array.isArray first.

The reliable way to avoid intake errors is to read the contract before you call: GET /v1/catalog/workflows/{id} returns the intakeSchema with every field, its type, whether it is required, its default, and its allowed values. See Workflows and catalog.

402 — a budget said no

Budgets are enforced before the run starts, against the estimate. A refused run spends nothing, and the refusal is recorded so you can query it later.

POST /v1/runs — refusedjson
HTTP 402

{
  "detail": {
    "code": "budget_exceeded",
    "reason": "hard_stop",
    "budgetId": "budget-…",
    "scope": "workspace",
    "enforcementMode": "hard_stop",
    "limitUsd": 50.0,
    "spendUsd": 49.82,
    "runEstimateUsd": 0.28,
    "remainingUsd": 0.18
  }
}

reason tells you which rule fired: per_run_cap_exceeded, hard_stop, allow_overage_exceeded, or allow_one_more_exhausted. The rest of the body is everything you need to render a useful message — you know the limit, the spend, and what this run would have cost. Costs and budgets explains the enforcement modes.

400 — the template moved on

Workflows are deprecated rather than deleted, and the refusal names its successor so you can migrate without going hunting.

POST /v1/runs — deprecated templatejson
HTTP 400

{
  "detail": {
    "code": "template_deprecated",
    "templateId": "generate-clip-art-asset",
    "supersededBy": "generate-clip-art-asset-v2",
    "message": "Template 'generate-clip-art-asset' is deprecated; use 'generate-clip-art-asset-v2' instead."
  }
}

A failed run is not an API error

This is the distinction that matters most for your error handling. If POST /v1/runs returns 201, the request succeeded. The work can still fail afterwards, and you learn that by reading the run’s status — not by catching an exception.

GET /v1/runs/{run_id} — the work failedjson
{
  "id": "run-7bef698a",
  "status": "failed",
  "error": "step 'step-4': OCR text gate rejected the render — verdict: {\"foundText\": \"BRIEF, STRATEGY\", \"pass\": false, \"reason\": \"The image contains legible text, which violates the 'none' text policy.\"}",
  "totalCosts": { "actualUsd": 0.042348, "status": "provider_reported" }
}

Two things to notice. error carries the actual verdict, including the gate’s reasoning — here an OCR gate rejecting lettering the model invented. And totalCosts.actualUsd is non-zero: a failed run still costs money, because the provider calls before the failure really happened. Budget for failures, not just for successes.

Terminal is not the same as successful
Treat completed, review, failed, cancelled, rejected and changes_requested as “stop waiting”. Only completed means you have a finished artifact. A run in review produced something, but a human has not accepted it yet.

Retrying

Reads are safe to retry. Writes are not: Esy does not yet enforce idempotency keys on POST /v1/runs, so a blind retry after a timeout can start the work twice and bill you twice. If a write times out, look for what you created before you try again.

a retry policy that will not double-billtypescript
// Retry transient failures; never retry a 4xx you caused.
const RETRYABLE = new Set([429, 500, 502, 503, 504]);

async function callEsy(path, init, attempt = 0) {
  const res = await fetch(`https://api.esy.com${path}`, init);
  if (res.ok) return res.json();

  // A POST that may have already created a run must not be blindly repeated.
  const isWrite = (init.method ?? 'GET') !== 'GET';
  if (RETRYABLE.has(res.status) && !isWrite && attempt < 4) {
    await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
    return callEsy(path, init, attempt + 1);
  }

  const body = await res.json().catch(() => ({}));
  throw new EsyError(res.status, body.detail);
}
In short
  • Errors arrive under detail, which may be a string, object, or array. Check the type before reading it.
  • 422 means your intake did not match the contract — read detail.fields, and fetch the intakeSchema to avoid it next time.
  • 402 is a budget refusal and costs nothing. A failed run costs real money.
  • Retry reads with backoff; never blind-retry a run creation.