Waafir
API Reference

Reconciliation

The reconciliation surface lets you verify what landed in Waafir using your own caller IDs rather than the platform-assigned UUIDs. It is intentionally minimal — two routes — but it is the operational backbone of a robust integration: every healthy daily ingestion ends with a reconciliation call.

Why reconcile by caller ID

When you submit a loan with externalId: "L-001", your system already knows the row by that name. Asking the platform "do you have L-001?" is the natural follow-up — and not having to translate to a platform UUID first means your reconciliation logic can run against the same identifiers your bookkeeping uses.

The platform guarantees that the (org_id, portfolio_id, external_id) triple is unique for live rows, which is what makes lookup by externalId a single-row read.

Routes

MethodPathPurpose
GET/api/v1/monitoring/loan/loans/{externalId}?portfolioId=<UUID>Read a single loan by caller ID.
GET/api/v1/monitoring/loan/countsRead per-entity row totals, optionally narrowed by portfolio, date, or entity.

GET /api/v1/monitoring/loan/loans/{externalId}

Read a single loan by the externalId you submitted. Excludes cancelled (soft-deleted) rows.

Request

ParameterPositionRequiredConstraint
externalIdpathyes1-255 chars; percent-encode any URL-unsafe characters.
portfolioIdqueryyesUUID of the portfolio that owns the loan.

The portfolioId is required because externalId is not unique across portfolios — it is only unique within a portfolio.

Response — 200 OK

{
  "loan": {
    "id": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
    "portfolioId": "ddf63d12-1234-5678-9abc-def012345678",
    "borrowerId": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e",
    "externalId": "L-001",
    "predecessorLoanId": null,
    "principalAmount": "10000.0000",
    "currencyCode": "USD",
    "interestRateApr": "0.180000",
    "disbursementDate": "2026-01-15",
    "termMonths": 12,
    "repaymentFrequency": "monthly",
    "productType": "Term loan",
    "status": "active",
    "metadata": {},
    "createdAt": "2026-01-15T08:00:00Z",
    "updatedAt": "2026-01-15T08:00:00Z"
  },
  "borrower": {
    "id": "5b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e",
    "externalId": "B-001",
    "name": "J***",
    "email": "j***@example.com",
    "phone": "***-***-1234",
    "nationalIdLast4": "1234",
    "countryCode": "KE",
    "dob": "1985-03-20",
    "gender": "female",
    "incomeBand": null,
    "employmentStatus": null
  }
}

The response embeds the loan's borrower with PII masked. This is the investor-scope view — the same masking that applies to the borrower GET endpoint. The masking lets you confirm "this loan is attached to the right borrower" without surfacing PII to the calling system. Admins who need raw PII go through the borrower-by-UUID surface with ?unmask=true and audit-log capture.

Money fields appear with their full stored precision (four fractional digits for monetary amounts; six for interestRateApr); your client should treat them as opaque decimal strings rather than parsing to floats.

Status codes

StatusTrigger
200 OKLoan found in your organisation.
400 Bad RequestportfolioId missing or malformed, or externalId contains malformed percent-encoding.
404 Not FoundLoan does not exist live in the supplied portfolio, or the portfolio belongs to another organisation.

cURL

curl https://app.waafir.io/api/v1/monitoring/loan/loans/L-001?portfolioId=ddf63d12-1234-5678-9abc-def012345678 \
  -H "Authorization: Bearer $WAAFIR_PAT"

GET /api/v1/monitoring/loan/counts

Read per-entity row counts, optionally narrowed by portfolio, by date, or to a single entity. Soft-deleted rows are excluded.

Request

ParameterPositionRequiredConstraint
portfolioIdquerynoUUID; narrows to a single portfolio. Omit for an organisation-wide rollup.
datequerynoYYYY-MM-DD; when supplied, the response includes an onDate count per entity.
entityquerynoOne of borrowers, loans, scheduled_repayments, actual_payments, collateral, collateral_valuations (case-insensitive). When supplied, only that entity is returned.

Response — 200 OK

Default rollup, all six entities:

{
  "counts": {
    "borrowers": { "total": 1200 },
    "loans": { "total": 3450 },
    "scheduled_repayments": { "total": 41400 },
    "actual_payments": { "total": 32118 },
    "collateral": { "total": 600 },
    "collateral_valuations": { "total": 1840 }
  },
  "date": null,
  "portfolioId": null
}

With ?date=2026-05-22, each entity gains an onDate count of rows whose created_at::date equals the supplied date:

{
  "counts": {
    "borrowers": { "total": 1200, "onDate": 0 },
    "loans": { "total": 3450, "onDate": 12 },
    "scheduled_repayments": { "total": 41400, "onDate": 144 },
    "actual_payments": { "total": 32118, "onDate": 1207 },
    "collateral": { "total": 600, "onDate": 0 },
    "collateral_valuations": { "total": 1840, "onDate": 0 }
  },
  "date": "2026-05-22",
  "portfolioId": null
}

With ?entity=loans, only the loans entry appears.

Status codes

StatusTrigger
200 OKQuery parsed; counts returned.
400 Bad RequestMalformed portfolioId, date, or entity.

A portfolioId that does not exist or belongs to another organisation does not return 404; it returns 200 with zeros across the board. This is intentional — the counts surface is a read of your own data, and an unknown portfolio is functionally indistinguishable from a portfolio with no rows.

cURL

# Full daily reconciliation for one portfolio:
curl "https://app.waafir.io/api/v1/monitoring/loan/counts?portfolioId=ddf63d12-1234-5678-9abc-def012345678&date=2026-05-22" \
  -H "Authorization: Bearer $WAAFIR_PAT"

A typical daily reconciliation

Once you have completed today's submissions, the canonical reconciliation flow is:

  1. Call GET /counts?date=<today> with your portfolio. Compare the onDate count for each entity to what you submitted today. If the counts match what you submitted, your batch landed cleanly.
  2. For any mismatch, look up the specific rows with GET /loans/{externalId}. The lookups are cheap — a single index scan each — and they return the canonical row state including the platform-assigned UUID, which you can then use to investigate further.
  3. For row-level investigation, the single-record GET endpoints and the async-imports paged-errors endpoint are the surfaces that surface per-row failure detail.

The reconciliation surface is intentionally read-only and inexpensive — it is the integration's heartbeat, not a load surface. Call it once or twice per day per portfolio; there is no rate limit specific to it, but please do not poll it from a tight loop.