Waafir
Cookbook

Reconcile your data by external ID

This recipe shows how to close the loop on an ingestion submission: confirm a specific row landed by your own caller-supplied identifier, and verify the aggregate counts per entity match what you submitted. Both reads are organisation-scoped and never expose another tenant's data.

Why reconciliation matters

You sent Waafir an NDJSON file containing 1,200 payments today. The import landed succeeded with rowCountSucceeded: 1200. That is one signal. The reconciliation reads give you two stronger ones:

  • Spot-check by caller ID — pick a payment your team cares about, look it up by your own external_id, confirm the values match what you submitted. The DX-bar criterion M7 ("reconciliation by caller ID") says your identifier is the canonical handle, not Waafir's internal UUID.
  • Aggregate counts per entity — confirm Waafir's count of live silver rows for the day matches your source-of-truth count. "I submitted 1,200 payments today; counts says I have 1,200 with created_at::date = today. Reconciled."

The reads are cache-safe (Cache-Control: private, no-store on the per-loan lookup) so the response reflects the latest write. Reconciling immediately after an import returns the just-landed state.

The two endpoints

  • GET /api/v1/monitoring/loan/loans/{externalId}?portfolioId=… — single-loan reconciliation by your own external_id. Returns the loan body plus the masked borrower context so you can verify the loan is attached to the borrower you expect, without a second request.
  • GET /api/v1/monitoring/loan/counts?date=YYYY-MM-DD&portfolioId=…&entity=… — per-entity row counts. Without entity it returns counts for all six loan-domain entities; with entity it returns just that one. The date parameter adds an onDate count alongside the all-time count.

Both endpoints return 404 for missing rows and for rows that exist in another organisation — Waafir never confirms or denies the existence of another tenant's data.

The recipe

# curl — reconcile a single loan and verify per-entity counts
set -euo pipefail

PAT="${WAAFIR_PAT}"
BASE_URL="${WAAFIR_BASE_URL:-https://app.waafir.io}"
PORTFOLIO_ID="${WAAFIR_PORTFOLIO_ID}"
EXTERNAL_LOAN_ID="L-2026-05-22-1234"
TODAY="2026-05-22"

# Step 1 — single-loan reconciliation. The external_id is URL-encoded
# automatically by curl when passed as a positional path segment.
curl -fsS \
  "${BASE_URL}/api/v1/monitoring/loan/loans/${EXTERNAL_LOAN_ID}?portfolioId=${PORTFOLIO_ID}" \
  -H "Authorization: Bearer ${PAT}" \
  | jq '{loan: {externalId: .loan.externalId, principalAmount: .loan.principalAmount, status: .loan.status, disbursementDate: .loan.disbursementDate}, borrower: {externalId: .borrower.externalId, name: .borrower.name, countryCode: .borrower.countryCode}}'

# Step 2 — full per-entity counts for the portfolio.
curl -fsS \
  "${BASE_URL}/api/v1/monitoring/loan/counts?portfolioId=${PORTFOLIO_ID}&date=${TODAY}" \
  -H "Authorization: Bearer ${PAT}" \
  | jq

# Step 3 — counts for just the payments entity (narrower request).
curl -fsS \
  "${BASE_URL}/api/v1/monitoring/loan/counts?portfolioId=${PORTFOLIO_ID}&date=${TODAY}&entity=actual_payments" \
  -H "Authorization: Bearer ${PAT}" \
  | jq
// TypeScript — reconcile a single loan and verify per-entity counts
const PAT = process.env.WAAFIR_PAT!;
const BASE_URL = process.env.WAAFIR_BASE_URL ?? "https://app.waafir.io";
const portfolioId = process.env.WAAFIR_PORTFOLIO_ID!;
const externalLoanId = "L-2026-05-22-1234";
const today = "2026-05-22";

const auth = { Authorization: `Bearer ${PAT}` };

// Step 1 — single-loan reconciliation.
const loanRes = await fetch(
    `${BASE_URL}/api/v1/monitoring/loan/loans/${encodeURIComponent(externalLoanId)}?portfolioId=${portfolioId}`,
    { headers: auth },
);
if (loanRes.status === 404) throw new Error(`loan ${externalLoanId} not found in portfolio ${portfolioId}`);
if (!loanRes.ok) throw new Error(`reconcile failed: ${loanRes.status}`);
const loanBody = (await loanRes.json()) as {
    loan: { externalId: string; principalAmount: string; status: string; disbursementDate: string };
    // borrower may be null if a referential-integrity edge case dropped
    // the borrower row; the response is a partial projection in that case.
    borrower: {
        externalId: string;
        name: string;            // masked (e.g. "M*** S. L."), empty when absent
        countryCode: string | null;
    } | null;
};
console.log({
    loan: {
        externalId: loanBody.loan.externalId,
        principalAmount: loanBody.loan.principalAmount,
        status: loanBody.loan.status,
        disbursementDate: loanBody.loan.disbursementDate,
    },
    borrower: loanBody.borrower
        ? {
              externalId: loanBody.borrower.externalId,
              name: loanBody.borrower.name,
              countryCode: loanBody.borrower.countryCode,
          }
        : null,
});

// Step 2 — full per-entity counts.
const countsRes = await fetch(
    `${BASE_URL}/api/v1/monitoring/loan/counts?portfolioId=${portfolioId}&date=${today}`,
    { headers: auth },
);
console.log("counts:", await countsRes.json());

// Step 3 — narrow to a single entity.
const paymentsRes = await fetch(
    `${BASE_URL}/api/v1/monitoring/loan/counts?portfolioId=${portfolioId}&date=${today}&entity=actual_payments`,
    { headers: auth },
);
console.log("payments:", await paymentsRes.json());
# python — reconcile a single loan and verify per-entity counts
import os
import urllib.parse
import requests

PAT = os.environ["WAAFIR_PAT"]
BASE_URL = os.environ.get("WAAFIR_BASE_URL", "https://app.waafir.io")
portfolio_id = os.environ["WAAFIR_PORTFOLIO_ID"]
external_loan_id = "L-2026-05-22-1234"
today = "2026-05-22"

auth = {"Authorization": f"Bearer {PAT}"}

# Step 1 — single-loan reconciliation. external_id can contain hyphens
# and other URL-safe chars; encode defensively in case yours contain slashes.
encoded = urllib.parse.quote(external_loan_id, safe="")
loan = requests.get(
    f"{BASE_URL}/api/v1/monitoring/loan/loans/{encoded}",
    params={"portfolioId": portfolio_id},
    headers=auth,
    timeout=30,
)
if loan.status_code == 404:
    raise SystemExit(f"loan {external_loan_id} not found in portfolio {portfolio_id}")
loan.raise_for_status()
body = loan.json()
print({
    "loan": {
        "externalId": body["loan"]["externalId"],
        "principalAmount": body["loan"]["principalAmount"],
        "status": body["loan"]["status"],
        "disbursementDate": body["loan"]["disbursementDate"],
    },
    "borrower": {
        "externalId": body["borrower"]["externalId"],
        "name": body["borrower"]["name"],
        "countryCode": body["borrower"]["countryCode"],
    },
})

# Step 2 — full per-entity counts.
counts = requests.get(
    f"{BASE_URL}/api/v1/monitoring/loan/counts",
    params={"portfolioId": portfolio_id, "date": today},
    headers=auth,
    timeout=30,
).json()
print("counts:", counts)

# Step 3 — narrow to one entity.
payments = requests.get(
    f"{BASE_URL}/api/v1/monitoring/loan/counts",
    params={"portfolioId": portfolio_id, "date": today, "entity": "actual_payments"},
    headers=auth,
    timeout=30,
).json()
print("payments:", payments)

What to verify

For the single-loan response, every field you submitted under your external_id should round-trip identically: principalAmount, currencyCode, interestRateApr, disbursementDate, termMonths, repaymentFrequency, productType, status, metadata. The borrower section is masked according to Waafir's PII policy — name, email, phone, and nationalIdLast4 are masked by default on this endpoint; non-PII fields (countryCode, dob, gender, incomeBand, employmentStatus) pass through.

For the counts response, the totals are scoped to your organisation and exclude soft-deleted rows. With ?date=YYYY-MM-DD, each entity's entry includes an onDate count — rows whose created_at::date matches the date — alongside the all-time total. The onDate count is your matched-the-submission signal.

A 404 on the single-loan endpoint always means the loan is not visible to your organisation in that portfolio. It may not exist at all, or it may exist under a different organisation; the response intentionally does not distinguish.

This recipe anchors the DX-bar criterion M7 — reconciliation by caller ID from the epic specification. The reconciliation endpoints are the integrator-facing surface of that contract: your identifiers stay canonical end to end.