14 KiB
title, tags, created
| title | tags | created | |||
|---|---|---|---|---|---|
| Scanner API |
|
2026-05-30 |
Scanner API
HTTP API exposed by the AMN Pay Scanner microservice. Default port: 8080.
All endpoints except /health require Authorization: Bearer <SCANNER_API_KEY> when the key is configured in the environment (production). In dev mode (key not set) all requests are allowed.
Scanner 0.1.8 adds direct-address EVM ERC-20 balance checks and balance watches for non-smart-contract payment rails. Tron/TON direct balance watches are future scope; their existing intent scanners still work through /intents.
Base URL (dev): http://localhost:8080
Authentication
Authorization: Bearer <SCANNER_API_KEY>
- Uses constant-time comparison to prevent timing attacks.
- Returns
401 {"error":"unauthorized"}on failure. /healthis explicitly excluded from auth — always open.
POST /intents
Register a new payment intent. The scanner will watch the specified chain for a matching transfer and call back to callbackUrl when confirmed.
Request body (application/json):
| Field | Type | Required | Notes |
|---|---|---|---|
intentId |
string | yes | Caller-supplied unique ID (UUID recommended) |
chainId |
integer | yes | Numeric chain ID (e.g. 56, 137, 728126428) |
tokenAddress |
string | yes | Token contract address. EVM/Tron: lowercase 0x hex. TON: exact base64url or raw format |
destination |
string | yes | Receiving wallet address. EVM/Tron: 0x hex. TON: base64url |
amount |
string | yes | Amount in smallest unit (wei / token decimals) as a base-10 integer string |
callbackUrl |
string | yes | URL the scanner POSTs to on confirmation |
callbackSecret |
string | yes | HMAC key for X-AMN-Signature verification |
confirmations |
integer | no | Requested confirmation count. The scanner raises it to the chain acceptance floor if lower. |
Example request:
{
"intentId": "a1b2c3d4-...",
"chainId": 56,
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"destination": "0xAbCd1234...",
"amount": "10000000000000000000",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"callbackSecret": "abc123...",
"confirmations": 200
}
Response 200 OK:
{
"intentId": "a1b2c3d4-...",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"checkoutBlock": {
"destination": "0xabcd1234...",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"chainId": 56,
"proxyAddress": "0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"feeAmount": "0",
"feeAddress": "0x000000000000000000000000000000000000dEaD",
"amountWei": "10000000000000000000"
}
}
Confirmation floor: Built-in accepted thresholds are currently BSC 200, Ethereum 50, Polygon 300, Arbitrum 2400, Base 300, Tron 200, and TON 120. Callers may raise a requirement but cannot lower an intent below the chain floor.
Idempotency: If intentId already exists the existing intent's checkout block is returned (no error).
Error cases:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"intentId is required"} |
Missing field |
| 400 | {"error":"amount must be a positive integer string (base-10 wei)"} |
Non-numeric or zero amount |
| 400 | {"error":"unsupported chainId: 999"} |
Chain not in supported-chains.json |
| 500 | {"error":"internal error"} |
DB write failure |
GET /intents/{intentId}
Fetch the current state of a payment intent.
Response 200 OK: Full Intent object (see Data Models below).
callbackSecret is excluded from the response regardless of auth state.
Error cases:
| Status | Body | Cause |
|---|---|---|
| 404 | {"error":"intent not found"} |
Unknown intentId |
POST /balances/check
Read the current ERC-20 token balance for a public EVM address. The backend uses this for direct-address payment rails, including an initial baseline read when the address is shown to the buyer and a second read when the buyer clicks "I paid".
Request body (application/json):
| Field | Type | Required | Notes |
|---|---|---|---|
chainId |
integer | yes | EVM chain ID configured in supported-chains.json |
address |
string | yes | Holder address to read |
tokenAddress |
string | conditional | ERC-20 contract address. Required unless token/tokenSymbol resolves in tokens.json |
token |
string | conditional | Token symbol alias, e.g. USDT; same meaning as tokenSymbol |
tokenSymbol |
string | conditional | Token symbol alias, e.g. USDT |
Example request:
{
"chainId": 56,
"address": "0x1111111111111111111111111111111111111111",
"token": "USDT"
}
Response 200 OK:
{
"chainId": 56,
"chainType": "evm",
"address": "0x1111111111111111111111111111111111111111",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"balance": "25000000000000000000",
"checkedAt": "2026-06-03T10:00:00Z"
}
balance is a base-unit integer string. It is not formatted into human token units.
Error cases:
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"chainId is required"} |
Missing chain |
| 400 | {"error":"balance checks are currently supported for evm chains only"} |
Non-EVM chain |
| 400 | {"error":"tokenAddress or token is required"} |
No token selector |
| 400 | {"error":"unsupported token USDT on chainId 999"} |
Token symbol not registered |
| 502 | {"error":"balance check failed: ..."} |
RPC read failed |
POST /balance-watches
Create or replay a direct-address balance watch. A watch stores the current token balance and polls for changes. When the balance changes, the scanner sends a signed balance_changed webhook to callbackUrl.
Request body (application/json):
| Field | Type | Required | Notes |
|---|---|---|---|
watchId |
string | no | Caller-supplied idempotency key. If omitted, scanner generates bw_<hex> |
chainId |
integer | yes | EVM chain ID |
address |
string | yes | Address to watch |
tokenAddress |
string | conditional | ERC-20 contract address unless token/tokenSymbol resolves |
token / tokenSymbol |
string | conditional | Token symbol from tokens.json |
callbackUrl |
string | yes | Backend webhook URL |
callbackSecret |
string | yes | HMAC key for X-AMN-Signature |
baselineBalance |
string | no | Optional base-unit integer baseline. If omitted, scanner uses the initial balance read |
Example request:
{
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"address": "0x1111111111111111111111111111111111111111",
"token": "USDT",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"callbackSecret": "abc123...",
"baselineBalance": "25000000000000000000"
}
Response 200 OK:
{
"watch": {
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"chainType": "evm",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"address": "0x1111111111111111111111111111111111111111",
"baselineBalance": "25000000000000000000",
"currentBalance": "25000000000000000000",
"status": "watching",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"nextCheckAt": "2026-06-03T10:05:00Z",
"changeCount": 0,
"expiresAt": "2026-06-10T10:00:00Z",
"createdAt": "2026-06-03T10:00:00Z",
"updatedAt": "2026-06-03T10:00:00Z"
}
}
Idempotency: Reusing the same watchId with the same chain, address, token, and callback returns the existing watch. Reusing it with different parameters returns 409.
Cadence: checks every 5 minutes during the first 24 hours, then 10 minutes until 48 hours, 20 minutes until 72 hours, and 40 minutes until the watch expires after 7 days.
GET /balance-watches/{watchId}
Fetch the current watch state. callbackSecret is excluded from the response.
Response 200 OK: { "watch": BalanceWatch }
DELETE /balance-watches/{watchId}
Stop a watch after the backend accepts, cancels, or times out the payment. Stopped watches are not polled.
POST /balance-watches/{watchId}/stop is accepted as an equivalent stop command.
Response 200 OK: { "watch": BalanceWatch } with status: "stopped".
GET /scanner/status
Returns scan progress for all verified chains.
Response 200 OK:
{
"chains": [
{
"chainId": 56,
"name": "BSC",
"chainType": "evm",
"lastScannedBlock": 39000000,
"chainHead": 39000015,
"lag": 15,
"pendingIntents": 3,
"activeBalanceWatches": 2
},
{
"chainId": 728126428,
"name": "TRX",
"chainType": "tron",
"lastScannedBlock": 1748500000000,
"chainHead": 1748500015000,
"lag": 15000,
"pendingIntents": 1,
"activeBalanceWatches": 0
},
{
"chainId": 1100,
"name": "TON",
"chainType": "ton",
"lastScannedBlock": 1748500000,
"chainHead": 1748500015,
"lag": 15,
"pendingIntents": 0,
"activeBalanceWatches": 0
}
]
}
Note on lag units: For EVM and Tron chains, lag is in blocks (or ms-timestamp difference). For TON, lag is in seconds (Unix timestamps).
POST /admin/webhooks/retry
Immediately trigger a re-delivery attempt for all webhook_failed intents. Normally the scanner retries automatically every WEBHOOK_RETRY_HOURS; this endpoint forces an immediate pass.
Response 200 OK:
{ "queued": 2 }
Each retry is dispatched in a separate goroutine. Success resets the intent status to confirmed and records webhook_delivered_at.
GET /health
Health check. No authentication required.
Response 200 OK:
{ "status": "ok", "time": "2026-05-30T12:00:00Z" }
Used by Docker HEALTHCHECK and upstream load balancers / Gatus monitoring.
Webhook delivery (outbound)
When an intent is confirmed the scanner POSTs to callbackUrl:
Headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-AMN-Signature |
hex(HMAC-SHA256(body, callbackSecret)) |
X-AMN-Delivery-ID |
intentId |
X-AMN-Retry |
true (only on manual retry via /admin/webhooks/retry) |
Body:
{
"intentId": "a1b2c3d4-...",
"paymentReference": "0x1a2b3c4d5e6f7a8b",
"txHash": "0xdeadbeef...",
"blockNumber": 39000010,
"confirmations": 200,
"amount": "10000000000000000000",
"token": "0x55d398326f99059ff775485246999027b3197955",
"chainId": 56,
"status": "confirmed"
}
confirmations is the accepted confirmation count. Once the intent is confirmed, the scanner caps this value at confirmationsRequired; it does not keep reporting a live, ever-growing block count.
Retry schedule (on non-2xx or network error): 5 s → 30 s → 2 min → 10 min → 1 h → webhook_failed.
The backend should verify X-AMN-Signature to reject forged callbacks:
const expected = createHmac('sha256', callbackSecret).update(rawBody).digest('hex');
if (!timingSafeEqual(Buffer.from(received), Buffer.from(expected))) reject();
Balance watch webhook
When a balance watch observes a changed balance, the scanner POSTs to the watch callbackUrl.
Headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-AMN-Signature |
hex(HMAC-SHA256(body, callbackSecret)) |
X-AMN-Delivery-ID |
watchId |
X-AMN-Event-Type |
balance_changed |
Body:
{
"eventType": "balance_changed",
"watchId": "6840fabc-balance-c56-USDT",
"chainId": 56,
"chainType": "evm",
"address": "0x1111111111111111111111111111111111111111",
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"previousBalance": "25000000000000000000",
"currentBalance": "35000000000000000000",
"delta": "10000000000000000000",
"changeCount": 1,
"checkedAt": "2026-06-03T10:05:00Z",
"status": "balance_changed"
}
The scanner retries the changed-balance webhook inside the same due-check pass with short backoffs. If delivery still fails, it does not advance currentBalance; the same change is retried on the next scheduled due check.
Data models
Intent object
{
"intentId": "string",
"chainId": 56,
"chainType": "evm",
"tokenAddress": "0x...",
"destination": "0x...",
"amount": "10000000000000000000",
"paymentReference": "0x1a2b3c4d",
"topicRef": "0xdeadbeef...",
"status": "pending | confirming | confirmed | expired | webhook_failed",
"confirmationsRequired": 200,
"txHash": null,
"logIndex": null,
"blockNumber": null,
"confirmations": 0,
"salt": "hex64chars",
"webhookDeliveredAt": null,
"createdAt": "2026-05-30T10:00:00Z",
"updatedAt": "2026-05-30T10:00:00Z"
}
Note: callbackUrl and callbackSecret are present in the DB but callbackSecret is always omitted from API responses.
BalanceWatch object
{
"watchId": "string",
"chainId": 56,
"chainType": "evm",
"tokenAddress": "0x...",
"tokenSymbol": "USDT",
"decimals": 18,
"address": "0x...",
"baselineBalance": "25000000000000000000",
"currentBalance": "25000000000000000000",
"status": "watching | stopped | expired",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"lastCheckedAt": null,
"nextCheckAt": "2026-06-03T10:05:00Z",
"changeCount": 0,
"lastNotifiedAt": null,
"expiresAt": "2026-06-10T10:00:00Z",
"createdAt": "2026-06-03T10:00:00Z",
"updatedAt": "2026-06-03T10:00:00Z"
}