Waafir
API Reference

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

MethodPathPurpose
POST/api/v1/monitoring/loan/borrowersCreate a borrower
GET/api/v1/monitoring/loan/borrowers/{id}Read a borrower by UUID with PII masking
POST/api/v1/monitoring/loan/loansCreate a loan
POST/api/v1/monitoring/loan/paymentsCreate an actual payment
GET/api/v1/monitoring/loan/payments/{id}Read an actual payment by UUID
PATCH/api/v1/monitoring/loan/loans/{loanRef}/statusTransition a loan to a new status
POST/api/v1/monitoring/loan/payments/{id}/cancelSoft-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:

FieldTypeRequiredConstraint
idUUID stringyesCaller-supplied platform identifier.
portfolioIdUUID stringyesThe owning portfolio; must exist in your organisation.
externalIdstringyes1-255 chars; unique per (portfolio, externalId) for live rows.
namestring | nullnoBorrower full name. Persists raw; investor-facing reads mask.
emailstring | nullnoRFC 5322 email; persists raw; investor-facing reads mask.
phonestring | nullnoFree-form phone; persists raw; investor-facing reads mask.
nationalIdLast4string | nullnoExactly four digits; never returns the leading digits.
countryCodestring | nullnoISO-3166 two-letter code (uppercase).
dobdate string | nullnoYYYY-MM-DD.
genderenum | nullnomale | female | non_binary | other | undisclosed (case-insensitive).
incomeBandstring | nullnoFree-form.
employmentStatusstring | nullnoFree-form.
metadataobject | nullnoVerbatim 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
}
StatusEnvelopeTrigger
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 RequestfieldErrorsValidation 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

ParameterPositionRequiredConstraint
idpathyesThe borrower's platform UUID.
unmaskquerynotrue 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": {}
}
StatusTrigger
200 OKRow exists in your organisation. PII is raw if the caller is an admin AND ?unmask=true; otherwise PII is masked.
400 Bad Requestid is not a well-formed UUID.
404 Not FoundRow 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

FieldTypeRequiredConstraint
idUUID stringyesCaller-supplied platform identifier.
portfolioIdUUID stringyesThe owning portfolio.
borrowerIdUUID stringyesA live borrower in the same portfolio.
externalIdstringyes1-255 chars; unique per portfolio for live rows.
predecessorLoanIdUUID string | nullnoSet on a restructure; points at the loan being replaced.
principalAmountdecimal stringyesUp to four fractional digits.
currencyCodestringyesISO-4217 three-letter; case-insensitive.
interestRateAprdecimal stringyesUp to six fractional digits; must be >= 0.
disbursementDatedate stringyesYYYY-MM-DD.
termMonthsintegeryesMust be > 0.
repaymentFrequencyenumyesmonthly | weekly | biweekly | quarterly | bullet (case-insensitive).
productTypestring | nullnoFree-form, e.g. "Term loan", "Overdraft".
statusenumyespending | active | paid_off | restructured | written_off | defaulted (case-insensitive).
metadataobject | nullnoVerbatim pass-through.

Responses

201 Created (or 200 OK on idempotent replay) with { id, externalId, bronzeSubmissionId, reused }.

StatusEnvelopeTrigger
200 OK{ ..., reused: true }Idempotent replay.
201 Created{ ..., reused: false }New loan inserted.
400 Bad RequestfieldErrorsValidation 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

FieldTypeRequiredConstraint
idUUID stringyesCaller-supplied platform identifier.
portfolioIdUUID stringyesThe owning portfolio.
loanIdUUID stringyesA live loan in the same portfolio.
externalIdstringyes1-255 chars; unique per portfolio for live rows.
paymentDatedate stringyesYYYY-MM-DD.
amountdecimal stringyesThe total payment received.
currencyCodestringyesISO-4217.
principalPaiddecimal stringnoDefaults to "0".
interestPaiddecimal stringnoDefaults to "0".
feesPaiddecimal stringnoDefaults to "0".
writeOffAmountdecimal stringnoDefaults to "0". See forgiveness rule below.
paymentNotesstring | nullnoRequired to be "Forgiven" (case-insensitive) when writeOffAmount > 0.
paymentMethodenum | nullnocash | bank_transfer | card | mobile_money | check | other.
metadataobject | nullnoVerbatim 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 }.

StatusEnvelopeTrigger
200 OK{ ..., reused: true }Idempotent replay.
201 Created{ ..., reused: false }New payment inserted.
400 Bad RequestfieldErrorsValidation 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

ParameterPositionRequiredConstraint
idpathyesThe 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:

FieldTypeRequiredConstraint
statusenumyesOne of active | paid_off | defaulted | written_off | restructured (case-insensitive).
effectiveDatedate stringyesYYYY-MM-DD.
reasonstring | nullno1-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.

FromAllowed targets
pendingactive
activepaid_off, defaulted, written_off, restructured
paid_off(terminal)
defaultedwritten_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
}
StatusEnvelopeTrigger
200 OK{ id, externalId, status, bronzeSubmissionId, reused }Transition applied (or idempotent replay with reused: true).
400 Bad RequestfieldErrorsBody 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/json

Body (optional):

FieldTypeRequiredConstraint
reasonstring | nullno1-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.

StatusEnvelopeTrigger
200 OK{ id, deletedAt, bronzeSubmissionId, reused, alreadyCancelled }First or subsequent cancel.
400 Bad RequestfieldErrorsid 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:

  1. POST /api/v1/monitoring/loan/payments/{id}/cancel on the original row.
  2. POST /api/v1/monitoring/loan/payments for a new row with the corrected fields. You may reuse the same externalId; you must supply a different id.

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.