Backtesting
A backtest replays your historical transactions through a policy version and reports what it would have decided. Backtests never read or write live data. Rows are replayed in occurred_at order through an isolated history, using the same rules as live scoring.
The version is pinned at submission, so a backtest always names the exact version it evaluated.
Submit a backtest
Section titled “Submit a backtest”POST /v1/backtest:
{ "policy_version_id": "plv_…", "transactions": [ { "transaction": { "id": "t1", "type": "loan_disbursement", "amount": 5000000, "currency": "NGN", "occurred_at": "2026-08-01T10:00:00Z" }, "customer": { "id_hash": "<hmac>" }, "label": "fraud" } ]}Each item takes the same fields as POST /v1/score, plus an optional label.
Send Content-Type: text/csv with ?policy_version_id= and these columns (the first six are required):
transaction_id,type,amount,currency,occurred_at,channel,customer_id_hash,device_id_hash,device_platform,beneficiary_account_hash,beneficiary_bank_code,country,region,labelIdentifier columns must already be hashed. In the dashboard, hashing happens in your browser before the file is uploaded, using the identifier key you enter.
Rules for the data
Section titled “Rules for the data”- Target:
policy_version_id, orpolicy_id(withversion, default latest), or nothing for the active policy forproduct. - Limits: 50,000 rows and 25 MB. Larger files get
413 backtest_too_large, so split them by date range. Files are not split for you, because every row’s features depend on the rows before it. - Validation: rows are checked like
/v1/score, including the currency, which must match the market (NGN). If more than 1% of rows are invalid the batch is rejected (422 backtest_invalid_rows, with the first 100 row errors indetails). Otherwise invalid rows are skipped and reported. - Labels (optional):
fraud,legitimateorsuspected. They enable precision and recall. - Idempotency: send an
Idempotency-Keyto make retries safe. A repeat returns the original batch with200andIdempotent-Replayed: true.
POST /v1/backtest returns 202 with the batch.
Read the results
Section titled “Read the results”GET /v1/backtest/{id}:
{ "backtest_id": "bt_…", "status": "completed", "dataset": { "format": "csv", "row_count": 5000, "invalid_row_count": 3 }, "summary": { "scored": 4997, "by_risk_level": {}, "by_action": {}, "mean_score": 0, "rule_hits": {}, "labels": { "thresholds": [{ "level": "high", "min_score": 60, "flagged": 0, "precision": 0, "recall": 0 }] } }}GET /v1/backtest/{id}/rows?after=&limit=&errors_only=true pages through each row’s input and result. GET /v1/backtest lists recent batches.
Get notified
Section titled “Get notified”Large backtests take a while. Instead of polling:
- Email: pass
notify_emails(up to 10 members of your organisation). The email carries the status, a one-line summary and a dashboard link, and never transaction data. Dashboard submissions notify the person who submitted by default; pass an empty list to opt out. - Webhook: subscribe to
backtest.completedandbacktest.failed. See Webhooks.
With the SDK
Section titled “With the SDK”const backtest = await asqary.backtests.create({ policyVersionId: policy.versions![0]!.versionId, transactions: history.map((t) => ({ transactionId: t.id, type: "loan_disbursement", amount: t.amountKobo, currency: "NGN", occurredAt: t.createdAt, customerId: t.customerId, // hashed locally label: t.wasFraud ? "fraud" : "legitimate", })),});
const result = await asqary.backtests.waitForCompletion(backtest.backtestId);
for await (const row of asqary.backtests.rows(backtest.backtestId)) { // row.score, row.riskLevel, row.reasons, row.error}To upload a CSV with already-hashed columns: asqary.backtests.createFromCsv(csv, { policyVersionId }).