Validate a load with dry-run before committing data
This recipe shows how to use the async import endpoint as a side-effect-free validator before you commit data. The flow is identical to the bulk import recipe — start, upload, commit, poll — with one parameter flipped: dryRun: true. The worker runs the full validation pipeline, counts validated rows toward the success total, and never writes a silver row.
When to use this
Dry-run is the right tool when you want to know whether a file you generated is shaped correctly before you trust it with a daily submission. Typical uses include:
- The first time you wire up your loan-tape export and you want to confirm the JSON shape matches Waafir's expectations.
- A nightly canary run on the first 100 rows of a new file before submitting the rest.
- After an internal schema change on your side, to verify the wire shape still parses cleanly.
The dry-run runs the same validators as the real ingest path, so passing a dry-run is a strong guarantee that the same file submitted for real will land successfully. The asymmetry: a dry-run does not exercise database uniqueness constraints (since no rows are written), so a real run can still fail on duplicate external IDs that dry-run accepted. Reconcile by external ID after the real run if you need that guarantee.
The recipe
# curl — dry-run validation
set -euo pipefail
PAT="${WAAFIR_PAT}"
BASE_URL="${WAAFIR_BASE_URL:-https://app.waafir.io}"
NDJSON_FILE="loans-canary.ndjson"
# Step 1 — start an import with dryRun=true. The query parameter and
# the body field are both accepted; the query is the curl-friendly form.
START=$(curl -fsS -X POST "${BASE_URL}/api/v1/monitoring/loan/imports?dryRun=true" \
-H "Authorization: Bearer ${PAT}" \
-H "Content-Type: application/json" \
-d "$(cat <<JSON
{
"kind": "loan",
"filename": "${NDJSON_FILE}",
"sizeBytes": $(stat -c%s "${NDJSON_FILE}")
}
JSON
)")
IMPORT_ID=$(echo "$START" | jq -r .importId)
UPLOAD_URL=$(echo "$START" | jq -r .uploadUrl)
# Step 2 — upload the file, applying the signed-in headers the start
# response returned (same shape as a real import — see the
# async-import recipe for the full rationale).
HEADER_ARGS=$(echo "$START" | jq -r '.headers | to_entries[] | "-H \"\(.key): \(.value)\""')
eval curl -fsS -X PUT --data-binary @"\"${NDJSON_FILE}\"" $HEADER_ARGS "\"${UPLOAD_URL}\""
# Step 3 — commit; the worker enqueues but the row processor short-circuits.
curl -fsS -X POST "${BASE_URL}/api/v1/monitoring/loan/imports/${IMPORT_ID}/commit" \
-H "Authorization: Bearer ${PAT}"
# Step 4 — poll. Terminal status + dryRun:true means the file is structurally valid.
while :; do
BODY=$(curl -fsS "${BASE_URL}/api/v1/monitoring/loan/imports/${IMPORT_ID}" \
-H "Authorization: Bearer ${PAT}")
STATUS=$(echo "$BODY" | jq -r .status)
echo "dry-run ${IMPORT_ID}: ${STATUS}"
case "${STATUS}" in
succeeded|partial|failed|cancelled) break ;;
esac
sleep 5
done
# At terminal, inspect the row counts. `dryRun: true` should be present.
echo "$BODY" | jq '{status, dryRun, rowCountProcessed, rowCountSucceeded, rowCountFailed}'// TypeScript — dry-run validation
import { readFileSync, statSync } from "node:fs";
const PAT = process.env.WAAFIR_PAT!;
const BASE_URL = process.env.WAAFIR_BASE_URL ?? "https://app.waafir.io";
const NDJSON_FILE = "loans-canary.ndjson";
const auth = { Authorization: `Bearer ${PAT}` };
// Step 1 — start with dryRun=true via the body. (?dryRun=true is also accepted.)
const startRes = await fetch(`${BASE_URL}/api/v1/monitoring/loan/imports`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
kind: "loan",
filename: NDJSON_FILE,
sizeBytes: statSync(NDJSON_FILE).size,
dryRun: true,
}),
});
if (!startRes.ok) throw new Error(`start failed: ${startRes.status} ${await startRes.text()}`);
const { importId, uploadUrl, headers: uploadHeaders } = (await startRes.json()) as {
importId: string;
uploadUrl: string;
headers: Record<string, string>;
};
// Step 2 — upload, applying the signed-in headers.
const putRes = await fetch(uploadUrl, {
method: "PUT",
headers: uploadHeaders,
body: readFileSync(NDJSON_FILE),
});
if (!putRes.ok) throw new Error(`upload failed: ${putRes.status}`);
// Step 3 — commit.
const commitRes = await fetch(`${BASE_URL}/api/v1/monitoring/loan/imports/${importId}/commit`, {
method: "POST",
headers: auth,
});
if (!commitRes.ok) throw new Error(`commit failed: ${commitRes.status} ${await commitRes.text()}`);
// Step 4 — poll until terminal.
const TERMINAL = new Set(["succeeded", "partial", "failed", "cancelled"]);
let final: { status: string; dryRun: boolean; rowCountProcessed: number; rowCountSucceeded: number; rowCountFailed: number };
while (true) {
const statusRes = await fetch(`${BASE_URL}/api/v1/monitoring/loan/imports/${importId}`, { headers: auth });
final = (await statusRes.json()) as typeof final;
console.log(`dry-run ${importId}: ${final.status}`);
if (TERMINAL.has(final.status)) break;
await new Promise((r) => setTimeout(r, 5_000));
}
console.log({
status: final.status,
dryRun: final.dryRun,
rowCountProcessed: final.rowCountProcessed,
rowCountSucceeded: final.rowCountSucceeded,
rowCountFailed: final.rowCountFailed,
});# python — dry-run validation
import os
import time
import requests
from pathlib import Path
PAT = os.environ["WAAFIR_PAT"]
BASE_URL = os.environ.get("WAAFIR_BASE_URL", "https://app.waafir.io")
NDJSON_FILE = Path("loans-canary.ndjson")
auth = {"Authorization": f"Bearer {PAT}"}
# Step 1 — start with dryRun=True. The body field is the structured form;
# `?dryRun=true` on the query string is equivalent for curl ergonomics.
start = requests.post(
f"{BASE_URL}/api/v1/monitoring/loan/imports",
headers={**auth, "Content-Type": "application/json"},
json={
"kind": "loan",
"filename": NDJSON_FILE.name,
"sizeBytes": NDJSON_FILE.stat().st_size,
"dryRun": True,
},
timeout=30,
)
start.raise_for_status()
payload = start.json()
import_id = payload["importId"]
upload_url = payload["uploadUrl"]
upload_headers = payload["headers"]
# Step 2 — upload, applying the signed-in headers.
with NDJSON_FILE.open("rb") as fh:
requests.put(upload_url, data=fh, headers=upload_headers, timeout=300).raise_for_status()
# Step 3 — commit.
requests.post(
f"{BASE_URL}/api/v1/monitoring/loan/imports/{import_id}/commit",
headers=auth,
timeout=30,
).raise_for_status()
# Step 4 — poll until terminal.
terminal = {"succeeded", "partial", "failed", "cancelled"}
while True:
body = requests.get(
f"{BASE_URL}/api/v1/monitoring/loan/imports/{import_id}",
headers=auth,
timeout=30,
).json()
print(f"dry-run {import_id}: {body['status']}")
if body["status"] in terminal:
break
time.sleep(5)
print({
"status": body["status"],
"dryRun": body["dryRun"],
"rowCountProcessed": body["rowCountProcessed"],
"rowCountSucceeded": body["rowCountSucceeded"],
"rowCountFailed": body["rowCountFailed"],
})What to verify
The terminal status object carries dryRun: true so you can confirm the worker ran in dry-run mode. The row counts have a specific meaning under dry-run:
rowCountSucceededis the number of rows that passed validation — exactly the number that would have landed as live silver rows under a real run.rowCountFailedis the number of rows that failed validation. Walk these via the partial-failure inspection recipe — the paged-errors endpoint works identically under dry-run.rowCountProcessedis the sum: every row the worker considered.
If your file has zero failed rows under dry-run, the same file submitted for real (without dryRun: true) will land cleanly, modulo the database-uniqueness asymmetry noted above.
This recipe anchors the friction scenario import-dry-run-no-side-effects — the catalog entry pins the contract that dry-run runs the full validation pipeline but never invokes the row processor.
Bulk import a loan tape via async NDJSON
Walk the two-phase async import flow — start an import, upload an NDJSON file to the returned presigned URL, commit, then poll until the worker finishes processing.
Retry safely with an idempotency key
Send the same import request twice without creating duplicate work — the Idempotency-Key header makes the start-import endpoint replay the first call's result on every retry.