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 ownexternal_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. Withoutentityit returns counts for all six loan-domain entities; withentityit returns just that one. Thedateparameter adds anonDatecount 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.
Cancel an in-flight import
Stop a long-running async import at the next batch boundary — the cancel endpoint flips the worker to the cancelled state without leaving partially-written rows.
AI Virtual Data Room
An AI virtual data room is a secure VDR built to be run by AI on two axes — a built-in agent workforce that organises, indexes, redacts, translates, and answers questions inside, and an open MCP surface that lets your own AI operate the room from outside.