454 lines
14 KiB
Markdown
454 lines
14 KiB
Markdown
---
|
|
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 <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:**
|
|
|
|
```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_<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:**
|
|
|
|
```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"
|
|
}
|
|
```
|