Full-codebase-audit 2026-05-30 outputs: - Audit report: 09 - Audits/Full Codebase Audit - 2026-05-30.md - 81 issue files ISSUE-055..135 (decisions + 1 skipped no-brainer). - Scanner docs from scratch (was zero): architecture, data model, API ref, payment flow, operations runbook + repo README. - Doc-sync updates across API reference, data models, flows, design system. - Secret Rotation Runbook (08 - Operations) for the exposed credentials. - Reusable workflow guide (07 - Development) + .claude/workflows/full-codebase-audit.js. Issues remain status:open intentionally — the code fixes are uncommitted-then-committed working-tree changes per repo and aren't "resolved" until merged/deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
250 lines
6.5 KiB
Markdown
250 lines
6.5 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.
|
|
|
|
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 | Override chain default confirmation count (0 = use chain default) |
|
|
|
|
**Example request:**
|
|
|
|
```json
|
|
{
|
|
"intentId": "a1b2c3d4-...",
|
|
"chainId": 56,
|
|
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
|
|
"destination": "0xAbCd1234...",
|
|
"amount": "10000000000000000000",
|
|
"callbackUrl": "https://api.amn.gg/api/payment/scanner-callback",
|
|
"callbackSecret": "abc123...",
|
|
"confirmations": 12
|
|
}
|
|
```
|
|
|
|
**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"
|
|
}
|
|
}
|
|
```
|
|
|
|
**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,
|
|
"amount": "10000000000000000000",
|
|
"token": "0x55d398326f99059ff775485246999027b3197955",
|
|
"chainId": 56,
|
|
"status": "confirmed"
|
|
}
|
|
```
|
|
|
|
**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": 12,
|
|
"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.
|