Waafir
Cookbook

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:

  • rowCountSucceeded is the number of rows that passed validation — exactly the number that would have landed as live silver rows under a real run.
  • rowCountFailed is the number of rows that failed validation. Walk these via the partial-failure inspection recipe — the paged-errors endpoint works identically under dry-run.
  • rowCountProcessed is 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.