Screen every transaction before it settles.
Vantrace evaluates a transaction against 20+ AML rules — structuring, velocity, sanctions, PEP exposure, mule networks — and returns a decision your core banking system acts on synchronously, before funds move.
Environments
Staging runs the exact same rule set as production against synthetic but structurally realistic data. Nothing is relaxed or mocked — if a rule fires on staging, it fires the same way in production. Going live only changes your base URL and credentials; no code changes required.
| Environment | Base URL | Data |
|---|---|---|
| Staging | {{apiurl}} | Synthetic customers, accounts and transaction history, structurally identical to production |
| Production | {{apiurl}} | Live customer and transaction data |
{{apiurl}} is a placeholder throughout this reference — your onboarding contact provides the actual staging and production values, which differ from each other.
Authentication
Three layers. Layers 1 and 3 are required in both staging and production. Layer 2 is not currently enforced server-side, but you should still send it — here's why.
1 · API key required
Send your key in the X-API-Key header on every request. Vantrace stores only a SHA-256 hash of the key — the raw value is never persisted server-side.
X-API-Key: <your_api_key>
2 · Timestamp send it anyway
Send a UTC ISO 8601 timestamp in X-Timestamp.
X-Timestamp: 2026-07-18T11:00:00Z
3 · HMAC-SHA256 body signature required
Sign the raw request body with your HMAC secret and send the hex digest in X-Signature. Vantrace compares it using constant-time comparison — a mismatch returns 401. This is enforced on staging and production alike — there is no unsigned path in either environment.
import hmac, hashlib, json body = json.dumps(payload, separators=(',', ':')).encode('utf-8') signature = hmac.new(secret.encode('utf-8'), body, hashlib.sha256).hexdigest()
const crypto = require('crypto'); const body = JSON.stringify(payload); const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] raw = mac.doFinal(body.getBytes(StandardCharsets.UTF_8)); String signature = HexFormat.of().formatHex(raw);
h := hmac.New(sha256.New, []byte(secret)) h.Write(body) signature := hex.EncodeToString(h.Sum(nil))
The endpoint
There is one integration point. Every transaction — transfer, deposit, or withdrawal, across every channel — is submitted here before it settles.
{{apiurl}}/v1/engine/evaluate
Content-Type: application/json
X-API-Key: <your_api_key>
X-Timestamp: <iso8601>
X-Signature: <hmac_sha256_hex>
Request schema
Three objects: what happened (transaction), who did it (customer), and what else Vantrace should know (context). The first two are required; context is optional but unlocks the rules that depend on real-time signals your system already has.
transaction
| Field | Type | Req. | Notes |
|---|---|---|---|
| amount | float | ✓ | Transaction amount. Keep units consistent across every call (e.g. always NGN major units). |
| currency | string | ✓ | ISO 4217, 3-letter. Defaults to NGN. |
| trx_type | string | ✓ | transfer · deposit · withdrawal |
| channel | string | ✓ | mobile_app · USSD · POS · ATM · teller · online |
| txn_direction | string | ✓ | inbound or outbound |
| is_cash | bool | ✓ | Only true for physical cash. Never for NIP/RTGS/card rails — the cash-transaction-report rule keys off this being accurate. |
| is_cross_border | bool | — | Counterparty is outside Nigeria. Default false. |
| source_account_id | int | — | Your internal ID for the sending account. |
| beneficiary_account_id | int | — | Your internal ID for the receiving account. |
| counterparty_name | string | — | Counterparty bank or individual name. |
| counterparty_bank_code | string | — | CBN sort code of the counterparty institution. |
| narration | string | — | Free-text narration / payment reference. |
| status | string | — | completed · pending · failed. Default completed. |
customer
| Field | Type | Req. | Notes |
|---|---|---|---|
| customer_id | int | ✓ | Your internal customer ID — used to link every alert and case back to this person. |
| entity_type | string | ✓ | individual or corporate |
| kyc_tier | int | ✓ | CBN tier 1–3. Drives the daily transaction limit rule (E3). |
| is_pep | bool | — | Politically exposed person. Default false. |
| aml_risk_rating | string | — | low · medium · high · critical. Default low. |
| sanctions_flag | bool | — | Customer appears on a sanctions watchlist. Default false. |
| adverse_media_flag | bool | — | Adverse media hits on file. Default false. |
| risk_flags | string[] | — | Free-form labels from your own risk system, e.g. ["dormant_reactivation"]. |
context
Every field defaults to a safe empty value. A rule that depends on a missing field simply doesn't fire — roll these out incrementally, starting with whichever unlocks the rule that matters most to you.
| Field | Type | Unlocks | Description |
|---|---|---|---|
| transactions_24hr | object[] | B1 B5 | This customer's transactions in the last 24h. |
| beneficiary_transactions_72hr | object[] | B2 | Inbound transactions this beneficiary received in the last 72h. |
| customer_avg_txn_count_24hr | float | B5 | Historical average 24h transaction count. |
| customer_avg_txn_amount | float | B6 | Historical average single-transaction amount. |
| daily_spend_so_far | float | E3 | Total outbound amount already sent today. |
| recent_large_inbound | float | B7 | Largest inbound credit in the last 30 minutes. |
| round_trip_accounts | string[] | B3 | Account IDs forming a potential A→B→C→A chain. |
| device_accounts | object[] | B4 | Other accounts sharing this device fingerprint. |
| sanctions_matches | object[] | C1 | Sanctions list hits for the counterparty. |
| is_pep_transaction | bool | C3 | Transaction involves a PEP counterparty. |
| adverse_media_hits | string[] | C4 | Adverse media source identifiers. |
| cpf_risk_indicators | string[] | C5 | Proliferation-financing risk indicator codes. |
| open_cases_count | int | E1 | Number of open AML cases already on this customer. |
| is_high_risk_jurisdiction | bool | E2 | Counterparty sits in a FATF high-risk jurisdiction. |
| is_shell_company | bool | E2 | Counterparty is a known shell company. |
| last_review_days_ago | int | E4 | Days since this customer's last AML review. |
| has_complete_cdd | bool | E5 | Customer due diligence file is complete. Default true. |
Response schema
Every response carries a decision plus the reasoning behind it. should_block is the single field your settlement logic needs to branch on — everything else is context for compliance.
| Field | Type | Description |
|---|---|---|
| trx_id | int | Vantrace's internal transaction ID. Store it against your own record for reconciliation. |
| should_block | bool | The decision gate. true → decline before funds move. false → allow. |
| final_action | string | none → flag → alert → block, escalating. Only block implies should_block: true. |
| highest_severity | string | low · medium · high · critical — the ceiling across every rule that fired. |
| triggered_count | int | How many rules fired. 0 means clean. |
| triggered_rules | object[] | One entry per fired rule — see below. |
| alert_ids | int[] | IDs of the alerts Vantrace created. Reference these in any conversation with compliance. |
| mule_risk_summary | object | Mule network exposure for this transaction — see Mule network risk. |
| behavioural_data_status | string | ok or insufficient_history — whether behavioural rules (B5/B6) had enough baseline data to score with full confidence. |
triggered_rules[] shape
| Field | Type | Description |
|---|---|---|
| rule_id | string | Catalog code — see Rule catalog. |
| rule_name | string | Human-readable rule name. |
| severity | string | low · medium · high · critical |
| action | string | flag · alert · block |
| report_type | string · null | STR, CTR, or null if this rule doesn't feed a regulatory report. |
| details | object | Rule-specific evidence — thresholds, multipliers, the exact numbers that tripped it. Shape varies per rule; always render it in your compliance UI even if you don't parse it. |
data_status: "insufficient_history" inside details, with a plain-language data_caveat. Treat these as lower-confidence signals — worth a look, not worth an automatic block on their own.
Mule network risk
Mule detection doesn't live at a separate endpoint — it rides inside the same /v1/engine/evaluate response, in mule_risk_summary. It graphs this transaction against the wider Vantrace network — shared devices, fan-out/fan-in patterns, known mule clusters — to catch coordinated laundering that no single transaction would flag on its own.
| Field | Type | Description |
|---|---|---|
| enabled | bool | Whether mule network scoring is switched on for your tenant. |
| status | string | disabled · scored · partial |
| risk_score | int · null | 0–100 network risk score. null when disabled or unscored. |
| risk_level | string · null | low · medium · high · critical |
| triggered_rule_count | int | How many mule-network rules fired for this transaction. |
| evaluation_id | int · null | Reference ID for this network evaluation, for cross-referencing with compliance. |
| reason | string | Plain-language note — e.g. why scoring was skipped. |
Real example — captured live, tenant with network scoring off
{ "enabled": false, "status": "disabled", "risk_score": null, "risk_level": null, "enabled_rule_count": 0, "evaluated_rule_count": 0, "triggered_rule_count": 0, "evaluation_id": null, "reason": "No mule rules are enabled for this tenant." }
Ask your Vantrace onboarding contact to enable mule network scoring for your tenant — once on, risk_score and risk_level populate on every call and feed directly into the escalation guidance below.
Rule catalog
Every rule belongs to one of four families. The letter tells you the category before you've even read the name.
| Rule | Name | Family | Default action |
|---|---|---|---|
| A1 | Cash transaction report threshold | Currency reporting | alert · CTR |
| B1 | Structuring / smurfing detection | Behavioural | alert · STR |
| B2 | Inbound smurfing (beneficiary-side) | Behavioural | alert |
| B3 | Round-trip layering | Behavioural | alert |
| B4 | Mule network device linkage | Behavioural | alert |
| B5 | Velocity spike | Behavioural | alert · STR |
| B6 | Amount anomaly | Behavioural | alert |
| B7 | Rapid outbound after large inbound | Behavioural | alert |
| C1 | Sanctions list match | Customer risk | block · STR |
| C3 | PEP transaction monitoring | Customer risk | alert |
| C4 | Adverse media hit | Customer risk | alert |
| C5 | Proliferation financing (CPF) | Customer risk | alert |
| E1 | Dynamic risk scoring | Regulatory / KYC | flag |
| E2 | Auto high-risk jurisdiction | Regulatory / KYC | alert |
| E3 | KYC tier daily limit enforcement | Regulatory / KYC | block |
| E4 | Periodic review overdue | Regulatory / KYC | flag |
| E5 | Incomplete CDD, occasional threshold | Regulatory / KYC | flag |
"Default action" is what fires under typical conditions — highest_severity in the response can still shift per-call based on amount, customer risk rating, and how many rules co-triggered.
Recommended actions
The response tells you what fired; it doesn't tell you what to do about it. Here's how compliance teams typically route each outcome — treat this as a starting policy, not a fixed rulebook, and tune it against your own risk appetite.
alert_ids. No account action needed for a single occurrence.customer_id accumulates two or more high/critical alerts in a short window — check open_cases_count on your next call — that's a strong signal to move to an account hold rather than wait for a single call to return should_block: true. Structuring rules exist precisely because bad actors stay just under a per-transaction block threshold.Account holds & PND
should_block stops one transaction. Sometimes the right response is broader — freezing the account itself so nothing moves until compliance has looked at it. Vantrace exposes this as a separate action from transaction screening, distinct from declining a single call.
should_block: true) stops the transaction in front of you. Post-No-Debit / account hold stops every future debit on the account until it's lifted. They're independent — a transaction can be allowed through while its pattern still justifies freezing the account for everything that comes after.
When to reach for an account hold
- Confirmed sanctions match (
C1) — pair the transaction block with a hold while compliance verifies identity and files any required report. - Repeated high/critical alerts on one
customer_idwithin a short window — structuring (B1), velocity (B5), or rapid-outbound (B7) patterns that individually stayed under the block threshold but read as coordinated when stacked. - Elevated mule network risk —
mule_risk_summary.risk_levelathighorcriticalis a network-level signal, not a single-transaction one; it usually means the account itself is compromised or complicit, not just this transfer. - Open case count climbing —
context.open_cases_countrising across calls on the same customer, even if each individual transaction cleared.
Errors & reliability
| Status | Cause | What to do |
|---|---|---|
| 200 | Success | Read should_block and act. |
| 400 | Malformed body or an invalid field value (e.g. kyc_tier outside 1–3) | Fix the request — don't retry as-is. |
| 401 | Missing/invalid API key, expired timestamp, or HMAC mismatch | Check credentials; rotate the key if you suspect it leaked. |
| 422 | Body fails schema validation — missing required field or wrong type | Fix the payload — see the real shape below. |
| 429 | Rate limit exceeded | Back off exponentially. Contact Vantrace if this is sustained, not a spike. |
| 500 | Internal engine error | Fail open — allow the transaction, flag internally, alert Vantrace. |
| 503 | Engine temporarily unavailable | Fail open; retry with exponential backoff. |
422 — real shape, captured live
{ "detail": [ { "type": "missing", "loc": ["body", "transaction", "amount"], "msg": "Field required" } ] }
What we observed on 5xx, and what we think should change
Right now a 5xx can come back as a bare text body — literally Internal Server Error, no JSON envelope at all. That's fine for your fail-open handling (status code alone is enough to trigger it) but it's a dead end for debugging: no error code, no correlation ID, nothing to hand to Vantrace support beyond "it failed around 2:14pm." We think this is worth standardizing — every error, 4xx or 5xx, should return the same structured envelope:
{ "error": { "code": "ENGINE_UNAVAILABLE", "message": "The evaluation engine could not process this request.", "request_id": "req_8f3a1c2e9b4d", "retryable": true } }
A stable error.code lets you branch programmatically instead of string-matching. A request_id on every response — success or failure — turns "it was slow around 2pm" into a ten-second lookup for Vantrace support. This isn't live yet; treat the table above as current behavior and this envelope as the direction we'd recommend standardizing toward.
Worked examples
Two versions of the same call. Most integrations don't start with rich context — you build up transaction history, behavioural baselines, and case counts over time. Start with the minimum below, ship it, then layer in context fields as your own system accumulates the data to populate them.
Day 1 — minimum viable request
context is optional — omit it entirely until you have something real to put in it. Rules that depend on a missing field simply don't evaluate; nothing errors, nothing degrades.
{ "transaction": { "amount": 25000.00, "currency": "NGN", "trx_type": "transfer", "channel": "mobile_app", "txn_direction": "outbound", "is_cash": false }, "customer": { "customer_id": 5001, "entity_type": "individual", "kyc_tier": 2 } }
{ "trx_id": 12301, "should_block": false, "final_action": "none", "highest_severity": "low", "triggered_count": 0, "triggered_rules": [], "alert_ids": [] }
Action: allow. Rules that need context — B1, B5, B6, E3 and others — simply sat out this evaluation rather than firing false positives on missing data.
Once context is flowing
₦50,000 online transfer from a sanctioned, tier-1 individual, carrying a 30× velocity spike against a thin baseline. Captured live during integration testing — every top-level field populated, including mule network and behavioural sufficiency.
{ "transaction": { "amount": 50000.00, "currency": "NGN", "trx_type": "transfer", "channel": "online", "txn_direction": "outbound", "is_cash": false, "is_cross_border": false, "source_account_id": 7323712900, "beneficiary_account_id": null, "counterparty_name": null, "counterparty_bank_code": null, "narration": "Business payment", "status": "completed" }, "customer": { "customer_id": 6, "entity_type": "individual", "kyc_tier": 1, "is_pep": false, "aml_risk_rating": "critical", "sanctions_flag": true, "adverse_media_flag": false, "risk_flags": [] }, "context": { "transactions_24hr": [ { "amount": "50000.00", "account_id": 5, "counterparty_id": null } ], "beneficiary_transactions_72hr": [], "daily_spend_so_far": 0, "customer_avg_txn_amount": 1666.67, "customer_avg_txn_count_24hr": 0.0333, "recent_large_inbound": 0 } }
{ "trx_id": 29, "should_block": false, "final_action": "none", "highest_severity": "low", "triggered_count": 0, "triggered_rules": [], "alert_ids": [], "mule_risk_summary": { "enabled": false, "status": "disabled", "risk_score": null, "risk_level": null, "enabled_rule_count": 0, "evaluated_rule_count": 0, "triggered_rule_count": 0, "not_triggered_rule_count": 0, "not_evaluated_rule_count": 0, "evaluation_id": null, "reason": "No mule rules are enabled for this tenant.", "persistence_status": null }, "mule_risk": null, "behavioural_data_status": "insufficient_history", "behavioural_sufficiency": { "status": "insufficient_history", "reasons": ["No velocity baseline exists for this customer (new customer or velocity aggregates not yet computed)."], "has_velocity_row": false, "observations_30d": 0, "min_observations_30d": 10, "lifecycle_status": "new", "baseline_scope": "account_and_customer" } }
Action: allow. triggered_count: 0 — clean on this call, though behavioural_data_status flags that B5/B6 are running on a thin baseline for this customer, so confidence rises as more history accumulates.
should_block: true, final_action: "block", one entry in triggered_rules, a populated alert_ids. Stacked critical block (C1 sanctions + B5 velocity together) returns the same shape with two entries in triggered_rules and highest_severity: "critical" — see the rule catalog for what each code means on its own.
Integration checklist
Context fields can land incrementally after go-live — ship the core loop first.
- Call
POST /v1/engine/evaluatebefore every transaction settles - Block on the response — synchronous, not fire-and-forget
- Branch on
should_block; decline before funds move whentrue - Store
trx_idagainst your own transaction record - Store
alert_idsfor compliance reference - Sign every request — HMAC is required in staging and production alike
- Send
X-Timestampon every call, even though it isn't enforced yet - Implement fail-open handling for every 5xx and timeout
- Set
is_cashaccurately — onlytruefor physical cash channels - Populate
context.transactions_24hr— unlocks B1 and B5 - Populate
context.daily_spend_so_far— unlocks E3 - Populate
context.customer_avg_txn_amount— unlocks B6 - Wire up account-hold escalation for stacked high/critical alerts