Errors and status codes
This page is the consolidated reference for the response envelopes used by every route on the loan-domain API. Per-route documentation lists the status codes a specific route emits; this page documents the shape of each envelope and the catalogue of named code values you may receive.
Read it once before integrating; refer back whenever a non-2xx status surprises you.
Status-code catalogue
| Status | When you see it | Example route |
|---|---|---|
200 OK | Successful read, idempotent replay, soft-cancel, bulk batch processed, status transition. | All non-create routes. |
201 Created | Successful first-time create on a single-record route. | POST /borrowers. |
202 Accepted | Async import started — work continues in the background. | POST /imports. |
204 No Content | Reserved for future use; no current loan-domain route emits this. | — |
400 Bad Request | Request validation failure: malformed JSON, malformed query, schema violation. | All mutating routes. |
401 Unauthorized | No Authorization header, unknown token, or revoked token. | All routes. |
403 Forbidden | Operation requires a role the caller does not hold (?unmask=true by a non-admin). | GET /borrowers/{id}?unmask=true. |
404 Not Found | Resource does not exist or belongs to another organisation. | All routes that reference a resource. |
409 Conflict | A live row already exists with this id, or an illegal state transition was requested, or the commit phase could not see the uploaded file. | POST /borrowers, PATCH /loans/.../status, POST /imports/{id}/commit. |
413 Payload Too Large | A bulk NDJSON batch exceeds the row or byte cap. | POST /bulk/{entity}. |
422 Unprocessable Entity | Reserved for future use; current validation failures use 400. | — |
429 Too Many Requests | Rate limit exceeded. The platform applies per-organisation rate limits; the response header Retry-After indicates seconds. | All routes. |
500 Internal Server Error | Unhandled platform error. Treat as transient; safe to retry with the same Idempotency-Key. | All routes. |
Treat any 4xx other than 429 as a request defect — your client logic should reject the input rather than retry. Treat 429 and 500 as transient and retry with backoff; an Idempotency-Key makes the retry safe.
Standard envelopes
The loan-domain API uses three response envelopes for non-success status codes. The shape of the envelope tells you what kind of failure you are looking at.
Validation envelope — 400 Bad Request
Used for schema violations, malformed query parameters, and malformed paths.
{
"error": "Validation failed",
"fieldErrors": [
{ "path": "interestRateApr", "message": "interestRateApr must be >= 0" },
{ "path": "termMonths", "message": "termMonths must be > 0" }
]
}| Field | Type | Notes |
|---|---|---|
error | string | Short human-readable summary. |
fieldErrors[].path | string | Dotted path to the offending field. (root) indicates the body itself, e.g. when JSON parsing failed. |
fieldErrors[].message | string | Human-readable explanation. |
Some routes emit errors[] rather than fieldErrors[] for query-parameter failures (the framework convention varies by surface). The path/message shape is identical; treat the two array names as interchangeable for parsing purposes.
Named-code envelope — 4xx
Used when the failure has a specific cause the platform names with a stable code value. The presence of fieldErrors is optional and depends on whether the failure can be attributed to a specific field.
{
"error": "a borrower with this id already exists",
"code": "ID_CONFLICT",
"fieldErrors": [
{ "path": "id", "message": "a borrower with this id already exists" }
]
}| Field | Type | Notes |
|---|---|---|
error | string | Human-readable summary. |
code | string | Stable machine-readable code; see the glossary. |
fieldErrors[] | array | absent | Present when the failure points at a specific field. |
Some envelopes carry additional structured context — for example batch_too_large includes a fieldErrors block naming the cap exceeded. The per-route documentation names these.
Bare-error envelope — 404 / 401
Used for terse responses where no additional context would help the caller. 404 is the typical example:
{ "error": "Not found" }Some routes return { "error": "...", "code": "NOT_FOUND" }; treat the presence or absence of code on a 404 as informational, not load-bearing.
Two codespaces: upper-case vs lower-case
The loan-domain API uses two distinct code namespaces, intentionally separated by ingestion shape:
- Single-record routes (POSTs, PATCHes, the cancel and the reconciliation GETs) emit
UPPER_CASEcode values:ID_CONFLICT,PORTFOLIO_NOT_FOUND,REFERENCED_ENTITY_NOT_FOUND,INVALID_STATUS_TRANSITION,CHECK_VIOLATION,NOT_FOUND,UNKNOWN_KIND,COMMIT_FAILED. - Bulk NDJSON routes emit
lower_casecode values:batch_too_large,portfolio_not_found,portfolio_id_required,invalid_body,invalid_json, plus the per-row codes documented below.
When you branch on code in your client, match the exact case. The two codespaces are stable separately; do not normalise them.
Error-code glossary
The platform attaches a stable code to every named failure mode. Use these for programmatic branching; the error message text may evolve.
Single-record codespace
| Code | Status | Emitted by | Trigger |
|---|---|---|---|
ID_CONFLICT | 409 | POST /borrowers, POST /loans, POST /payments. | A live row with the same id already exists. |
PORTFOLIO_NOT_FOUND | 404 | All single-record write routes. | The portfolioId in the body does not exist in your organisation. |
REFERENCED_ENTITY_NOT_FOUND | 422 | POST /loans, POST /payments. | borrowerId or loanId references a row that does not exist live in the same portfolio. |
INVALID_STATUS_TRANSITION | 422 | PATCH /loans/{loanRef}/status. | The from/to status pair is not in the transition matrix. |
CHECK_VIOLATION | 422 | POST /payments. | A database CHECK constraint failed (typically the forgiveness invariant; the schema layer rejects the same shape at validation time). |
NOT_FOUND | 404 | GET /borrowers/{id}, GET /payments/{id}, PATCH /loans/.../status, POST /payments/{id}/cancel, GET /loans/{externalId}. | The looked-up row does not exist live in the supplied scope, or belongs to another organisation. |
UNKNOWN_KIND | 400 | POST /imports. | kind is not one of the six loan kinds. The response includes an allowed array. |
COMMIT_FAILED | 409 | POST /imports/{id}/commit. | The HEAD against the S3 object failed; typically the upload never landed. Retry the upload, then re-commit. |
Bulk-NDJSON codespace
| Code | Status | Trigger |
|---|---|---|
batch_too_large | 413 | The body exceeds 10 000 rows or 10 MB. The error and fieldErrors[].message distinguish row-cap vs byte-cap. |
portfolio_id_required | 400 | ?portfolioId=<UUID> is missing or not a valid UUID. |
portfolio_not_found | 404 | The ?portfolioId= does not exist live in your organisation. |
invalid_body | 400 | The request body could not be read as text. |
invalid_json | (per-row) | A row is not valid JSON. Surfaces inside the per-row errors[] array; the batch returns 200. |
A code that is not in this glossary indicates the platform added a new named code since this page was last revised. Always log the code value verbatim alongside your application's error so the engineering team can correlate.
Per-row error codes (bulk and async imports)
Bulk NDJSON and async imports both return per-row errors. The bulk surface uses one set of values; the async-imports surface uses a similar but slightly different set (notably it includes validation_error rather than validation_failed, reflecting an older surface). Per-row entries describe individual row failures inside a successful batch, not envelope failures.
Bulk NDJSON per-row codes
| Per-row code | Trigger |
|---|---|
validation_failed | The row failed zod validation. The message field carries the folded zod error chain. |
fk_violation | A foreign-key reference in the row (e.g. borrowerId, loanId) does not resolve to a live row in the same portfolio. Postgres SQLSTATE 23503. |
unique_violation | A live row with the same (portfolio, externalId) or id already exists. Postgres SQLSTATE 23505. (There is no separate id_conflict per-row code; both kinds of caller-id collision surface here.) |
check_violation | A database CHECK constraint failed — typically the forgiveness invariant on actual_payments. Postgres SQLSTATE 23514. |
db_error | An unexpected database error during this row's processing. The row is reported as failed; the batch continues. |
invalid_json | The row was not valid JSON. The platform processes the rest of the batch. |
Async imports per-row codes
| Per-row code | Trigger |
|---|---|
parse_error | The line was not valid JSON. |
validation_error | The row failed zod validation during the silver normalisation step. |
portfolio_mismatch | The row's portfolioId does not match the import's anchor portfolio. |
processor_error | An unexpected handler failure during this row's processing. |
Database-level errors during the silver insert step (FK violations, unique violations, CHECK violations) are wrapped by the silver normalisation worker and surface under one of the codes above with the database detail folded into errorMessage.
Per-row errors never bubble to a top-level non-200 response from the bulk surface — the batch as a whole succeeded if the request itself was well-formed; the per-row codes tell you what landed.
Retry semantics
A retry-safe pattern for any mutating route is:
- Generate a deterministic
Idempotency-Keyper logical operation. A UUIDv4 plus a route discriminator works. - Send the request with the key.
- On any of
2xx,409 ID_CONFLICT,404,400, or413— do not retry. The outcome is final. - On
429— retry after the duration in theRetry-Afterheader. - On
500or a network error — retry up to a small fixed number of times with exponential backoff. TheIdempotency-Keymakes the retry safe whether or not the first attempt landed.
A retry of a successful request with the same Idempotency-Key returns the same response as the original, with reused: true on the affected entity. This is the canonical happy-path recovery from a network blip.
What the platform never returns
For operational debugging, it is useful to know the negative space:
- No
5xxstatus code is treated as a feature by the platform. A500is always a bug to be fixed; never write client logic that depends on a specific500shape. - No
404response leaks tenancy. A404on a row that exists in another organisation looks identical to a404on a row that does not exist anywhere. Do not write client logic that infers cross-tenant existence. - No success response carries a body field named
success: false. The status code is the source of truth for whether the call succeeded; the body shape carries the detail. - No write route returns its own UUID in a path-parameter style ("Location" header). The created UUID appears in the response body's
idfield.