Waafir
API Reference

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

StatusWhen you see itExample route
200 OKSuccessful read, idempotent replay, soft-cancel, bulk batch processed, status transition.All non-create routes.
201 CreatedSuccessful first-time create on a single-record route.POST /borrowers.
202 AcceptedAsync import started — work continues in the background.POST /imports.
204 No ContentReserved for future use; no current loan-domain route emits this.
400 Bad RequestRequest validation failure: malformed JSON, malformed query, schema violation.All mutating routes.
401 UnauthorizedNo Authorization header, unknown token, or revoked token.All routes.
403 ForbiddenOperation requires a role the caller does not hold (?unmask=true by a non-admin).GET /borrowers/{id}?unmask=true.
404 Not FoundResource does not exist or belongs to another organisation.All routes that reference a resource.
409 ConflictA 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 LargeA bulk NDJSON batch exceeds the row or byte cap.POST /bulk/{entity}.
422 Unprocessable EntityReserved for future use; current validation failures use 400.
429 Too Many RequestsRate limit exceeded. The platform applies per-organisation rate limits; the response header Retry-After indicates seconds.All routes.
500 Internal Server ErrorUnhandled 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" }
  ]
}
FieldTypeNotes
errorstringShort human-readable summary.
fieldErrors[].pathstringDotted path to the offending field. (root) indicates the body itself, e.g. when JSON parsing failed.
fieldErrors[].messagestringHuman-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" }
  ]
}
FieldTypeNotes
errorstringHuman-readable summary.
codestringStable machine-readable code; see the glossary.
fieldErrors[]array | absentPresent 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_CASE code 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_case code 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

CodeStatusEmitted byTrigger
ID_CONFLICT409POST /borrowers, POST /loans, POST /payments.A live row with the same id already exists.
PORTFOLIO_NOT_FOUND404All single-record write routes.The portfolioId in the body does not exist in your organisation.
REFERENCED_ENTITY_NOT_FOUND422POST /loans, POST /payments.borrowerId or loanId references a row that does not exist live in the same portfolio.
INVALID_STATUS_TRANSITION422PATCH /loans/{loanRef}/status.The from/to status pair is not in the transition matrix.
CHECK_VIOLATION422POST /payments.A database CHECK constraint failed (typically the forgiveness invariant; the schema layer rejects the same shape at validation time).
NOT_FOUND404GET /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_KIND400POST /imports.kind is not one of the six loan kinds. The response includes an allowed array.
COMMIT_FAILED409POST /imports/{id}/commit.The HEAD against the S3 object failed; typically the upload never landed. Retry the upload, then re-commit.

Bulk-NDJSON codespace

CodeStatusTrigger
batch_too_large413The body exceeds 10 000 rows or 10 MB. The error and fieldErrors[].message distinguish row-cap vs byte-cap.
portfolio_id_required400?portfolioId=<UUID> is missing or not a valid UUID.
portfolio_not_found404The ?portfolioId= does not exist live in your organisation.
invalid_body400The 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 codeTrigger
validation_failedThe row failed zod validation. The message field carries the folded zod error chain.
fk_violationA 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_violationA 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_violationA database CHECK constraint failed — typically the forgiveness invariant on actual_payments. Postgres SQLSTATE 23514.
db_errorAn unexpected database error during this row's processing. The row is reported as failed; the batch continues.
invalid_jsonThe row was not valid JSON. The platform processes the rest of the batch.

Async imports per-row codes

Per-row codeTrigger
parse_errorThe line was not valid JSON.
validation_errorThe row failed zod validation during the silver normalisation step.
portfolio_mismatchThe row's portfolioId does not match the import's anchor portfolio.
processor_errorAn 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:

  1. Generate a deterministic Idempotency-Key per logical operation. A UUIDv4 plus a route discriminator works.
  2. Send the request with the key.
  3. On any of 2xx, 409 ID_CONFLICT, 404, 400, or 413do not retry. The outcome is final.
  4. On 429 — retry after the duration in the Retry-After header.
  5. On 500 or a network error — retry up to a small fixed number of times with exponential backoff. The Idempotency-Key makes 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 5xx status code is treated as a feature by the platform. A 500 is always a bug to be fixed; never write client logic that depends on a specific 500 shape.
  • No 404 response leaks tenancy. A 404 on a row that exists in another organisation looks identical to a 404 on 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 id field.