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
🚨 The status code alone is not enough
Error Body Shapes
1. The common two-field shape
{
"error": "Forbidden: Your role does not have update access to product",
"message": "Your role does not have update access to product"
}2. Create failures, with an error_code
{
"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
3. Illegal state transitions
{
"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
4. AI endpoints
{
"detail": {
"error": "quota_exceeded",
"upgrade_required": true,
"meter": "agent.prompt",
"limit": 10,
"used": 10,
"remaining": 0
}
}5. Connector endpoints
{
"error": "CONNECTOR_NOT_FOUND",
"message": "Connector 9c1f2a3b-... not found",
"details": { }
}💡 A parser that survives all of them
Status Codes
| Code | Meaning here | Typical cause |
|---|---|---|
| 200 | The 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. |
| 201 | A record was created. | POST to a CRUD collection. The body returns system fields; your own fields need a follow-up GET. |
| 202 | Accepted; work continues in the background. | Publishing an app, generating an SDK. Poll for status. |
| 204 | Done, no body. | A CORS preflight. Note that a CRUD delete through the public API returns 200 with a JSON body, not 204. |
| 207 | Multi-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. |
| 400 | Malformed 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. |
| 401 | No 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. |
| 402 | A 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. |
| 403 | Authenticated, 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. |
| 404 | Not found — or hidden. | A real miss, or a deliberate denial where confirming existence would leak information. See "Denials are not always 403". |
| 405 | The path does not exist. | A typo in the URL. This is not a 404, and the body is not JSON — see the warning below. |
| 409 | A 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. |
| 413 | The body is too large. | Over a per-endpoint size ceiling. Values are on Rate Limits & Quotas. |
| 422 | Well-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. |
| 429 | Rate limited. | Too many requests on a limited surface. Honour Retry-After when present. |
| 500 | Failed server-side. The body is deliberately generic. | Retry idempotent reads with backoff. Quote the correlation_id when you report it. |
| 501 | Not 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 / 504 | An upstream dependency failed or timed out. | Usually a connector or an external service. Retry idempotent calls with backoff. |
| 503 | Temporarily 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
⚠️ Path identifiers are UUIDs only
ℹ️ A 500 body tells you nothing on purpose — but it names the request
error_code Values
| error_code | Status | Meaning |
|---|---|---|
| ALREADY_EXISTS | 409 | A record with that name or fully-qualified name already exists in that scope. |
| PARENT_NOT_FOUND | 400 or 404 | The 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_REQUIRED | 400 | The type declares a parent and none could be resolved from the request. Common when creating a child record without a parent_uuid. |
| VALIDATION_FAILED | 422 | The record does not satisfy its schema — a missing required field, a wrong type, a value outside an enum. |
| NAME_DERIVATION_FAILED | 422 | No name could be established: none was supplied, no fq_name was given, and the schema's name-derivation rule produced nothing. |
| PERMISSION_DENIED | 403 | The caller's role or access policy does not permit the operation on this entity. |
| CREATE_FAILED | varies | The fallback when a create fails for a reason none of the above covers. Read message for the specifics. |
| PASSWORD_HASH_REJECTED | 400 | A pre-hashed password was sent where a plaintext password field was expected. |
| INVALID_STATE_TRANSITION | 409 | The requested state is not reachable from the record's current state. |
| UPDATE_NOT_PERSISTED / DELETE_NOT_PERSISTED | 409 | The write was accepted but matched no record. Reported rather than silently returning success. |
| DOMAIN_LEVEL_ACCESS_DENIED | 403 | A domain-level object was addressed by a caller without domain administration rights. |
| ACCESS_POLICY_UNAVAILABLE | 503 | Object-level authorization could not be evaluated, so the request was refused rather than allowed. Retry shortly. |
| SETUP_MODE_ACTIVE | 503 | The deployment has not finished setup. Carries Retry-After. |
| PAYMENT_REQUIRED | 402 | An entitlement is missing — for example, using a marketplace app that has not been purchased. |
ℹ️ quota_exceeded is not an error_code
Failures That Do Not Look Like Failures
A bare list is scoped to your own project, not your whole domain
🚨 The single most common cause of "my list is empty"
# 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}'A batch write returns 200 with failures inside it
{
"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.
Transactional service calls report failure inside a 200
{
"success": false,
"service_id": "cart",
"operation": "checkout",
"output": { },
"error": "Cart is empty",
"status_code": 400,
"error_code": "VALIDATION_FAILED"
}An empty list can be the denial
Query parameters you invent are dropped
# 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"Fields you are not allowed to write are stripped, not rejected
A newly uploaded schema is not instantly visible everywhere
- •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 do | What you get | What is actually happening |
|---|---|---|
| Log in naming a project and tenant that do not exist | 200 with a working session | A 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 own | 200 with "idempotent": true | Re-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 collides | 201, with a name you did not choose | A 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 time | Both succeed | Uniqueness is not enforced under concurrency. If a name must be unique, serialise the writes or check first. |
| Send a PUT that changes fq_name | 200, with the change discarded | Identity fields are immutable and are dropped from an update rather than rejected. |
| Read a live, no-copy connector table and get zero rows | 200 with an empty list | A 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 exist | 200 with every count at zero | Not a 404. A zeroed stats response and a genuinely empty project look identical. |
| Request an SDK in a language that is not built | 200 with a build | Unsupported languages are dropped and you get the default build back, with no error field. |
| Read billing usage for a new domain or a quiet period | 200 with found: false | Not an error — every counter is at its zero or null default. |
| Ask to resend an activation code for an unknown address | 200 with a generic message | Deliberate: a distinguishable answer would turn the endpoint into an account-enumeration oracle. |
Denials Are Not Always 403
| You see | When | What it means |
|---|---|---|
| 403 | A write, or an operation you are authenticated for but not permitted to perform | A straightforward denial. The message usually names the role and the entity. |
| 404 | Reading 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 denial | A deliberate denial. Returning 403 would confirm the record exists, so the API declines to distinguish "not yours" from "not there". |
| 200 with an empty list | Audit reads, and user listing outside your scope | The denial is expressed as having nothing to show rather than as an error. |
| 400 | A name used where a uuid belongs | The format check runs first, so this tells you nothing about whether access would have been granted. |
⚠️ Newly granted or revoked access is not instant
What to Retry
| Status | Retry? | How |
|---|---|---|
| 429 | Yes | Honour Retry-After if present; otherwise exponential backoff with jitter. Cap the total attempts. |
| 503 | Yes | Frequently 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 / 504 | Yes, for idempotent calls | Exponential backoff. Be careful with creates — one that timed out may still have succeeded. |
| "Schema not found for type" straight after an upload | Yes | A few attempts with a short delay. If it persists, the schema really is missing. |
| 402 | No | A plan state. Surface it — the body has everything an upgrade prompt needs. |
| 501 | No | The operation cannot be served in this mode. Change the query shape, not the timing. |
| 400 / 401 / 403 / 404 / 405 / 409 / 413 / 422 | No | The same request will fail identically. Fix the request, the credential, or the state. |
💡 Retrying a create safely
Triage Checklist
- 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
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
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
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
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
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
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
Next Steps
- •Rate Limits & Quotas — the 429 and 402 contracts, and the per-request size and page limits.
- •API Overview & Conventions — base URL, auth headers, and response envelopes.
- •CRUD Endpoints — create, read, update, delete and batch, including the response shapes referenced here.
- •Querying & Pagination — which read endpoint applies filters, and which silently does not.
- •Access Policies (Entity, Record & Field) — why a field was stripped, and how to read back a resolved policy.
On this page