--- title: Scanner API tags: [api, scanner, payment] created: 2026-05-30 --- # Scanner API HTTP API exposed by the AMN Pay Scanner microservice. Default port: `8080`. All endpoints except `/health` require `Authorization: Bearer ` 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 ``` - Uses constant-time comparison to prevent timing attacks. - Returns `401 {"error":"unauthorized"}` on failure. - `/health` is 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:** ```json { "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`:** ```json { "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:** ```json { "chainId": 56, "address": "0x1111111111111111111111111111111111111111", "token": "USDT" } ``` **Response `200 OK`:** ```json { "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_` | | `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:** ```json { "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`:** ```json { "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`:** ```json { "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`:** ```json { "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`:** ```json { "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:** ```json { "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: ```js 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:** ```json { "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 ```json { "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 ```json { "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" } ```