Overview
Every loan-domain endpoint shares the contract on this page. Read this first; the per-surface pages reference it rather than restating it.
Base URL and versioning
All loan-domain routes are mounted under /api/v1/monitoring/loan/. The integer following v is the major version: a major bump is a breaking change and only happens with explicit deprecation notice. Minor evolution within a major version is additive-only — new optional fields, new endpoints, new enum values — and never breaks an integration that ignores fields it does not understand.
The production host is https://app.waafir.io. For a complete URL, prepend the host to any path on this reference site, for example https://app.waafir.io/api/v1/monitoring/loan/borrowers.
Authentication
Every request carries an Authorization header with a Bearer token:
Authorization: Bearer <PAT><PAT> is a personal access token issued from the Waafir control plane against a single organisation. The token's organisation determines the tenancy scope of every read and every write — there is no organisation parameter in any URL or body, because the token already pins it.
Cross-organisation requests return 404 Not Found, not 403 Forbidden. Returning 403 would leak the existence of another tenant's resource by id; the 404 posture treats unknown ids and other-tenant ids identically.
A request without an Authorization header, with an unknown token, or with a revoked token returns 401 Unauthorized.
The developer-experience bar
The platform-wide developer-experience contract — referred to internally as the DX bar — is enforced by continuous integration on every mutating route. Each marker is something your client code can rely on; reading them once here lets you implement a robust integration in a single pass.
M1 — Caller-supplied IDs
Every silver entity has a id field on its request body that you supply as a UUID. The platform never invents IDs for you. This is the foundation of every other contract on this list: idempotency, reconciliation, and replay all depend on you being able to address a row by an identifier you chose.
If you re-POST a row with the same id, you receive 409 ID_CONFLICT and a fieldErrors entry pointing at id. Choose your IDs carefully — generated UUIDv4s are recommended, but any well-formed UUID works.
In addition to id, every silver entity also carries an externalId — a free-form string up to 255 characters that you use to address the row from your own system. The (org_id, portfolio_id, external_id) triple is unique for live rows, which is what makes reconciliation by caller ID possible.
M2 — Idempotency-Key header
Every mutating route reads an optional Idempotency-Key HTTP header. When present, the platform deduplicates against an internal archive: a second request with the same key returns the same response as the first, even if the body differs slightly. This is how clients survive network blips that leave them uncertain whether a request landed.
The header is case-insensitive on the wire (Idempotency-Key, idempotency-key, IDEMPOTENCY-KEY all work). The value is treated as opaque — choose anything stable per logical operation, typically a UUIDv4 plus a route discriminator.
M3 — ?dryRun=true
Every mutating route accepts a ?dryRun=true query parameter. A dry-run request runs validation and tenancy checks and returns the validated input, but performs no archive write and no silver insert. Use it to confirm a body parses cleanly before you commit a real write.
The response shape on a successful dry run is { "dryRun": true, "validatedInput": <body> } with a 200 OK. Validation failures during a dry run return the same 400 shape as a real write; only the side-effects differ.
M4 — fieldErrors validation envelope
Validation failures return 400 Bad Request with a structured envelope:
{
"error": "Validation failed",
"fieldErrors": [
{ "path": "interestRateApr", "message": "interestRateApr must be >= 0" },
{ "path": "termMonths", "message": "termMonths must be > 0" }
]
}path is the dotted path to the offending field; message is a human-readable explanation. The envelope is uniform across every route, so a single client-side handler can render every validation failure consistently.
M5 — Case-insensitive enums with "did you mean" hints
Domain enums (loan status, payment method, repayment frequency, currency code, and so on) accept any case on the wire. The platform normalises to the canonical lower-case form before persisting.
A miss returns 400 with a fieldErrors entry that names the offending value and suggests the closest accepted value:
{
"error": "Validation failed",
"fieldErrors": [
{
"path": "status",
"message": "got 'restruktured', did you mean 'restructured'?"
}
]
}The suggestion is computed with a small edit-distance metric, so transpositions and one-off typos are detected reliably.
M6 — metadata JSONB pass-through
Every silver entity accepts an optional metadata field of type object. The platform persists the object verbatim — no keys are stripped, no values are coerced. Use it to attach client-side context the platform itself does not need to understand: your internal ID, the source system, an extract timestamp, a campaign code.
Two surfaces opt out of metadata: the loan-status transition and the payment-cancel routes. The reasoning is documented inline on those routes.
M7 — Reconciliation by caller ID
Every silver entity is addressable by the externalId you submitted, scoped to a portfolio. The reconciliation surface — GET /loans/{externalId} and GET /counts — uses this to let you verify writes without ever needing the platform-assigned UUID. See the reconciliation page for the exact shapes.
Money representation
Monetary values are wire-format strings of decimal digits, with up to four fractional digits. JavaScript number is rejected because IEEE-754 binary floats cannot represent decimal cents exactly:
{ "amount": "100.50", "currencyCode": "USD" }A decimal with more than four fractional digits, or a value containing characters other than digits, a leading minus, and a single dot, returns 400 with a fieldErrors entry. Empty strings are rejected.
Currency codes are ISO-4217 three-letter codes. The platform recognises the common set explicitly; unknown codes that match the three-uppercase-letter shape are accepted and persisted verbatim so exotic-market codes are not blocked.
Date format
Dates are wire-format ISO-8601 calendar strings in the form YYYY-MM-DD:
{ "disbursementDate": "2026-01-15" }There is no timezone component. Dates are calendar dates in the portfolio's interpretation; the platform does not convert.
Soft-delete semantics
Silver rows are never physically deleted by API calls. Cancellations and other reversals set a deleted_at timestamp on the row instead. The implications:
externalIdis not freed by a cancel. The uniqueness constraint on(org_id, portfolio_id, external_id)applies only to live rows (wheredeleted_at IS NULL). A cancelled row is no longer live, so you can re-use itsexternalIdfor a corrected re-submission.- Reconciliation reads do not return cancelled rows by default. The
GET /loans/{externalId}andGET /countssurfaces exclude soft-deleted rows from their default response. - Bronze archives are immutable regardless. Even when a silver row is cancelled, the bronze submission that created it remains intact for replay and audit.
The bronze-first archive invariant
Every successful mutation archives the raw request body to bronze before the silver write. The implications for your integration:
- A
200/201means your submission is durably recorded even if downstream processing later flags it. Your audit trail starts the moment the platform acknowledges the request. - A
409 ID_CONFLICTstill archives. The bronze row records both the conflict and the attempted submission, so you can investigate later without ambiguity about what was sent. - Async imports archive at commit time. The full NDJSON object on S3 plus the import row itself is the archive — there is no separate per-row bronze write for an async import.
The bronze archive is replayable and immutable. You never read it directly through the public API, but its existence is why the reconciliation surface is authoritative — every row visible there has a corresponding archive entry behind it.
How the rest of this reference is organised
Each subsequent page on this reference site documents one ingestion shape or the reconciliation surface. Every route page follows the same structure: a one-sentence purpose, the request shape (headers, query, body), the response shape per status code, and a minimal cURL invocation. Field-by-field constraints live in tables you can scan; deep-dive examples and tri-language code samples live in the cookbook.
The errors and status codes page is the consolidated reference for the response envelopes used by every route on this site. Read it once before integrating; refer back to it whenever a non-200 status surprises you.