Files
nick-doc/03 - API Reference/Scanner API.md

14 KiB

title, tags, created
title tags created
Scanner API
api
scanner
payment
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.
  • /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:

{
  "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"
}