Detection API · v1

Score the machine behind the withdrawal.

One POST inside the authorization path returns allow, step_up, or block, with the reason codes and the terminal's own risk attached. Cases, evidence packs, and kit signatures come from the same surface.

Scoring pathpost

https://api.atmlist.com/v1/score/withdrawal

decision
block
score
0.94
model
detect-2
latency_ms
38 ms

P50 38 ms · P99 90 ms · zero retention

Base URL
https://api.atmlist.com
Version
v1
Spec
OpenAPI 3.1.0
Updated
8 October 2026
Overview

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.

POST/v1/score/withdrawalScore one cash withdrawal against the terminal in the authorization path.
GET/v1/terminalsList terminals with current risk, sorted by risk descending.
GET/v1/terminals/{terminal_id}/riskCurrent risk for one terminal, with the signals behind it and the last sweep time.
GET/v1/casesList open and resolved cases, newest first.
GET/v1/cases/{case_id}One case, with the decision that opened it.
GET/v1/cases/{case_id}/evidenceArtifacts, the event timeline, and the neighbouring terminals for a case.
GET/v1/signaturesKit families and device fingerprints with their market reach.
POST/v1/subscriptionsRegister an HTTPS endpoint for terminal and case events.
Overview

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.

Request headershttp
Authorization: Bearer sk_live_••••••••
Accept: application/json
X-Request-Id: 7f3c9e2a-4b81-4d0f-9c37-2a5e6f9b1c04

Keep 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.

Overview

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
Quickstart

From key to first decision

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Scoring
POST/v1/score/withdrawal

Score 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

Request body
FieldTypeNotes
terminal_idrequiredstringTerminal identifier from /v1/terminals, e.g. ATM-SG-0182.
amountrequiredobject{ value, currency }. value in the currency's major unit, currency as ISO-4217.
cardrequiredobject{ token, scheme }. token is your vault reference. A PAN is rejected.
entry_modestringchip, magstripe, or contactless. A magstripe read raises the weight on READ_ANOMALY.
request_idstringYour correlation id. Returned unchanged so an authorization log and a decision line up.

Response fields

Response fields
FieldTypeNotes
decisionstringallow, step_up, or block. block at or above 0.80, step_up from 0.55.
scorenumberDecision score, 0–1, rounded to two decimals.
reason_codesstring[]Reason codes that fired, highest weight first. Empty on an allow.
terminal_riskTerminalRiskTerminal risk at decision time: risk, band, compromised, last_sweep.
modelstringModel that scored the request, e.g. detect-2. Log it with the decision.
latency_msintegerServer-side scoring time, excluding your network hop.
request_idstringEcho of your id, or a generated one. Quote it in disputes.
cURLbash
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"
  }'
Responsejson
{
  "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
}
TypeScripttypescript
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);
}
Pythonpython
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"])
Scoring

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.

Terminals
GET/v1/terminals

List 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

Query parameters
FieldTypeNotes
marketstringISO 3166-1 alpha-2 market, e.g. SG, MY, VN.
risk_minnumberRisk floor, 0–1. 0.80 returns the block threshold band and above.
compromisedbooleantrue returns terminals with an active compromise flag.
cursorstringOpaque cursor from next_cursor. Do not construct one.
limitintegerPage size. Default 50, max 200.

Response fields

Response fields
FieldTypeNotes
data[].terminal_idstringTerminal identifier used by the scoring call.
data[].marketstringISO 3166-1 alpha-2 market code.
data[].operatorstringOperator slug, e.g. hsbc, maybank, vpbank.
data[].risknumberTerminal risk, 0–1, refreshed on each sweep.
data[].bandstringlow, elevated, or high.
data[].compromisedbooleanTrue while terminal risk is at or above 0.80, or a kit signature has matched.
data[].last_sweepstringISO-8601 UTC timestamp of the last risk sweep.
next_cursorstringCursor for the next page, or null on the last page.
has_morebooleanWhether another page exists.
cURLbash
curl -s "https://api.atmlist.com/v1/terminals?market=SG&risk_min=0.60&compromised=true&limit=50" \
  -H "Authorization: Bearer sk_live_••••••••"
Responsejson
{
  "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
}
TypeScripttypescript
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);
}
Terminals
GET/v1/terminals/{terminal_id}/risk

Get 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

Path parameters
FieldTypeNotes
terminal_idrequiredstringTerminal identifier, e.g. ATM-SG-0182.

Response fields

Response fields
FieldTypeNotes
risknumberCurrent terminal risk, 0–1.
bandstringlow below 0.55, elevated from 0.55, high from 0.80.
last_sweepstringISO-8601 UTC timestamp of the sweep that set the score.
signals[].codestringReason code that contributed to the score.
signals[].weightnumberContribution of this signal to the current score.
signals[].sourcestringterminal_telemetry, card_reader, device_graph, or dispenser.
signals[].observed_atstringISO-8601 UTC timestamp the signal was observed.
neighbouring_terminalsstring[]Terminals within 500 m, used by the fleet-neighbour signal.
cURLbash
curl -s "https://api.atmlist.com/v1/terminals/ATM-SG-0182/risk" \
  -H "Authorization: Bearer sk_live_••••••••"
Responsejson
{
  "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"
  ]
}
Cases & evidence
GET/v1/cases

List 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

Query parameters
FieldTypeNotes
statusstringopen or resolved. Omit for both.
marketstringISO 3166-1 alpha-2 market code.
terminal_idstringAll cases for one terminal, newest first.
cursorstringOpaque cursor from next_cursor.
limitintegerPage size. Default 50, max 200.

Response fields

Response fields
FieldTypeNotes
data[].case_idstringCase reference, e.g. ATM-EV-4471. Stable, quote it to the network.
data[].terminal_idstringTerminal the case is about.
data[].statusstringopen or resolved.
data[].opened_atstringISO-8601 UTC timestamp the case opened.
data[].resolved_atstringResolution timestamp, or null while open.
data[].opened_bystringdecision when the scorer opened it, agent when a human did.
data[].evidence_availablebooleanWhether an evidence pack is ready to pull.
cURLbash
curl -s "https://api.atmlist.com/v1/cases?status=open&market=SG&limit=25" \
  -H "Authorization: Bearer sk_live_••••••••"
Responsejson
{
  "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
}
Cases & evidence
GET/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

Path parameters
FieldTypeNotes
case_idrequiredstringCase reference from /v1/cases, e.g. ATM-EV-4471.

Response fields

Response fields
FieldTypeNotes
outcomestringconfirmed_skimming, false_positive, or no_action. null while the case is open.
opening_decision.decisionstringallow, step_up, or block that opened the case.
opening_decision.scorenumberScore at the moment the case opened.
opening_decision.reason_codesstring[]Reason codes attached to the opening decision.
opening_decision.modelstringModel version that scored it.
opening_decision.request_idstringCorrelation id of the opening request.
opening_decision.atstringISO-8601 UTC timestamp of the opening decision.
cURLbash
curl -s "https://api.atmlist.com/v1/cases/ATM-EV-4471" \
  -H "Authorization: Bearer sk_live_••••••••"
Responsejson
{
  "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"
  }
}
Pythonpython
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"]))
Cases & evidence
GET/v1/cases/{case_id}/evidence

Get 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

Path parameters
FieldTypeNotes
case_idrequiredstringCase reference, e.g. ATM-EV-4471.

Query parameters

Query parameters
FieldTypeNotes
includestringComma-separated subset of artifacts, timeline, neighbours. Default returns all three.

Response fields

Response fields
FieldTypeNotes
artifacts[].artifact_idstringImmutable artifact id, used in the download URL.
artifacts[].kindstringreader_firmware_log, device_fingerprint, door_event_log, dispenser_trace, or capture_image.
artifacts[].captured_atstringISO-8601 UTC timestamp the artifact was captured.
artifacts[].sha256stringSHA-256 of the artifact bytes for verification.
artifacts[].bytesintegerArtifact size in bytes.
artifacts[].urlstringDownload URL, bearer-authenticated and valid for 15 minutes.
timeline[].atstringISO-8601 UTC timestamp of the event.
timeline[].sourcestringterminal_telemetry, card_reader, device_graph, dispenser, or the model id.
timeline[].titlestringShort event description.
timeline[].detailstringMeasured detail behind the event.
timeline[].reason_codesstring[]Reason codes this event contributed to.
neighbouring_terminals[].distance_mintegerDistance to the case terminal, in metres.
neighbouring_terminals[].shared_devicebooleanTrue when a device fingerprint is shared with the case terminal.
cURLbash
curl -s "https://api.atmlist.com/v1/cases/ATM-EV-4471/evidence?include=artifacts,timeline" \
  -H "Authorization: Bearer sk_live_••••••••"
Responsejson
{
  "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
    }
  ]
}
Signatures
GET/v1/signatures

List 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

Query parameters
FieldTypeNotes
kindstringkit_family or device_fingerprint.
marketstringISO 3166-1 alpha-2 market code.
cursorstringOpaque cursor from next_cursor.
limitintegerPage size. Default 50, max 200.

Response fields

Response fields
FieldTypeNotes
data[].signature_idstringSignature id used in model output and case evidence.
data[].familystringKit family label, e.g. B-112.
data[].kindstringkit_family for hardware, device_fingerprint for a single device.
data[].confidencenumberMatch confidence, 0–1, from field captures and lab teardowns.
data[].first_seenstringISO-8601 UTC timestamp of the first observation.
data[].marketsstring[]Markets where the signature has been observed.
data[].terminal_countintegerTerminals that currently match the signature.
data[].device_fingerprintsstring[]Fingerprints that resolve to this entry.
cURLbash
curl -s "https://api.atmlist.com/v1/signatures?kind=kit_family&market=SG" \
  -H "Authorization: Bearer sk_live_••••••••"
Responsejson
{
  "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
}
Webhooks
POST/v1/subscriptions

Create 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

Request body
FieldTypeNotes
urlrequiredstringHTTPS endpoint that accepts POST and returns 2xx within 5 seconds.
eventsrequiredstring[]terminal.compromised, withdrawal.blocked, case.opened, case.resolved.
descriptionstringLabel shown in the console, e.g. sg-fleet-pager.
activebooleanDefaults to true. Set false to register and pause.

Response fields

Response fields
FieldTypeNotes
subscription_idstringSubscription id for later updates and deletion.
secretstringSigning secret, returned once at creation. Store it; it is never returned again.
signature_headerstringHeader carrying the signature: ATMList-Signature.
activebooleanWhether deliveries are running.
created_atstringISO-8601 UTC creation timestamp.
cURLbash
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"
  }'
Responsejson
{
  "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"
}
TypeScripttypescript
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);
Webhooks

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_at

  • withdrawal.blocked

    A scoring decision returned block.

    data: request_id, terminal_id, decision, score, reason_codes[], decided_at

  • case.opened

    An investigation opened against a terminal.

    data: case_id, terminal_id, market, opened_at, opened_by, evidence_available

  • case.resolved

    A case closed with an outcome.

    data: case_id, status, outcome, resolved_at, evidence_available

Signature headerhttp
ATMList-Signature: t=1781041920,v1=4c1f0b8a9d2e7f3b6c5a4d8e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c
Payloadjson
{
  "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"
  }
}
Verifypython
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.

Errors & limits

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.

Detection API error codes
CodeStatusMeaningWhat to do
invalid_request400A required field is missing, or a value is out of range.Read error.param and correct that field.
unauthenticated401Key missing, revoked, or sent without the Bearer prefix.Check the Authorization header and the key's environment.
forbidden403The key is valid but not entitled to this endpoint or market.Check the market list on the key.
not_found404Unknown `terminal_id` or `case_id`.Re-read the id from the fleet or case list.
idempotency_conflict409The `Idempotency-Key` was reused with a different request body.Send a new key when the request changes.
rate_limited429The key exceeded its per-minute request budget.Wait Retry-After seconds, then retry the same key.
internal_error500Scoring failed after internal retries.Retry once with the same Idempotency-Key; quote request_id to support.
429 responsejson
{
  "error": {
    "code": "rate_limited",
    "message": "Key sk_test_4a1f exceeded 60 requests per minute.",
    "param": null
  },
  "request_id": "req_8f2c91a4"
}
Errors & limits

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, and RateLimit-Reset on every response.
Errors & limits

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.

Two pages of open casesbash
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_••••••••"
Data handling

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.

Wire it into the authorization path.

Sandbox keys run the same scoring path on a fixed terminal sample, so decisions and reason codes match production semantics.