Waafir
API Reference

Async imports

The async-imports surface is the path for historical loads and any batch above the bulk-NDJSON ten-thousand-row or ten-megabyte cap. The flow is two-phase: you describe what you intend to upload, receive a presigned S3 URL, PUT the file directly to S3, then ask the platform to commit it. From there a worker streams the file row-by-row, capturing per-row errors and surfacing progress through a status endpoint.

The implementation is shared with future non-loan domains — inventory, cashflows — so the routes you see here describe loan-domain entry points into a domain-agnostic framework.

Lifecycle

The lifecycle has seven states. Most production loads pass through pending_upload → queued → processing → succeeded; the others surface failure modes.

StateMeaning
pending_uploadImport row exists; presigned PUT URL is live; the platform has not yet seen the file.
queuedCommit succeeded; the worker has not yet picked up the file.
processingThe worker is streaming the file row-by-row.
succeededEvery row landed in silver.
partialThe file was fully processed but at least one row failed.
failedA fatal error halted processing before completion (e.g. malformed gzip, missing S3 object, internal worker error).
cancelledThe caller cancelled the import; the worker stopped at the next chunk boundary.

A row that never moves out of pending_upload represents a presigned URL that was issued but never committed; these are routinely garbage-collected after the URL's TTL expires.

Routes at a glance

MethodPathPurpose
POST/api/v1/monitoring/loan/importsStart a new import; receive a presigned PUT URL.
POST/api/v1/monitoring/loan/imports/{id}/commitFinalise upload, enqueue worker.
GET/api/v1/monitoring/loan/imports/{id}Read status, counts, and the error summary.
GET/api/v1/monitoring/loan/imports/{id}?errors=truePage through per-row errors.
GET/api/v1/monitoring/loan/importsList the caller's loan imports.
DELETE/api/v1/monitoring/loan/imports/{id}Cancel an in-flight import.

POST /api/v1/monitoring/loan/imports

Start a new import. Returns the import id, a presigned PUT URL, and the upload headers your client must echo when it PUTs the file to S3.

Request body

FieldTypeRequiredConstraint
kindenumyesOne of the six kinds.
filenamestringyes1-255 chars; for display only.
sizeBytesintegeryesPositive integer; matches the file size you intend to upload.
portfolioIdUUID string | nullnoOptional override; rows in the body still carry their own.
idempotencyKeystring | nullnoHeader is preferred; body field is a fallback.
dryRunbooleannotrue runs validation without enqueueing.
metadataobject | nullnoVerbatim pass-through.
rowCountDeclaredinteger | nullnoAdvisory — improves progress reporting.

The Idempotency-Key HTTP header takes precedence over the body field when both are supplied.

Response — 202 Accepted

{
  "importId": "1234abcd-5678-90ef-1234-567890abcdef",
  "uploadUrl": "https://s3.amazonaws.com/waafir-bronze-prod/...?X-Amz-Signature=...",
  "headers": { "x-amz-server-side-encryption": "aws:kms" },
  "s3Key": "imports/abc.../...",
  "reused": false,
  "statusUrl": "/api/v1/monitoring/loan/imports/1234abcd-5678-90ef-1234-567890abcdef",
  "commitUrl": "/api/v1/monitoring/loan/imports/1234abcd-5678-90ef-1234-567890abcdef/commit"
}
FieldNotes
importIdThe platform identifier you reference in every subsequent call.
uploadUrlTime-limited presigned URL for PUT to S3.
headersA map of HTTP headers your PUT must include.
s3KeyThe S3 object key the file will live under.
reusedtrue if the response is the dedup result of a prior Idempotency-Key.
statusUrl / commitUrlConvenience URLs for the lifecycle that follows.

Status codes

StatusEnvelopeTrigger
202 Acceptedas aboveImport row created.
400 Bad Requesterrors[] (path/message)Body validation failure.
400 Bad Request{ error, code: "UNKNOWN_KIND", allowed: [...] }kind is not one of the six loan kinds.

Uploading the file to S3

Once you hold a uploadUrl, PUT the file directly to S3. Echo every header from the response's headers map; the signature on the URL covers them and S3 rejects the upload otherwise.

curl -X PUT "$UPLOAD_URL" \
  -H "x-amz-server-side-encryption: aws:kms" \
  --data-binary @your-file.ndjson

The upload is direct S3 traffic — it does not pass through Waafir. The file format is the same NDJSON wire format documented on the bulk-NDJSON page, with no row cap.

POST /api/v1/monitoring/loan/imports/{id}/commit

After PUT-ing the file to S3, call commit to tell the platform the upload is complete. The platform HEADs the S3 object to confirm it exists, flips the import row from pending_upload to queued, and enqueues the worker.

Request

No body. Idempotency-Key is implicit — a second commit on a non-pending_upload row is a no-op that returns the current state.

Responses

200 OK:

{ "importId": "1234abcd-...", "status": "queued" }
StatusEnvelopeTrigger
200 OK{ importId, status }Commit succeeded, or the row was already past pending_upload (idempotent).
404 Not FoundstandardImport does not exist or belongs to another organisation.
409 Conflict{ error, code: "COMMIT_FAILED" }The S3 HEAD failed — typically the upload never happened or completed too recently for S3 consistency. Retry the upload, then commit again.

GET /api/v1/monitoring/loan/imports/{id}

Read the current status, counts, and error summary for an import. This is the polling endpoint your client calls to monitor progress.

Request

ParameterPositionRequiredNotes
idpathyesThe import's platform UUID.
errorsquerynotrue switches to paged-errors mode (see below).

Response — default mode

{
  "importId": "1234abcd-...",
  "kind": "actual_payment",
  "domain": "loan",
  "status": "succeeded",
  "filename": "payments-2025-full-year.ndjson",
  "sizeBytes": 524288000,
  "portfolioId": "ddf63d12-...",
  "rowCountDeclared": 1500000,
  "rowCountProcessed": 1500000,
  "rowCountSucceeded": 1499987,
  "rowCountFailed": 13,
  "createdAt": "2026-05-20T10:00:00Z",
  "startedAt": "2026-05-20T10:00:15Z",
  "completedAt": "2026-05-20T10:18:42Z",
  "dryRun": false,
  "errorSummary": {
    "totals": { "validation_error": 9, "fk_violation": 4 },
    "samples": [
      { "rowNumber": 12, "errorCode": "validation_error", "errorMessage": "..." },
      { "rowNumber": 1041, "errorCode": "fk_violation", "errorMessage": "..." }
    ]
  }
}

errorSummary is a small embedded summary — totals by error code and up to ten sample rows. For the full per-row list, switch to paged-errors mode.

Response — ?errors=true&limit=&offset=

{
  "items": [
    {
      "rowNumber": 12,
      "errorCode": "validation_error",
      "errorMessage": "interestRateApr must be >= 0",
      "rawRow": "{\"id\":\"...\",\"interestRateApr\":\"-0.05\",...}",
      "createdAt": "2026-05-20T10:01:14Z"
    }
  ],
  "limit": 20,
  "offset": 0
}
FieldNotes
rowNumber1-based line index in the uploaded NDJSON.
errorCodeCommon values include parse_error (line was not valid JSON), validation_error (schema rejected the row), portfolio_mismatch (the row's portfolioId does not match the import's anchor), and processor_error (an unexpected handler failure). See errors and status codes for the consolidated list.
errorMessageHuman-readable failure summary.
rawRowThe original NDJSON line, preserved verbatim for debugging.

limit defaults to 20, max 100. offset defaults to 0. Pagination iterates the persisted error list in rowNumber order, so a stable cursor is offset += limit.

Status codes

StatusTrigger
200 OKRow exists in your organisation.
400 Bad Requestlimit or offset malformed when ?errors=true.
404 Not FoundRow not found or cross-organisation.

GET /api/v1/monitoring/loan/imports

List the caller's loan imports, most recent first.

Request

ParameterPositionRequiredConstraint
statusquerynoOne of the seven lifecycle states.
kindquerynoOne of the six loan kinds.
limitqueryno1-100; default 20.
offsetquerynoNon-negative; default 0.

Response

{
  "items": [
    {
      "importId": "...",
      "kind": "actual_payment",
      "status": "succeeded",
      "filename": "...",
      "createdAt": "...",
      "completedAt": "...",
      "rowCountSucceeded": 1499987,
      "rowCountFailed": 13
    }
  ],
  "limit": 20,
  "offset": 0
}

DELETE /api/v1/monitoring/loan/imports/{id}

Cancel an in-flight import. The worker stops at the next chunk boundary; rows already inserted remain in silver. Cancelling an already-terminal row (succeeded, partial, failed, or cancelled) is a no-op that returns the existing state.

Responses

200 OK:

{ "importId": "1234abcd-...", "status": "cancelled" }
StatusTrigger
200 OKCancel applied or row was already terminal.
404 Not FoundRow not found or cross-organisation.

Kinds

The kind field on the start request determines the schema each row is validated against and which silver table receives the inserts. The six kinds map one-to-one to the silver entities:

KindSilver entitySchema
borrowerborrowersSame as POST /borrowers.
loanloansSame as POST /loans.
scheduled_repaymentscheduled_repaymentsScheduled repayment schema.
actual_paymentactual_paymentsSame as POST /payments.
collateralcollateralCollateral schema.
collateral_valuationcollateral_valuationsCollateral valuation schema.

A kind that is not in this list returns 400 UNKNOWN_KIND with the allowed values listed in the response, so a typo is recoverable without trial and error.

When to use this surface

Use async imports when:

  • You are loading a historical archive — typical historical loads are millions of rows.
  • Your batch exceeds either bulk-NDJSON cap (10 000 rows or 10 MB).
  • You want the option to cancel mid-load — bulk NDJSON has no cancel surface because the request itself is the load.
  • Your client tolerates polling — there is no push notification when an import completes.

For small operational batches that fit in a single synchronous request, bulk NDJSON is the simpler path.