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.
{ "detail": "Workflow template not found" }Status codes
| Code | Meaning | What to do |
|---|---|---|
400 | The 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. |
401 | Missing or invalid credentials. | Check the header format. See Authentication. |
402 | A budget refused the run before anything was spent. | Raise the budget, wait for the period to roll over, or lower the estimate. |
403 | Authenticated, but not for this workspace — usually a workspace-bound key reaching elsewhere. | Use a key bound to the right workspace, or omit workspaceId. |
404 | No 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". |
422 | The payload did not satisfy the intake contract or the endpoint schema. | Read detail.fields and fix the named inputs. |
5xx | Something 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.
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.
HTTP 422
{
"detail": [
{
"type": "missing",
"loc": ["query", "workspaceId"],
"msg": "Field required",
"input": null
}
]
}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.
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.
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.
{
"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.
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.
// 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);
}- Errors arrive under
detail, which may be a string, object, or array. Check the type before reading it. 422means your intake did not match the contract — readdetail.fields, and fetch theintakeSchemato avoid it next time.402is a budget refusal and costs nothing. Afailedrun costs real money.- Retry reads with backoff; never blind-retry a run creation.
