Single-record CRUD
The single-record CRUD surface is the low-volume path: one HTTP call per row. Use it for operational writes — a single new loan, a single payment that did not arrive in the daily batch, a status transition — and for read-backs by platform UUID. For end-of-day batches use bulk NDJSON; for historical loads use async imports.
Every route on this page honours the contract documented in the overview: bearer-token authentication, the developer-experience bar, the fieldErrors envelope, soft-delete semantics, and the bronze-first archive invariant. The route-by-route documentation below names only the route-specific shapes; refer back to the overview for cross-cutting behaviour.
Routes at a glance
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/monitoring/loan/borrowers | Create a borrower |
GET | /api/v1/monitoring/loan/borrowers/{id} | Read a borrower by UUID with PII masking |
POST | /api/v1/monitoring/loan/loans | Create a loan |
POST | /api/v1/monitoring/loan/payments | Create an actual payment |
GET | /api/v1/monitoring/loan/payments/{id} | Read an actual payment by UUID |
PATCH | /api/v1/monitoring/loan/loans/{loanRef}/status | Transition a loan to a new status |
POST | /api/v1/monitoring/loan/payments/{id}/cancel | Soft-delete a payment |
All mutating routes accept the Idempotency-Key header, the ?dryRun=true query parameter, and return the structured fieldErrors envelope on validation failure.
POST /api/v1/monitoring/loan/borrowers
Create a borrower in the portfolio identified by portfolioId.
Request
POST /api/v1/monitoring/loan/borrowers HTTP/1.1
Authorization: Bearer <PAT>
Content-Type: application/json
Idempotency-Key: <opaque-key>Body:
| Field | Type | Required | Constraint |
|---|---|---|---|
id | UUID string | yes | Caller-supplied platform identifier. |
portfolioId | UUID string | yes | The owning portfolio; must exist in your organisation. |
externalId | string | yes | 1-255 chars; unique per (portfolio, externalId) for live rows. |
name | string | null | no | Borrower full name. Persists raw; investor-facing reads mask. |
email | string | null | no | RFC 5322 email; persists raw; investor-facing reads mask. |
phone | string | null | no | Free-form phone; persists raw; investor-facing reads mask. |
nationalIdLast4 | string | null | no | Exactly four digits; never returns the leading digits. |
countryCode | string | null | no | ISO-3166 two-letter code (uppercase). |
dob | date string | null | no | YYYY-MM-DD. |
gender | enum | null | no | male | female | non_binary | other | undisclosed (case-insensitive). |
incomeBand | string | null | no | Free-form. |
employmentStatus | string | null | no | Free-form. |
metadata | object | null | no | Verbatim pass-through. |
Responses
201 Created on a fresh write, 200 OK on an idempotent replay:
{
"id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e",
"externalId": "BORROWER-001",
"bronzeSubmissionId": "1a2b3c4d-5e6f-7890-abcd-ef0123456789",
"reused": false
}| Status | Envelope | Trigger |
|---|---|---|
200 OK | { id, externalId, bronzeSubmissionId, reused: true } | Idempotency-Key matched a prior request; no new write. |
201 Created | { id, externalId, bronzeSubmissionId, reused: false } | New row inserted. |
400 Bad Request | fieldErrors | Validation failure on any body field. |
404 Not Found | { error, code: "PORTFOLIO_NOT_FOUND" } | portfolioId does not exist or belongs to another organisation. |
409 Conflict | { error, code: "ID_CONFLICT", fieldErrors: [...] } | A live borrower with the same id already exists. |
cURL
curl -X POST https://app.waafir.io/api/v1/monitoring/loan/borrowers \
-H "Authorization: Bearer $WAAFIR_PAT" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e" \
-d '{
"id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e",
"portfolioId": "ddf63d12-1234-5678-9abc-def012345678",
"externalId": "BORROWER-001",
"gender": "female",
"countryCode": "KE"
}'GET /api/v1/monitoring/loan/borrowers/{id}
Read a borrower by platform UUID. PII fields are masked by default; admins can request raw PII with ?unmask=true, which writes an audit-log entry.
Request
| Parameter | Position | Required | Constraint |
|---|---|---|---|
id | path | yes | The borrower's platform UUID. |
unmask | query | no | true returns raw PII; admin-only. Default masks. |
Responses
200 OK with the masked borrower record:
{
"id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e",
"portfolioId": "ddf63d12-1234-5678-9abc-def012345678",
"externalId": "BORROWER-001",
"name": "J***",
"email": "j***@example.com",
"phone": "***-***-1234",
"nationalIdLast4": "1234",
"countryCode": "KE",
"dob": "1985-03-20",
"gender": "female",
"incomeBand": null,
"employmentStatus": null,
"metadata": {}
}| Status | Trigger |
|---|---|
200 OK | Row exists in your organisation. PII is raw if the caller is an admin AND ?unmask=true; otherwise PII is masked. |
400 Bad Request | id is not a well-formed UUID. |
404 Not Found | Row does not exist, is cancelled, or belongs to another organisation. |
PII masking applies to name, email, phone, and nationalIdLast4. The nationalIdLast4 field already only stores the last four digits at write time; the masking on read still hides them from non-admin reads.
A non-admin caller passing ?unmask=true does not receive a 403 — the response is still 200 with the masked shape, and an audit-log entry of the denied unmask attempt is written. The platform does not surface the role check as an error because the masked shape is the safe default; the audit trail is what makes the attempt visible to the security team.
An admin caller passing ?unmask=true receives the raw PII and the request writes a separate audit-log entry recording the unmask. The response carries Cache-Control: private, no-store to prevent intermediaries from caching the raw PII payload.
POST /api/v1/monitoring/loan/loans
Create a loan against an existing borrower in the same portfolio.
Request
| Field | Type | Required | Constraint |
|---|---|---|---|
id | UUID string | yes | Caller-supplied platform identifier. |
portfolioId | UUID string | yes | The owning portfolio. |
borrowerId | UUID string | yes | A live borrower in the same portfolio. |
externalId | string | yes | 1-255 chars; unique per portfolio for live rows. |
predecessorLoanId | UUID string | null | no | Set on a restructure; points at the loan being replaced. |
principalAmount | decimal string | yes | Up to four fractional digits. |
currencyCode | string | yes | ISO-4217 three-letter; case-insensitive. |
interestRateApr | decimal string | yes | Up to six fractional digits; must be >= 0. |
disbursementDate | date string | yes | YYYY-MM-DD. |
termMonths | integer | yes | Must be > 0. |
repaymentFrequency | enum | yes | monthly | weekly | biweekly | quarterly | bullet (case-insensitive). |
productType | string | null | no | Free-form, e.g. "Term loan", "Overdraft". |
status | enum | yes | pending | active | paid_off | restructured | written_off | defaulted (case-insensitive). |
metadata | object | null | no | Verbatim pass-through. |
Responses
201 Created (or 200 OK on idempotent replay) with { id, externalId, bronzeSubmissionId, reused }.
| Status | Envelope | Trigger |
|---|---|---|
200 OK | { ..., reused: true } | Idempotent replay. |
201 Created | { ..., reused: false } | New loan inserted. |
400 Bad Request | fieldErrors | Validation failure (e.g. negative interestRateApr, malformed currencyCode). |
404 Not Found | { error, code: "PORTFOLIO_NOT_FOUND" } | Portfolio missing or cross-org. |
409 Conflict | { error, code: "ID_CONFLICT" } | Loan with the same id already exists live. |
422 Unprocessable Entity | { error, code: "REFERENCED_ENTITY_NOT_FOUND", fieldErrors: [{ path: "borrowerId", ... }] } | borrowerId does not resolve to a live borrower in the same portfolio. |
cURL
curl -X POST https://app.waafir.io/api/v1/monitoring/loan/loans \
-H "Authorization: Bearer $WAAFIR_PAT" \
-H "Content-Type: application/json" \
-d '{
"id": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
"portfolioId": "ddf63d12-1234-5678-9abc-def012345678",
"borrowerId": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e",
"externalId": "LOAN-001",
"principalAmount": "10000.00",
"currencyCode": "USD",
"interestRateApr": "0.18",
"disbursementDate": "2026-01-15",
"termMonths": 12,
"repaymentFrequency": "monthly",
"status": "active"
}'POST /api/v1/monitoring/loan/payments
Record an actual payment against an existing loan. Use this for received payments, partial payments, and write-offs. Restructures are modelled as a new loan with predecessorLoanId set, not as a payment.
Request
| Field | Type | Required | Constraint |
|---|---|---|---|
id | UUID string | yes | Caller-supplied platform identifier. |
portfolioId | UUID string | yes | The owning portfolio. |
loanId | UUID string | yes | A live loan in the same portfolio. |
externalId | string | yes | 1-255 chars; unique per portfolio for live rows. |
paymentDate | date string | yes | YYYY-MM-DD. |
amount | decimal string | yes | The total payment received. |
currencyCode | string | yes | ISO-4217. |
principalPaid | decimal string | no | Defaults to "0". |
interestPaid | decimal string | no | Defaults to "0". |
feesPaid | decimal string | no | Defaults to "0". |
writeOffAmount | decimal string | no | Defaults to "0". See forgiveness rule below. |
paymentNotes | string | null | no | Required to be "Forgiven" (case-insensitive) when writeOffAmount > 0. |
paymentMethod | enum | null | no | cash | bank_transfer | card | mobile_money | check | other. |
metadata | object | null | no | Verbatim pass-through. |
Forgiveness rule. If writeOffAmount > 0, paymentNotes must equal "Forgiven" case-insensitively. The platform fails the request at the API boundary so you do not bounce off a database constraint after the bronze write.
Responses
201 Created (or 200 OK on idempotent replay) with { id, externalId, bronzeSubmissionId, reused }.
| Status | Envelope | Trigger |
|---|---|---|
200 OK | { ..., reused: true } | Idempotent replay. |
201 Created | { ..., reused: false } | New payment inserted. |
400 Bad Request | fieldErrors | Validation failure (malformed amounts, enum miss, missing required field). |
404 Not Found | { error, code: "PORTFOLIO_NOT_FOUND" } | Portfolio missing or cross-org. |
409 Conflict | { error, code: "ID_CONFLICT" } | Payment with the same id already exists live. |
422 Unprocessable Entity | { error, code: "REFERENCED_ENTITY_NOT_FOUND", fieldErrors: [{ path: "loanId", ... }] } | loanId does not resolve to a live loan in the same portfolio. |
422 Unprocessable Entity | { error, code: "CHECK_VIOLATION", fieldErrors: [...] } | The forgiveness invariant slipped past the schema layer and was rejected by a database CHECK constraint (rare; the schema rejects the same shape at validation time). |
GET /api/v1/monitoring/loan/payments/{id}
Read an actual payment by platform UUID, including cancelled (soft-deleted) rows. Use to verify a write or inspect a cancelled record.
Request
| Parameter | Position | Required | Constraint |
|---|---|---|---|
id | path | yes | The payment's platform UUID. |
Responses
200 OK with the full payment record. Cancelled rows surface with deletedAt populated; live rows have deletedAt: null. 404 Not Found on cross-org or unknown UUID. 400 Bad Request if id is not a well-formed UUID.
PATCH /api/v1/monitoring/loan/loans/{loanRef}/status
Transition a loan to a new status. The {loanRef} path segment accepts either the loan's platform UUID or its externalId. When you pass an externalId, you must also supply ?portfolioId=<UUID> because external IDs are not unique across portfolios.
Request
PATCH /api/v1/monitoring/loan/loans/LOAN-001/status?portfolioId=ddf63d12-1234-5678-9abc-def012345678 HTTP/1.1
Authorization: Bearer <PAT>
Content-Type: application/json
Idempotency-Key: <opaque-key>Body:
| Field | Type | Required | Constraint |
|---|---|---|---|
status | enum | yes | One of active | paid_off | defaulted | written_off | restructured (case-insensitive). |
effectiveDate | date string | yes | YYYY-MM-DD. |
reason | string | null | no | 1-500 chars; recorded in the audit log. |
Status transition matrix
The platform enforces a transition matrix; an invalid transition returns 409 INVALID_STATUS_TRANSITION with fieldErrors naming the from/to states.
| From | Allowed targets |
|---|---|
pending | active |
active | paid_off, defaulted, written_off, restructured |
paid_off | (terminal) |
defaulted | written_off, restructured |
written_off | (terminal) |
restructured | (terminal) |
When you transition a loan to restructured, the natural follow-up is a POST /loans for the replacement loan with predecessorLoanId set. The two writes are intentionally not atomic — if the replacement POST fails, the predecessor remains in restructured and the reconciliation surface will surface the drift the next time you query.
Responses
200 OK on success:
{
"id": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
"externalId": "LOAN-001",
"status": "restructured",
"bronzeSubmissionId": "9d8c7b6a-5432-1098-7654-321098765432",
"reused": false
}| Status | Envelope | Trigger |
|---|---|---|
200 OK | { id, externalId, status, bronzeSubmissionId, reused } | Transition applied (or idempotent replay with reused: true). |
400 Bad Request | fieldErrors | Body validation failure, missing portfolioId when looking up by externalId, malformed enum. |
404 Not Found | { error, code: "NOT_FOUND" } | Loan not found by UUID, or (portfolioId, externalId) does not resolve. |
422 Unprocessable Entity | { error, code: "INVALID_STATUS_TRANSITION", fieldErrors: [...] } | The from/to pair is not in the transition matrix; the fieldErrors[].message lists the allowed targets. |
The effectiveDate you supply is recorded in the bronze envelope and the audit log; it does not appear in the response body. metadata is intentionally not accepted on this route — the transition itself is the audit event, and the optional reason field carries the human-readable context.
POST /api/v1/monitoring/loan/payments/{id}/cancel
Soft-delete a previously recorded payment. The cancel endpoint is the only reversal surface for the loan domain — there is no PATCH /loans/{id}/adjust and there will not be one.
Request
POST /api/v1/monitoring/loan/payments/7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f/cancel HTTP/1.1
Authorization: Bearer <PAT>
Content-Type: application/jsonBody (optional):
| Field | Type | Required | Constraint |
|---|---|---|---|
reason | string | null | no | 1-500 chars; recorded on the cancel audit event. |
An empty body is accepted; you may omit the Content-Type header entirely.
Responses
200 OK on success:
{
"id": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
"deletedAt": "2026-03-15T11:02:33Z",
"bronzeSubmissionId": "9d8c7b6a-5432-1098-7654-321098765432",
"reused": false,
"alreadyCancelled": false
}On a second call against the same payment, the response shape is identical except alreadyCancelled: true and deletedAt is the original cancellation timestamp. The reused flag tracks the bronze idempotency state, while alreadyCancelled reflects whether the silver row was mutated by this call.
| Status | Envelope | Trigger |
|---|---|---|
200 OK | { id, deletedAt, bronzeSubmissionId, reused, alreadyCancelled } | First or subsequent cancel. |
400 Bad Request | fieldErrors | id is not a well-formed UUID, or reason exceeds 500 chars. |
404 Not Found | { error, code: "NOT_FOUND" } | Payment does not exist or belongs to another organisation. |
A bronze submission is written on every cancel — including the second one — for audit completeness. The silver row is only mutated on the first call.
Soft-delete and externalId reuse
A cancelled payment leaves its externalId available for reuse on a future write, because the uniqueness constraint applies only to live rows. To correct a wrong submission, the canonical path is:
POST /api/v1/monitoring/loan/payments/{id}/cancelon the original row.POST /api/v1/monitoring/loan/paymentsfor a new row with the corrected fields. You may reuse the sameexternalId; you must supply a differentid.
This is intentional: cancellations are non-destructive (the original row is preserved for audit), and the reuse of externalId keeps the operational caller-id space dense for reconciliation purposes.