What this API decides
The Detection API scores a cash withdrawal against the terminal that produced it, not only against the card. One POST inside the authorization path returns a decision, the score behind it, the reason codes that fired, and the current risk of the machine. The same surface carries the case and the evidence pack when a block turns into an investigation.
Under 40 ms at P50
Scoring is synchronous and in-region. Your timeout budget can stay at 150 ms.
Terminal-level signals
Device fingerprints, door events, read patterns, dispense behaviour, and fleet neighbours.
Evidence, not just a verdict
Every block carries the reason codes that fired and a request_id you can defend to the network.
Bearer keys, sandbox and live
Send a secret key as a Bearer token: sk_test_ for sandbox, sk_live_ for production. A key carries a market list, an entitlement set, and a processing region, so it cannot read a terminal outside its markets. Rotate by issuing a second key, moving traffic, then revoking the first.
Authorization: Bearer sk_live_••••••••
Accept: application/json
X-Request-Id: 7f3c9e2a-4b81-4d0f-9c37-2a5e6f9b1c04Keep live keys on your servers. Tokenise the card before you call scoring: the API takes a vault reference in card.token and rejects anything that looks like a PAN. X-Request-Id is optional; if you send it, we echo it as request_id.
Every request, every response
- Base URL
- https://api.atmlist.com/v1
- Auth
- Authorization: Bearer sk_live_… · sk_test_…
- Fields
- snake_case JSON, UTF-8
- Timestamps
- ISO-8601 UTC, second precision
- Idempotency
- Idempotency-Key on POST, replayed for 24 h
- Pagination
- Opaque cursor, limit 50 default, 200 max
- Versioning
- Breaking changes ship as /v2; additive fields do not bump the version
- Correlation
- request_id on every response; X-Request-Id accepted on requests
From key to first decision
- 01
Get a key
Email sales@atmlist.com for a sandbox key (sk_test_). Sandbox keys run the same scoring path on a fixed terminal sample, so decisions and reason codes match production semantics.
- 02
Score one withdrawal
POST /v1/score/withdrawal with a terminal_id, an amount, a card token, and the entry mode. Set your HTTP timeout to 150 ms.
- 03
Read the decision
allow passes, step_up asks for a second factor, block declines. Map block to your decline reason code and keep reason_codes with the authorization record.
- 04
Follow the terminal
GET /v1/terminals lists fleet risk with market, risk_min, and compromised filters. Use it to decide which terminals need a sweep this week.
- 05
Subscribe and pull cases
POST /v1/subscriptions registers the four events. When case.opened fires, GET /v1/cases/{case_id}/evidence returns the artifacts, the timeline, and the neighbouring terminals.
/v1/score/withdrawalScore a withdrawal
Score one cash withdrawal against the terminal in the authorization path.
Send this before you authorise. The decision comes back synchronously, in-region, and takes no external calls: the terminal's device state, its read patterns and its fleet neighbourhood are already scored and cached. Retries with the same Idempotency-Key return the original response for 24 hours, so a network timeout never produces a second decision.
- Returns
- decision, score, reason_codes[], terminal_risk, model, latency_ms, request_id
- Latency
- P50 38 ms · P99 90 ms
- Needs
- Bearer keyIdempotency-KeyJSON body
Request body
| Field | Type | Notes |
|---|---|---|
| terminal_idrequired | string | Terminal identifier from /v1/terminals, e.g. ATM-SG-0182. |
| amountrequired | object | { value, currency }. value in the currency's major unit, currency as ISO-4217. |
| cardrequired | object | { token, scheme }. token is your vault reference. A PAN is rejected. |
| entry_mode | string | chip, magstripe, or contactless. A magstripe read raises the weight on READ_ANOMALY. |
| request_id | string | Your correlation id. Returned unchanged so an authorization log and a decision line up. |
Response fields
| Field | Type | Notes |
|---|---|---|
| decision | string | allow, step_up, or block. block at or above 0.80, step_up from 0.55. |
| score | number | Decision score, 0–1, rounded to two decimals. |
| reason_codes | string[] | Reason codes that fired, highest weight first. Empty on an allow. |
| terminal_risk | TerminalRisk | Terminal risk at decision time: risk, band, compromised, last_sweep. |
| model | string | Model that scored the request, e.g. detect-2. Log it with the decision. |
| latency_ms | integer | Server-side scoring time, excluding your network hop. |
| request_id | string | Echo of your id, or a generated one. Quote it in disputes. |
curl -s "https://api.atmlist.com/v1/score/withdrawal" \
-H "Authorization: Bearer sk_live_••••••••" \
-H "Idempotency-Key: 6f1a4c2e-9b7d-4f31-8a02-1c9e5d7b3a44" \
-H "Content-Type: application/json" \
-d '{
"terminal_id": "ATM-SG-0182",
"amount": { "value": 400, "currency": "SGD" },
"card": { "token": "tok_9f21c8", "scheme": "visa" },
"entry_mode": "magstripe",
"request_id": "req_8f2c91a4"
}'{
"request_id": "req_8f2c91a4",
"decision": "block",
"score": 0.94,
"reason_codes": [
"KIT_SIGNATURE",
"DOOR_OUT_OF_WINDOW",
"READ_ANOMALY"
],
"terminal_risk": {
"terminal_id": "ATM-SG-0182",
"market": "SG",
"operator": "hsbc",
"risk": 0.94,
"band": "high",
"compromised": true,
"last_sweep": "2026-06-09T03:15:09Z"
},
"model": "detect-2",
"latency_ms": 38
}import { randomUUID } from "node:crypto";
const res = await fetch("https://api.atmlist.com/v1/score/withdrawal", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ATMLIST_API_KEY}`,
"Idempotency-Key": randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
terminal_id: "ATM-SG-0182",
amount: { value: 400, currency: "SGD" },
card: { token: "tok_9f21c8", scheme: "visa" },
entry_mode: "magstripe",
}),
signal: AbortSignal.timeout(150),
});
if (!res.ok) throw new Error(`score failed: ${res.status}`);
const decision = await res.json();
if (decision.decision !== "allow") {
console.log(decision.decision, decision.reason_codes, decision.request_id);
}import os
import uuid
import requests
response = requests.post(
"https://api.atmlist.com/v1/score/withdrawal",
headers={
"Authorization": f"Bearer {os.environ['ATMLIST_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"terminal_id": "ATM-SG-0182",
"amount": {"value": 400, "currency": "SGD"},
"card": {"token": "tok_9f21c8", "scheme": "visa"},
"entry_mode": "magstripe",
"request_id": "req_8f2c91a4",
},
timeout=0.15,
)
response.raise_for_status()
decision = response.json()
if decision["decision"] != "allow":
decline(decision["reason_codes"], decision["request_id"])What fired, in order
Weights decide the score: block at or above 0.8, step_up from 0.55, allow below. Reason codes come back highest weight first, and an allow returns an empty list.
- READ_ANOMALYMagnetic-stripe read failures or read latency above the terminal's 30-day baseline.
- KIT_SIGNATUREDevice fingerprint matches a kit family in the signature library.
- DOOR_OUT_OF_WINDOWService door or fascia opened outside a scheduled work order.
- CASH_TRAP_PATTERNNote jam, dispense retraction, or cassette timing consistent with a cash trap.
- GEO_VELOCITYTerminal market and the card's activity history are inconsistent with travel time.
- DISPENSE_RETRACTCash retracted after presentation at the dispenser.
- SHARED_DEVICEThe same device fingerprint has been observed at another terminal.
- FLEET_NEIGHBOUR_COMPROMISEDA terminal within 500 m of this one is compromised.
Reason codes are stable strings. New codes ship without a version bump, so treat an unknown code as a fired signal rather than an error.
/v1/terminalsList fleet risk
List terminals with current risk, sorted by risk descending.
The fleet view your operations team works from. Filter to a market, set a risk floor, or ask only for terminals the model currently treats as compromised. List rows omit the contributing signals — read the risk endpoint for a single terminal when you need the reasons.
- Returns
- data[], next_cursor, has_more
- Latency
- P50 90 ms · P99 220 ms
- Needs
- Bearer keyCursor pagination
Query parameters
| Field | Type | Notes |
|---|---|---|
| market | string | ISO 3166-1 alpha-2 market, e.g. SG, MY, VN. |
| risk_min | number | Risk floor, 0–1. 0.80 returns the block threshold band and above. |
| compromised | boolean | true returns terminals with an active compromise flag. |
| cursor | string | Opaque cursor from next_cursor. Do not construct one. |
| limit | integer | Page size. Default 50, max 200. |
Response fields
| Field | Type | Notes |
|---|---|---|
| data[].terminal_id | string | Terminal identifier used by the scoring call. |
| data[].market | string | ISO 3166-1 alpha-2 market code. |
| data[].operator | string | Operator slug, e.g. hsbc, maybank, vpbank. |
| data[].risk | number | Terminal risk, 0–1, refreshed on each sweep. |
| data[].band | string | low, elevated, or high. |
| data[].compromised | boolean | True while terminal risk is at or above 0.80, or a kit signature has matched. |
| data[].last_sweep | string | ISO-8601 UTC timestamp of the last risk sweep. |
| next_cursor | string | Cursor for the next page, or null on the last page. |
| has_more | boolean | Whether another page exists. |
curl -s "https://api.atmlist.com/v1/terminals?market=SG&risk_min=0.60&compromised=true&limit=50" \
-H "Authorization: Bearer sk_live_••••••••"{
"data": [
{
"terminal_id": "ATM-SG-0182",
"market": "SG",
"operator": "hsbc",
"risk": 0.94,
"band": "high",
"compromised": true,
"last_sweep": "2026-06-09T03:15:09Z"
},
{
"terminal_id": "ATM-MY-0921",
"market": "MY",
"operator": "maybank",
"risk": 0.61,
"band": "elevated",
"compromised": false,
"last_sweep": "2026-06-09T02:48:31Z"
},
{
"terminal_id": "ATM-VN-0043",
"market": "VN",
"operator": "vpbank",
"risk": 0.38,
"band": "low",
"compromised": false,
"last_sweep": "2026-06-09T01:12:04Z"
}
],
"next_cursor": "cur_eyJvZmZzZXQiOjUwfQ",
"has_more": true
}const params = new URLSearchParams({
market: "SG",
risk_min: "0.60",
compromised: "true",
limit: "50",
});
const res = await fetch(`https://api.atmlist.com/v1/terminals?${params}`, {
headers: { Authorization: `Bearer ${process.env.ATMLIST_API_KEY}` },
});
const page = await res.json();
for (const terminal of page.data) {
console.log(terminal.terminal_id, terminal.risk, terminal.last_sweep);
}/v1/terminals/{terminal_id}/riskGet terminal risk
Current risk for one terminal, with the signals behind it and the last sweep time.
Use this to enrich a decline at your agent desk, to brief a field team before a sweep, or to show a merchant why their machine is flagged. Weights are the model's contribution per signal for the current sweep, so they sum with rounding rather than exactly to the risk score.
- Returns
- terminal_id, market, operator, risk, band, compromised, last_sweep, signals[], neighbouring_terminals[]
- Latency
- P50 45 ms · P99 110 ms
- Needs
- Bearer keyTerminal id
Path parameters
| Field | Type | Notes |
|---|---|---|
| terminal_idrequired | string | Terminal identifier, e.g. ATM-SG-0182. |
Response fields
| Field | Type | Notes |
|---|---|---|
| risk | number | Current terminal risk, 0–1. |
| band | string | low below 0.55, elevated from 0.55, high from 0.80. |
| last_sweep | string | ISO-8601 UTC timestamp of the sweep that set the score. |
| signals[].code | string | Reason code that contributed to the score. |
| signals[].weight | number | Contribution of this signal to the current score. |
| signals[].source | string | terminal_telemetry, card_reader, device_graph, or dispenser. |
| signals[].observed_at | string | ISO-8601 UTC timestamp the signal was observed. |
| neighbouring_terminals | string[] | Terminals within 500 m, used by the fleet-neighbour signal. |
curl -s "https://api.atmlist.com/v1/terminals/ATM-SG-0182/risk" \
-H "Authorization: Bearer sk_live_••••••••"{
"terminal_id": "ATM-SG-0182",
"market": "SG",
"operator": "hsbc",
"risk": 0.94,
"band": "high",
"compromised": true,
"last_sweep": "2026-06-09T03:15:09Z",
"signals": [
{
"code": "KIT_SIGNATURE",
"weight": 0.41,
"source": "device_graph",
"observed_at": "2026-06-09T03:15:02Z"
},
{
"code": "DOOR_OUT_OF_WINDOW",
"weight": 0.28,
"source": "terminal_telemetry",
"observed_at": "2026-06-09T03:14:07Z"
},
{
"code": "READ_ANOMALY",
"weight": 0.17,
"source": "card_reader",
"observed_at": "2026-06-09T03:14:41Z"
}
],
"neighbouring_terminals": [
"ATM-SG-0181",
"ATM-SG-0183"
]
}/v1/casesList cases
List open and resolved cases, newest first.
A case opens when a terminal crosses the compromise threshold or an agent pulls one from a block. Filter by status when you triage, and by terminal_id when you need the history of one machine. Resolved cases keep their outcome so you can measure how often a kit was confirmed.
- Returns
- data[], next_cursor, has_more
- Latency
- P50 120 ms · P99 280 ms
- Needs
- Bearer keyCursor pagination
Query parameters
| Field | Type | Notes |
|---|---|---|
| status | string | open or resolved. Omit for both. |
| market | string | ISO 3166-1 alpha-2 market code. |
| terminal_id | string | All cases for one terminal, newest first. |
| cursor | string | Opaque cursor from next_cursor. |
| limit | integer | Page size. Default 50, max 200. |
Response fields
| Field | Type | Notes |
|---|---|---|
| data[].case_id | string | Case reference, e.g. ATM-EV-4471. Stable, quote it to the network. |
| data[].terminal_id | string | Terminal the case is about. |
| data[].status | string | open or resolved. |
| data[].opened_at | string | ISO-8601 UTC timestamp the case opened. |
| data[].resolved_at | string | Resolution timestamp, or null while open. |
| data[].opened_by | string | decision when the scorer opened it, agent when a human did. |
| data[].evidence_available | boolean | Whether an evidence pack is ready to pull. |
curl -s "https://api.atmlist.com/v1/cases?status=open&market=SG&limit=25" \
-H "Authorization: Bearer sk_live_••••••••"{
"data": [
{
"case_id": "ATM-EV-4471",
"terminal_id": "ATM-SG-0182",
"market": "SG",
"status": "open",
"opened_at": "2026-06-09T03:15:09Z",
"resolved_at": null,
"opened_by": "decision",
"evidence_available": true
},
{
"case_id": "ATM-EV-4468",
"terminal_id": "ATM-MY-0921",
"market": "MY",
"status": "resolved",
"opened_at": "2026-06-07T22:41:12Z",
"resolved_at": "2026-06-08T04:19:55Z",
"opened_by": "agent",
"evidence_available": true
}
],
"next_cursor": "cur_eyJvcGVuZWQiOiIyMDI2LTA2LTA3In0",
"has_more": false
}/v1/cases/{case_id}Get a case
One case, with the decision that opened it.
The analyst view. opening_decision holds the decision line that raised the case — decision, score, reason codes, model, and the request_id that ties the case to the authorization record. Resolved cases also carry the outcome, so a false positive is as auditable as a confirmed kit.
- Returns
- case_id, terminal_id, market, status, opened_at, resolved_at, outcome, opening_decision, evidence_available
- Latency
- P50 110 ms · P99 260 ms
- Needs
- Bearer keyCase id
Path parameters
| Field | Type | Notes |
|---|---|---|
| case_idrequired | string | Case reference from /v1/cases, e.g. ATM-EV-4471. |
Response fields
| Field | Type | Notes |
|---|---|---|
| outcome | string | confirmed_skimming, false_positive, or no_action. null while the case is open. |
| opening_decision.decision | string | allow, step_up, or block that opened the case. |
| opening_decision.score | number | Score at the moment the case opened. |
| opening_decision.reason_codes | string[] | Reason codes attached to the opening decision. |
| opening_decision.model | string | Model version that scored it. |
| opening_decision.request_id | string | Correlation id of the opening request. |
| opening_decision.at | string | ISO-8601 UTC timestamp of the opening decision. |
curl -s "https://api.atmlist.com/v1/cases/ATM-EV-4471" \
-H "Authorization: Bearer sk_live_••••••••"{
"case_id": "ATM-EV-4471",
"terminal_id": "ATM-SG-0182",
"market": "SG",
"status": "open",
"opened_at": "2026-06-09T03:15:09Z",
"resolved_at": null,
"outcome": null,
"evidence_available": true,
"opening_decision": {
"decision": "block",
"score": 0.94,
"reason_codes": [
"KIT_SIGNATURE",
"DOOR_OUT_OF_WINDOW"
],
"model": "detect-2",
"request_id": "req_8f2c91a4",
"at": "2026-06-09T03:15:09Z"
}
}import os
import requests
headers = {"Authorization": f"Bearer {os.environ['ATMLIST_API_KEY']}"}
base = "https://api.atmlist.com/v1/cases"
case = requests.get(f"{base}/ATM-EV-4471", headers=headers, timeout=2).json()
evidence = requests.get(
f"{base}/{case['case_id']}/evidence", headers=headers, timeout=5
).json()
print(case["opened_by"]["decision"], len(evidence["artifacts"]))/v1/cases/{case_id}/evidenceGet the evidence pack
Artifacts, the event timeline, and the neighbouring terminals for a case.
Everything an investigator needs to defend a block: the firmware and reader logs captured at the terminal, the timeline that produced the decision, and the terminals within 500 m that share a device fingerprint. Artifacts are hash-addressed and immutable once captured, so the pack you pull today verifies later.
- Returns
- case_id, terminal_id, generated_at, artifacts[], timeline[], neighbouring_terminals[]
- Latency
- P50 320 ms · P99 900 ms
- Needs
- Bearer keyCase id
Path parameters
| Field | Type | Notes |
|---|---|---|
| case_idrequired | string | Case reference, e.g. ATM-EV-4471. |
Query parameters
| Field | Type | Notes |
|---|---|---|
| include | string | Comma-separated subset of artifacts, timeline, neighbours. Default returns all three. |
Response fields
| Field | Type | Notes |
|---|---|---|
| artifacts[].artifact_id | string | Immutable artifact id, used in the download URL. |
| artifacts[].kind | string | reader_firmware_log, device_fingerprint, door_event_log, dispenser_trace, or capture_image. |
| artifacts[].captured_at | string | ISO-8601 UTC timestamp the artifact was captured. |
| artifacts[].sha256 | string | SHA-256 of the artifact bytes for verification. |
| artifacts[].bytes | integer | Artifact size in bytes. |
| artifacts[].url | string | Download URL, bearer-authenticated and valid for 15 minutes. |
| timeline[].at | string | ISO-8601 UTC timestamp of the event. |
| timeline[].source | string | terminal_telemetry, card_reader, device_graph, dispenser, or the model id. |
| timeline[].title | string | Short event description. |
| timeline[].detail | string | Measured detail behind the event. |
| timeline[].reason_codes | string[] | Reason codes this event contributed to. |
| neighbouring_terminals[].distance_m | integer | Distance to the case terminal, in metres. |
| neighbouring_terminals[].shared_device | boolean | True when a device fingerprint is shared with the case terminal. |
curl -s "https://api.atmlist.com/v1/cases/ATM-EV-4471/evidence?include=artifacts,timeline" \
-H "Authorization: Bearer sk_live_••••••••"{
"case_id": "ATM-EV-4471",
"terminal_id": "ATM-SG-0182",
"generated_at": "2026-06-09T04:02:11Z",
"artifacts": [
{
"artifact_id": "art_2211",
"kind": "reader_firmware_log",
"terminal_id": "ATM-SG-0182",
"captured_at": "2026-06-09T03:14:41Z",
"bytes": 184320,
"sha256": "9c1f7a41e2b0c9d5f6a83b7c0d41e2985b6c7f0a3d9e1b2c4f5a6d7e8b9c0a1d",
"url": "https://api.atmlist.com/v1/cases/ATM-EV-4471/evidence/art_2211"
},
{
"artifact_id": "art_2212",
"kind": "device_fingerprint",
"terminal_id": "ATM-SG-0182",
"captured_at": "2026-06-09T03:15:02Z",
"bytes": 2048,
"sha256": "41d0c9b8a7f6e5d4c3b2a1908f7e6d5c4b3a2918f7e6d5c4b3a2918f7e6d5c4b",
"url": "https://api.atmlist.com/v1/cases/ATM-EV-4471/evidence/art_2212"
}
],
"timeline": [
{
"at": "2026-06-09T03:14:07Z",
"source": "terminal_telemetry",
"title": "Service door opened",
"detail": "Out-of-window access with no work order on file.",
"reason_codes": [
"DOOR_OUT_OF_WINDOW"
]
},
{
"at": "2026-06-09T03:14:41Z",
"source": "card_reader",
"title": "Read-pattern shift",
"detail": "Magnetic-stripe read failures up 6× against the terminal baseline.",
"reason_codes": [
"READ_ANOMALY"
]
},
{
"at": "2026-06-09T03:15:02Z",
"source": "device_graph",
"title": "Kit signature matched",
"detail": "Hardware profile matches kit family B-112, seen in 14 markets.",
"reason_codes": [
"KIT_SIGNATURE"
]
},
{
"at": "2026-06-09T03:15:09Z",
"source": "detect-2",
"title": "Withdrawal blocked",
"detail": "Decision block at 0.94 with the kit signature as the leading code.",
"reason_codes": [
"KIT_SIGNATURE"
]
}
],
"neighbouring_terminals": [
{
"terminal_id": "ATM-SG-0181",
"market": "SG",
"risk": 0.72,
"band": "elevated",
"distance_m": 210,
"shared_device": true
},
{
"terminal_id": "ATM-SG-0183",
"market": "SG",
"risk": 0.44,
"band": "low",
"distance_m": 260,
"shared_device": false
}
]
}/v1/signaturesList kit signatures
Kit families and device fingerprints with their market reach.
The signature library behind the KIT_SIGNATURE and SHARED_DEVICE reason codes. Each entry carries the first sighting, the markets where the hardware has been observed, and how many terminals match. Use it to plan a sweep across a region instead of a single machine.
- Returns
- data[], next_cursor, has_more
- Latency
- P50 80 ms · P99 200 ms
- Needs
- Bearer keyCursor pagination
Query parameters
| Field | Type | Notes |
|---|---|---|
| kind | string | kit_family or device_fingerprint. |
| market | string | ISO 3166-1 alpha-2 market code. |
| cursor | string | Opaque cursor from next_cursor. |
| limit | integer | Page size. Default 50, max 200. |
Response fields
| Field | Type | Notes |
|---|---|---|
| data[].signature_id | string | Signature id used in model output and case evidence. |
| data[].family | string | Kit family label, e.g. B-112. |
| data[].kind | string | kit_family for hardware, device_fingerprint for a single device. |
| data[].confidence | number | Match confidence, 0–1, from field captures and lab teardowns. |
| data[].first_seen | string | ISO-8601 UTC timestamp of the first observation. |
| data[].markets | string[] | Markets where the signature has been observed. |
| data[].terminal_count | integer | Terminals that currently match the signature. |
| data[].device_fingerprints | string[] | Fingerprints that resolve to this entry. |
curl -s "https://api.atmlist.com/v1/signatures?kind=kit_family&market=SG" \
-H "Authorization: Bearer sk_live_••••••••"{
"data": [
{
"signature_id": "sig_b112",
"family": "B-112",
"kind": "kit_family",
"confidence": 0.97,
"first_seen": "2025-11-02T00:00:00Z",
"markets": [
"SG",
"MY",
"VN",
"TH",
"PH",
"ID",
"JP",
"AU",
"IN",
"AE",
"GB",
"DE",
"BR",
"ZA"
],
"terminal_count": 41,
"device_fingerprints": [
"fp_9a2c",
"fp_9a2d",
"fp_9a31"
]
},
{
"signature_id": "sig_trap07",
"family": "TRAP-07",
"kind": "device_fingerprint",
"confidence": 0.89,
"first_seen": "2026-03-14T00:00:00Z",
"markets": [
"MY",
"SG"
],
"terminal_count": 6,
"device_fingerprints": [
"fp_c41e"
]
}
],
"next_cursor": null,
"has_more": false
}/v1/subscriptionsCreate a webhook subscription
Register an HTTPS endpoint for terminal and case events.
Events replace polling. Register an HTTPS URL, choose the event types, and ATMList posts a signed payload per event. A successful create returns 201 with the signing secret — store it in your secret manager, verify every delivery, and rotate by creating a second subscription before deleting the first. Deliveries retry for 24 hours with exponential backoff; a 2xx within 5 seconds cancels the retry.
- Returns
- subscription_id, url, events[], active, created_at, signature_header, secret
- Latency
- P50 140 ms · P99 300 ms
- Needs
- Bearer keyJSON bodyHTTPS endpoint
Request body
| Field | Type | Notes |
|---|---|---|
| urlrequired | string | HTTPS endpoint that accepts POST and returns 2xx within 5 seconds. |
| eventsrequired | string[] | terminal.compromised, withdrawal.blocked, case.opened, case.resolved. |
| description | string | Label shown in the console, e.g. sg-fleet-pager. |
| active | boolean | Defaults to true. Set false to register and pause. |
Response fields
| Field | Type | Notes |
|---|---|---|
| subscription_id | string | Subscription id for later updates and deletion. |
| secret | string | Signing secret, returned once at creation. Store it; it is never returned again. |
| signature_header | string | Header carrying the signature: ATMList-Signature. |
| active | boolean | Whether deliveries are running. |
| created_at | string | ISO-8601 UTC creation timestamp. |
curl -s -X POST "https://api.atmlist.com/v1/subscriptions" \
-H "Authorization: Bearer sk_live_••••••••" \
-H "Content-Type: application/json" \
-d '{
"url": "https://ops.yourbank.example/hooks/atmlist",
"events": [
"terminal.compromised",
"withdrawal.blocked",
"case.opened",
"case.resolved"
],
"description": "sg-fleet-pager"
}'{
"subscription_id": "sub_7c41",
"url": "https://ops.yourbank.example/hooks/atmlist",
"events": [
"terminal.compromised",
"withdrawal.blocked",
"case.opened",
"case.resolved"
],
"active": true,
"created_at": "2026-06-09T04:12:00Z",
"signature_header": "ATMList-Signature",
"secret": "whsec_1f9c4a77d0b25e83"
}const res = await fetch("https://api.atmlist.com/v1/subscriptions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ATMLIST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://ops.yourbank.example/hooks/atmlist",
events: [
"terminal.compromised",
"withdrawal.blocked",
"case.opened",
"case.resolved",
],
description: "sg-fleet-pager",
}),
});
const subscription = await res.json();
await vault.put("atmlist/webhook-secret", subscription.secret);What we post
Every delivery is a POST with a JSON body, an ATMList-Signature header, and a 5-second deadline. A non-2xx response retries for 24 hours with exponential backoff, so a receiver that is down for a deploy does not lose events.
terminal.compromised
Terminal risk reached the block threshold, or a kit signature matched at the terminal.
data:
terminal_id,market,risk,band,reason_codes[],detected_atwithdrawal.blocked
A scoring decision returned
block.data:
request_id,terminal_id,decision,score,reason_codes[],decided_atcase.opened
An investigation opened against a terminal.
data:
case_id,terminal_id,market,opened_at,opened_by,evidence_availablecase.resolved
A case closed with an outcome.
data:
case_id,status,outcome,resolved_at,evidence_available
ATMList-Signature: t=1781041920,v1=4c1f0b8a9d2e7f3b6c5a4d8e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c{
"id": "evt_2f81c0",
"type": "withdrawal.blocked",
"created_at": "2026-06-09T03:15:10Z",
"data": {
"request_id": "req_8f2c91a4",
"terminal_id": "ATM-SG-0182",
"market": "SG",
"decision": "block",
"score": 0.94,
"reason_codes": [
"KIT_SIGNATURE",
"DOOR_OUT_OF_WINDOW"
],
"decided_at": "2026-06-09T03:15:09Z"
}
}import hashlib
import hmac
import time
def verify(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
if abs(time.time() - int(parts["t"])) > tolerance:
return False
signed = parts["t"].encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])The signature covers the timestamp and the raw request body: v1 = HMAC-SHA256(secret, t + "." + body). Reject deliveries older than 300 seconds and compare in constant time.
Error codes
Failures return a JSON body with error.code, a human error.message, and the request_id that ties the call to our logs. error.param names the offending field on a 400.
| Code | Status | Meaning | What to do |
|---|---|---|---|
| invalid_request | 400 | A required field is missing, or a value is out of range. | Read error.param and correct that field. |
| unauthenticated | 401 | Key missing, revoked, or sent without the Bearer prefix. | Check the Authorization header and the key's environment. |
| forbidden | 403 | The key is valid but not entitled to this endpoint or market. | Check the market list on the key. |
| not_found | 404 | Unknown `terminal_id` or `case_id`. | Re-read the id from the fleet or case list. |
| idempotency_conflict | 409 | The `Idempotency-Key` was reused with a different request body. | Send a new key when the request changes. |
| rate_limited | 429 | The key exceeded its per-minute request budget. | Wait Retry-After seconds, then retry the same key. |
| internal_error | 500 | Scoring failed after internal retries. | Retry once with the same Idempotency-Key; quote request_id to support. |
{
"error": {
"code": "rate_limited",
"message": "Key sk_test_4a1f exceeded 60 requests per minute.",
"param": null
},
"request_id": "req_8f2c91a4"
}Rate limits and backoff
- Sandbox
- 60 req/min
- Per key, on the same scoring path with a fixed terminal sample.
- Production
- 2,000 req/min
- Per key, with a 4,000 burst ceiling for scoring traffic.
- Scoring budget
- 150 ms
- Set your timeout here. P50 is 38 ms, P99 is 90 ms.
- Backoff
- Retry-After
- 429 responses carry the wait in seconds. Retry with the same Idempotency-Key.
- Headers
- RateLimit-*
RateLimit-Limit,RateLimit-Remaining, andRateLimit-Reseton every response.
Cursor pagination
Cursors are opaque and stable for the sort order you requested. Pass next_cursor back as cursor; null means you are on the last page. Pages can shift by one record when a terminal's risk changes mid-page, so dedupe on terminal_id or case_id in your pipeline.
curl -s "https://api.atmlist.com/v1/cases?status=open&limit=50" \
-H "Authorization: Bearer sk_live_••••••••"
curl -s "https://api.atmlist.com/v1/cases?status=open&limit=50&cursor=cur_eyJvcGVuZWQiOiIyMDI2LTA2LTA3In0" \
-H "Authorization: Bearer sk_live_••••••••"How we handle card data
- PAN stored
- none
- Card input
- token + scheme
- Retention default
- 0 days
- Regions
- eu-central · us-east · ap-southeast
- Attestation
- SOC 2 Type II
- Transport
- TLS 1.3
- Card numbers are references
- Scoring takes a token from your vault and a scheme. The API rejects a PAN in any field and never returns one, so card data stays where you already tokenise it.
- Zero retention by default
- Scoring requests and responses are discarded once the response is written. Enable retention per key if your disputes team needs the request history; evidence artifacts are the exception and are kept with their case.
- No cardholder identity in the model
- Model inputs are terminal telemetry, device fingerprints, read patterns, dispense behaviour, and the token reference. Names, addresses, and PANs are not inputs.
- Compliance
- SOC 2 Type II, audited annually by an independent firm. Because no PAN is stored, adding scoring does not widen your cardholder-data scope.
- Residency
- Processing runs in eu-central, us-east, or ap-southeast, fixed per key at issue time. Evidence packs are stored in the region of the key that pulled them.
- Transport and signing
- TLS 1.3 in transit, AES-256 at rest, and HMAC-SHA256 signatures on every webhook delivery so your receiver can prove the payload came from us.