S
supero.docs
Developers/API Reference/Errors & Status Codes

Errors & Status Codes

Every status code the Supero API returns and what it actually means here, the error body shapes, the error_code values worth branching on — and the failures that come back as 200.

Overview

Once you know what failed, Operations & Observability covers where to read the record of it — audit entries, workflow step logs, connector run history, and tracing one request end to end.
This page is the reference for what goes wrong: the error body shapes, a status-code table describing what each code means on this platform rather than in the abstract, the error_code values you can branch on, and — the part most likely to save you a day — the calls that fail while returning a success status.

🚨 The status code alone is not enough

Several important failures on this API return 200. A partial batch write, a denied read, a filter that was never applied, a field that was silently discarded, and every failure from the transactional services endpoint all come back as success. If your client's error handling is a check on response.ok, it is not checking the cases most likely to lose data or show the wrong answer.
For rate limiting and plan quotas specifically — the 429 and 402 contracts — see Rate Limits & Quotas.

Error Body Shapes

There is no single error envelope. Several shapes are in use, and which one you get depends on the route family you called. Branch on the status code first, then look for the fields you need — never require a field to be present.

1. The common two-field shape

Most errors on the control plane and the CRUD data path. error is a prefixed form of the sentence; message is the bare sentence:
json
{
  "error": "Forbidden: Your role does not have update access to product",
  "message": "Your role does not have update access to product"
}
Common prefixes are Bad Request, Unauthorized, Forbidden, Not Found, and the operation that failed — Get Failed, Update Failed, Delete Failed, Create Failed. Both fields are human-readable; neither is a stable identifier to match on. Some responses also carry a correlation_id.

2. Create failures, with an error_code

A failed record create adds a machine-readable code. This is the shape to branch on where it is available:
json
{
  "success": false,
  "error": "Create Failed: Object with fq_name 'acme-corp:default-project:northwind:widget-01' already exists",
  "message": "Object with fq_name 'acme-corp:default-project:northwind:widget-01' already exists",
  "error_code": "ALREADY_EXISTS",
  "details": "Object with fq_name 'acme-corp:default-project:northwind:widget-01' already exists"
}

⚠️ error_code is create-only

Updates and deletes do not carry error_code at all — a failed PUT or DELETE returns only the two-field shape. Write your handler so that a missing error_code is normal and falls back to the status code. Note also that details here is a string that repeats the message, not a structured object.

3. Illegal state transitions

Attempting a transition a record's state machine does not allow returns a much richer 409 — it tells you where the record actually is, which states would have made the transition legal, and what you can do from here:
json
{
  "error_code": "INVALID_STATE_TRANSITION",
  "error": "Cannot cancel from state 'shipped'. Allowed from: ['pending', 'confirmed'].",
  "service_id": "order",
  "schema": "order",
  "record_uuid": "9c1f2a3b-...",
  "current_state": "shipped",
  "current_state_terminal": false,
  "attempted_operation": "cancel",
  "allowed_from_states": ["pending", "confirmed"],
  "target_state": "cancelled",
  "remediation": {
    "type": "wrong_state_for_op",
    "message": "The record is in 'shipped'. Read it first and branch on its current state.",
    "available_operations_from_current_state": ["deliver", "return"]
  },
  "trace": { "request_id": "...", "timestamp": "2026-08-26T09:00:00Z" }
}

🚨 The same failure arrives in several wrappings

This body can reach you as a 409, as a 200 with success: false and the object inline, or as a plain string with the whole JSON payload embedded in the error text. Detect it by searching for INVALID_STATE_TRANSITION at error_code, at output.error_code, and as a substring of error — do not require the structured body to be present, and do not rely on the status code alone.

4. AI endpoints

Routes under /ai/v1 nest the error one level deeper, under detail. It may be a plain string or an object:
json
{
  "detail": {
    "error": "quota_exceeded",
    "upgrade_required": true,
    "meter": "agent.prompt",
    "limit": 10,
    "used": 10,
    "remaining": 0
  }
}

5. Connector endpoints

Endpoints under /api/v1/connectors return a third shape in which the error field holds the code rather than a sentence, and details is a real object:
json
{
  "error": "CONNECTOR_NOT_FOUND",
  "message": "Connector 9c1f2a3b-... not found",
  "details": { }
}
Public unauthenticated reads use a fourth, smaller shape with no message field at all: {"success": false, "error": "..."}.

💡 A parser that survives all of them

Read the status code. Then take the body object at detail if present, otherwise the body itself. From that object prefer error_code, then error, then message, and treat everything else as optional context. Never assume the body is JSON at all — see 405 below.

Status Codes

What each code means on this API, and the situation that produces it.
CodeMeaning hereTypical cause
200The request was processed. Not necessarily that it did what you wanted.Reads, updates, deletes — and partial batch writes, denied reads, ignored filters, and every failure from POST /api/v1/services/execute. See the next section.
201A record was created.POST to a CRUD collection. The body returns system fields; your own fields need a follow-up GET.
202Accepted; work continues in the background.Publishing an app, generating an SDK. Poll for status.
204Done, no body.A CORS preflight. Note that a CRUD delete through the public API returns 200 with a JSON body, not 204.
207Multi-status: some of it worked.A schema upload where some schemas applied and some failed, or a multi-reference update with partial failures. Below 400, so a status-only check reads it as success.
400Malformed request, or an identifier not in the expected form.A missing required field, an unparseable body, an invalid enum value — and very commonly a name where a UUID was expected.
401No valid credential.Missing, malformed, or expired token; wrong password; a user that does not exist. An API key sent as a Bearer token also lands here.
402A plan allowance is exhausted.Publishing beyond your live-deployment allowance, creating a project past the limit, AI prompts past the free allowance. Never retry — see Rate Limits & Quotas.
403Authenticated, but not permitted.A role or access policy that does not grant the operation; a cross-project or cross-tenant access; writing to a read-only schema; aggregating over a system object type.
404Not found — or hidden.A real miss, or a deliberate denial where confirming existence would leak information. See "Denials are not always 403".
405The path does not exist.A typo in the URL. This is not a 404, and the body is not JSON — see the warning below.
409A conflict with existing state.A duplicate name or fq_name, a bare type name that matches more than one namespace, an illegal state transition, a write that matched no record, or launching a preview over an app that is already live.
413The body is too large.Over a per-endpoint size ceiling. Values are on Rate Limits & Quotas.
422Well-formed but fails validation.A record that violates its schema, a request field outside its allowed bounds, a name that could not be derived, or a schema upload where nothing applied and something failed.
429Rate limited.Too many requests on a limited surface. Honour Retry-After when present.
500Failed server-side. The body is deliberately generic.Retry idempotent reads with backoff. Quote the correlation_id when you report it.
501Not supported in this mode.A read shape that cannot be pushed down to a live, no-copy connector table. Refused rather than answered from stale local data — deliberate, and not a retryable condition.
502 / 504An upstream dependency failed or timed out.Usually a connector or an external service. Retry idempotent calls with backoff.
503Temporarily unavailable, by design or by circumstance.A platform still in setup, an authorization check that could not be evaluated (refused rather than allowed), or a transient read failure during login. Frequently carries Retry-After.

🚨 A mistyped URL returns 405, not 404 — and the body is HTML

An unmatched path on api.supero.dev comes back as 405 Method Not Allowed with an Allow header, not as a JSON 404. Any client that calls response.json() on an error path throws a parse error on a simple typo, and the exception you see has nothing to do with the real problem. Check the status before parsing the body, and read 405 as "this endpoint does not exist" rather than "wrong HTTP method". A URL assembled from an empty variable — /api/v1/crud//list-bulk — produces exactly this.

⚠️ Path identifiers are UUIDs only

GET /api/v1/crud/{domain}/project/my-project returns 400 with "Invalid UUID format: my-project". The format check runs before any access check, so a name in the path never reaches authorization — which also means a 400 here tells you nothing about whether you would have been allowed. To go from a name to a uuid, call POST /api/v1/crud/{domain}/fq-name-to-uuid with the type and the fq_name array. Records key on uuid, never on id or name.

ℹ️ A 500 body tells you nothing on purpose — but it names the request

Server errors are scrubbed before they leave: the body is {"error": "Internal server error", "correlation_id": "..."} regardless of what actually failed, so no internal detail escapes. That correlation_id is the useful part. Log it, and quote it in a support request — it is how the failure gets found. You can also set your own by sending an X-Correlation-ID header, which is echoed back.

error_code Values

Where an error_code is present, branch on it. It is stable; message text is not, and message strings do change.
error_codeStatusMeaning
ALREADY_EXISTS409A record with that name or fully-qualified name already exists in that scope.
PARENT_NOT_FOUND400 or 404The parent_uuid does not resolve, or parent_type is wrong for this object type. The status varies — match on the code, not the status.
PARENT_REQUIRED400The type declares a parent and none could be resolved from the request. Common when creating a child record without a parent_uuid.
VALIDATION_FAILED422The record does not satisfy its schema — a missing required field, a wrong type, a value outside an enum.
NAME_DERIVATION_FAILED422No name could be established: none was supplied, no fq_name was given, and the schema's name-derivation rule produced nothing.
PERMISSION_DENIED403The caller's role or access policy does not permit the operation on this entity.
CREATE_FAILEDvariesThe fallback when a create fails for a reason none of the above covers. Read message for the specifics.
PASSWORD_HASH_REJECTED400A pre-hashed password was sent where a plaintext password field was expected.
INVALID_STATE_TRANSITION409The requested state is not reachable from the record's current state.
UPDATE_NOT_PERSISTED / DELETE_NOT_PERSISTED409The write was accepted but matched no record. Reported rather than silently returning success.
DOMAIN_LEVEL_ACCESS_DENIED403A domain-level object was addressed by a caller without domain administration rights.
ACCESS_POLICY_UNAVAILABLE503Object-level authorization could not be evaluated, so the request was refused rather than allowed. Retry shortly.
SETUP_MODE_ACTIVE503The deployment has not finished setup. Carries Retry-After.
PAYMENT_REQUIRED402An entitlement is missing — for example, using a marketplace app that has not been purchased.

ℹ️ quota_exceeded is not an error_code

Plan-quota blocks carry "error": "quota_exceeded" rather than an error_code, together with meter, limit, used and remaining. Check the error field when handling a 402.
Connector endpoints carry their own code set in the error field — CONNECTOR_NOT_FOUND, CONNECTOR_DISABLED, EXECUTION_IN_PROGRESS, EXECUTION_ALREADY_RUNNING, TRIGGER_NOT_ALLOWED, WEBHOOK_VERIFICATION_FAILED, RATE_LIMIT_EXCEEDED, PLATFORM_UNAVAILABLE, PLATFORM_TIMEOUT and others — each with its own status. Treat them as a separate namespace from the CRUD codes above.

Failures That Do Not Look Like Failures

These are the ones that cost real time, because nothing raises. The call returns a success status, your client carries on, and the problem surfaces later as missing rows, a wrong total, or a change that quietly did not happen.

A bare list is scoped to your own project, not your whole domain

🚨 The single most common cause of "my list is empty"

GET /api/v1/crud/{domain}/{type} with no scoping parameters is not a domain-wide read, even for a domain administrator. It is silently narrowed to the project carried in your credential — which for a fresh account is default-project — and for a tenant-scoped caller to that tenant as well. Records in any other project are simply absent, with no error and nothing in the response indicating that anything was filtered. Reading across every project in the domain requires POST /api/v1/crud/{domain}/list-bulk with an explicit fq_name_prefix.
bash
# Looks domain-wide. Is not — silently scoped to the project in your token.
curl "https://api.supero.dev/api/v1/crud/acme-corp/product" \
  -H "Authorization: Bearer $TOKEN"

# Actually domain-wide: name the prefix you want
curl -X POST "https://api.supero.dev/api/v1/crud/acme-corp/list-bulk" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"type": "product", "fq_name_prefix": ["acme-corp"], "limit": 100}'
Two things to know about the escape hatch. The prefix must begin with the domain you are addressing — an administrator who sends one that does not gets a 403. And for a non-administrator, a prefix that would widen scope is silently clamped back to the caller's own scope rather than refused: the response is a 200 with fewer rows than you asked for, and no field says so. If your list-bulk still comes back short, the limit is your credential, not your query.

A batch write returns 200 with failures inside it

Batch creates report per-record outcomes in the body. The HTTP status describes the request, not the records:
json
{
  "success": false,
  "created": 47,
  "updated": 0,
  "failed": 3,
  "total_submitted": 50,
  "input_format": "standard",
  "errors": [
    {"index": 12, "message": "VALIDATION_FAILED: price must be a number"}
  ]
}
  • •The status is 200 even when every record failed.
  • •success is exactly failed == 0 — check the field, not the status.
  • •errors lists only the first 10 failures and each message is truncated, so failed can be far larger than the array length.
  • •Processing stops once 100 errors accumulate, so records after that point were never attempted. total_submitted minus created minus updated minus failed is how many were skipped entirely.
  • •Error entries come in two shapes — {index, message} and {index, phase, error, error_code} — so read defensively.
Schema uploads have the same hazard with a different status. A partly-successful upload returns 207 with a failed array and a summary object; 207 is below 400, so a client that only checks for an error status treats a dropped schema as success. Read summary.failed and the failed array every time.

Transactional service calls report failure inside a 200

POST /api/v1/services/execute keeps a success envelope for essentially every failure, including validation errors, business-rule refusals, and handler errors. The response carries success, error, error_code and status_code inside the body:
json
{
  "success": false,
  "service_id": "cart",
  "operation": "checkout",
  "output": { },
  "error": "Cart is empty",
  "status_code": 400,
  "error_code": "VALIDATION_FAILED"
}
A 200 from this endpoint tells you nothing on its own. Branch on body.success first, then on body.error_code, then on body.status_code. The same applies to service imports: an unknown service or an invalid manifest comes back as 200 with success: false.

An empty list can be the denial

Several reads answer an unauthorized request with 200 and zero rows rather than 403 — notably audit-log reads. A caller who is not allowed to see anything sees an empty collection, which is indistinguishable from a collection that is genuinely empty. User listing behaves similarly with a twist: an explicit out-of-scope project or tenant parameter does return 403, but omitting the parameter silently substitutes the caller's own scope and returns a narrowed list. The echoed filters block in that response is the only signal the request was clamped.
Do not treat "200 with an empty results array" as proof that no data exists. Confirm with a credential you know is authorized before concluding a table is empty.

Query parameters you invent are dropped

GET list recognises only pagination and scoping parameters: limit, offset, cursor, sort_by, sort_order, include_total, parent_id / parent_uuid, parent_type, tenant, and project. Anything else — ?status=active, ?category=shoes — is parsed off the query string and never reaches the filter engine. The call returns 200 with an unfiltered page, so a test with three records passes and production returns everything.
bash
# Returns every product. The status filter is silently ignored.
curl "https://api.supero.dev/api/v1/crud/acme-corp/product?status=active" \
  -H "Authorization: Bearer $TOKEN"
Use POST /query or POST /list-bulk to filter. The same trap has a second form: a field lookup written as price__gte on the wire parses as a literal field name and matches zero rows — 200, empty list, no error. See Querying & Pagination.

Fields you are not allowed to write are stripped, not rejected

An access rule can mark fields readonly or hidden for a role. Sending a readonly field on a create or update does not return 403 — the value is removed from the payload and the rest of the write succeeds. Hidden fields are likewise stripped from read responses rather than refused. Owner-scoped fields behave the same way: the server stamps the caller's own identity over whatever you sent and returns 201.
The observable result is a successful write whose effect differs from what you asked for — the other fields in the same request do apply. After any write touching a field a role might not own, read the record back and compare. There is one place this does raise: naming a hidden field in a filter, sort, or projection against a live connector-backed schema returns 400, deliberately without saying which field, so the error cannot be used to enumerate hidden columns.

A newly uploaded schema is not instantly visible everywhere

Uploading a schema makes its CRUD API live immediately, but visibility settles over a short window. A write issued moments after an upload can be refused with "Schema not found for type" for a schema that demonstrably exists, and a follow-up list can omit it briefly.
  • •Take uuids from the upload response itself, not from a list call issued afterwards — the upload response carries them at write time and never lags.
  • •Retry a "Schema not found for type" error a few times with a short delay before treating it as real.
  • •Do not assert on a list immediately after an upload in a test. Assert on the upload response.

Other silent outcomes worth knowing

What you doWhat you getWhat is actually happening
Log in naming a project and tenant that do not exist200 with a working sessionA fallback chain places you in the default project and tenant. Only a user that does not exist returns 401. Worse, the context block may echo the tenant you asked for rather than the one you landed in — so every later scoped read comes back empty for a reason that looks unrelated.
Re-register a domain you already own200 with "idempotent": trueRe-registering as the same owner is a no-op, not a conflict. A domain owned by someone else does return 409, and duplicate projects and tenants return 409 too — so branch per resource rather than assuming one rule.
Create a record whose name comes from a schema derive rule and collides201, with a name you did not chooseA derived name that collides is retried with a short random suffix rather than returning 409. A name you supplied explicitly still conflicts normally.
Create two records with the same fq_name at the same timeBoth succeedUniqueness is not enforced under concurrency. If a name must be unique, serialise the writes or check first.
Send a PUT that changes fq_name200, with the change discardedIdentity fields are immutable and are dropped from an update rather than rejected.
Read a live, no-copy connector table and get zero rows200 with an empty listA read that could not resolve its live binding is answered from the empty local store instead. Zero rows from a live table means "check the binding", not "the source is empty".
Ask for stats on a project that does not exist200 with every count at zeroNot a 404. A zeroed stats response and a genuinely empty project look identical.
Request an SDK in a language that is not built200 with a buildUnsupported languages are dropped and you get the default build back, with no error field.
Read billing usage for a new domain or a quiet period200 with found: falseNot an error — every counter is at its zero or null default.
Ask to resend an activation code for an unknown address200 with a generic messageDeliberate: a distinguishable answer would turn the endpoint into an account-enumeration oracle.

Denials Are Not Always 403

Authorization failures surface in four different ways, depending on what confirming the failure would reveal. Handle all four.
You seeWhenWhat it means
403A write, or an operation you are authenticated for but not permitted to performA straightforward denial. The message usually names the role and the entity.
404Reading a record that exists but is not yours — including an owner-scoped row belonging to another user, a connector in another project, or any public-surface denialA deliberate denial. Returning 403 would confirm the record exists, so the API declines to distinguish "not yours" from "not there".
200 with an empty listAudit reads, and user listing outside your scopeThe denial is expressed as having nothing to show rather than as an error.
400A name used where a uuid belongsThe format check runs first, so this tells you nothing about whether access would have been granted.
A failed login is its own case: an unknown email returns 401, never 404, so that the endpoint cannot be used to discover which addresses have accounts.
The consequence for a client: do not build a permissions UI by probing endpoints and watching for 403. Read the caller's own resolved policy instead with GET /api/v1/crud/{domain}/access-policy/me, which returns what that role can actually do per entity. See Access Policies.

⚠️ Newly granted or revoked access is not instant

Resolved policies are cached for up to five minutes. A permission you just revoked can keep working for that window, and one you just granted may not appear immediately. Never confirm to an operator that access has changed on the strength of a successful policy write — verify as the affected role.

What to Retry

StatusRetry?How
429YesHonour Retry-After if present; otherwise exponential backoff with jitter. Cap the total attempts.
503YesFrequently carries Retry-After — a platform still in setup asks for 30 seconds, a transient login failure for 1. Respect it rather than hammering.
500 / 502 / 504Yes, for idempotent callsExponential backoff. Be careful with creates — one that timed out may still have succeeded.
"Schema not found for type" straight after an uploadYesA few attempts with a short delay. If it persists, the schema really is missing.
402NoA plan state. Surface it — the body has everything an upgrade prompt needs.
501NoThe operation cannot be served in this mode. Change the query shape, not the timing.
400 / 401 / 403 / 404 / 405 / 409 / 413 / 422NoThe same request will fail identically. Fix the request, the credential, or the state.
A reasonable default is three attempts at roughly 100 ms, 300 ms and 900 ms, each jittered by about thirty percent, which is what the Python SDK uses. Two refinements are worth copying from it: never retry a 4xx, and treat a request that failed without any status — a timeout or a dropped connection — as ambiguous rather than failed, because the write may have landed. Re-send only operations that are safe to apply twice.

💡 Retrying a create safely

Creates are not idempotent and concurrent duplicates are not prevented, so a blind retry can produce two records. Either use the batch endpoint with upsert, which matches on the schema's own identity fields, or read back by fq_name before retrying.

Triage Checklist

When something is not behaving, this order resolves most cases fastest.
  1. 1

    Is the body even JSON?

    A parse error on an error path usually means 405 from a mistyped URL, or an empty path segment built from an unset variable. Print the status and the raw body before anything else.

  2. 2

    Is it a real failure, or a 200 that lied?

    For a write, check success and the created/updated/failed counts. For a services call, check body.success and body.status_code. For a read, ask whether an empty result could be a denial or a scope clamp rather than an empty table.

  3. 3

    Is your filter actually being applied?

    Re-run with a filter value you know matches nothing. If the row count does not change, the filter is being dropped — you are on GET list and need POST /query.

  4. 4

    Are you looking in the right project?

    Re-run as a list-bulk with an explicit fq_name_prefix of [domain]. If rows appear, it was the project clamp.

  5. 5

    Is it a uuid in the path?

    A 400 saying "Invalid UUID format" means you passed a name. Resolve it with fq-name-to-uuid first.

  6. 6

    Is it the credential?

    Read GET /api/v1/auth/session or /api/v1/auth/me to see the domain, project, tenant and role actually in force. Assumed scope is wrong more often than assumed permissions — especially after a login that named a project or tenant that does not exist.

  7. 7

    Is it policy rather than role?

    Read GET /api/v1/crud/{domain}/access-policy/me as the affected role. A 403 with an unhelpful message is usually an access rule, not the role catalog.

ℹ️ What to include in a support request

Your domain name, the project, the exact method and path, the timestamp, the full response body and status, and the correlation_id if the response carried one. Most support round-trips are spent establishing those — see Contact & Links.

Next Steps