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

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 |

---

## 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
    },
    {
      "chainId": 728126428,
      "name": "TRX",
      "chainType": "tron",
      "lastScannedBlock": 1748500000000,
      "chainHead": 1748500015000,
      "lag": 15000,
      "pendingIntents": 1
    },
    {
      "chainId": 1100,
      "name": "TON",
      "chainType": "ton",
      "lastScannedBlock": 1748500000,
      "chainHead": 1748500015,
      "lag": 15,
      "pendingIntents": 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();
```

---

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