---
title: Payment Flow - Scanner (In-House)
tags: [flow, scanner, payment]
created: 2026-05-30
---

# Payment Flow — AMN Pay Scanner (In-House)

> **Last updated:** 2026-06-03 — documented backend/frontend `2.8.60` and scanner `0.1.8` direct-address balance checks and balance watches.

End-to-end payment flow using the in-house AMN Pay Scanner, replacing the Request Network integration. The scanner is a separate microservice; the backend talks to it over an internal HTTP API.

See also: [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md), [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md), [PRD - Direct Address Token Payments via Scanner Balance Watches](../PRD%20-%20Direct%20Address%20Token%20Payments%20via%20Scanner%20Balance%20Watches.md)

---

## 1. High-level sequence

```
Buyer                Backend              Scanner            Chain
  │                     │                    │                 │
  │  initiate payment   │                    │                 │
  │────────────────────►│                    │                 │
  │                     │ POST /intents      │                 │
  │                     │───────────────────►│                 │
  │                     │ 200 checkoutBlock  │                 │
  │                     │◄───────────────────│                 │
  │  checkoutBlock      │                    │                 │
  │◄────────────────────│                    │                 │
  │                     │                    │                 │
  │  sign + submit tx ──────────────────────────────────────►│
  │                     │                    │  (polling)      │
  │                     │                    │◄────────────────│
  │                     │                    │  log matched    │
  │                     │                    │  confirmations… │
  │                     │◄───────────────────│                 │
  │                     │  POST callbackUrl  │                 │
  │                     │  (webhook)         │                 │
  │                     │                    │                 │
  │  payment confirmed  │                    │                 │
  │◄────────────────────│                    │                 │
```

---

## 2. Step-by-step

### Step 1 — Backend creates an intent

When the buyer chooses a payment method (e.g. USDT on BSC), the backend calls:

```
POST http://scanner:8080/intents
Authorization: Bearer <SCANNER_API_KEY>

{
  "intentId":       "<payment._id>",
  "chainId":        56,
  "tokenAddress":   "0x55d398326f99059ff775485246999027b3197955",
  "destination":    "0xSellerWalletAddress",
  "amount":         "10000000000000000000",
  "callbackUrl":    "https://api.amn.gg/api/payment/amn-scanner/webhook",
  "callbackSecret": "<per-intent HMAC secret stored in payment doc>",
  "confirmations":  200
}
```

The scanner responds with a `checkoutBlock` that the backend passes to the frontend.

### Step 2 — Frontend shows checkout

The `checkoutBlock` contains everything the frontend needs to build the `ERC20FeeProxy.transferWithReferenceAndFee` calldata:

| Field | Used for |
|---|---|
| `proxyAddress` | contract to call |
| `tokenAddress` | ERC20 token |
| `destination` | `_to` param |
| `paymentReference` | `_paymentReference` param (8-byte reference) |
| `amountWei` | `_amount` param |
| `feeAmount` | `_feeAmount` param (always `"0"` currently) |
| `feeAddress` | `_feeAddress` param (always dead address) |

For Tron/TON the buyer sends a plain TRC20/Jetton transfer to `destination`; there is no proxy contract.

### Step 3 — Buyer submits transaction

The buyer signs and broadcasts the transaction using their wallet. The scanner independently monitors the chain and does not require the transaction hash.

### Step 4 — Scanner detects and confirms

**EVM path:**
1. `eth_getLogs` returns a `TransferWithReferenceAndFee` log matching `topicRef`
2. `validateLogMatchesIntent` verifies token address, destination, and amount
3. Intent moves to `confirming`; scanner waits for N blocks
4. Once `confirmationsRequired` blocks have been built on top, intent moves to `confirmed`. The scanner stores and reports the accepted threshold count, not an ever-growing live count.

**Tron path:**
1. TronGrid `Transfer` event matches `destination` (EVM-hex normalized)
2. Amount validated ≥ intent amount
3. Intent goes directly to `confirmed` (TronGrid returns only confirmed txs)

**TON path:**
1. TonCenter Jetton transfer matches `destination` (exact base64url) and `jetton_master_address`
2. Amount validated ≥ intent amount
3. Intent goes directly to `confirmed`

### Step 5 — Webhook delivery

The scanner POSTs to `callbackUrl` with:

```json
{
  "intentId": "...",
  "paymentReference": "0x...",
  "txHash": "0x...",
  "blockNumber": 39000010,
  "amount": "10000000000000000000",
  "token": "0x55d...",
  "chainId": 56,
  "confirmations": 200,
  "status": "confirmed"
}
```

Header `X-AMN-Signature` = `HMAC-SHA256(body, callbackSecret)`.

The backend verifies the signature, matches the `intentId` to a Payment record, and marks it paid. Backend `2.6.82+` treats scanner `status: "confirmed"` as final enough to run Transaction Safety Provider checks and persist `blockchain.confirmations`. The stored confirmation count comes from verifier evidence first, then the webhook payload, then the configured per-chain threshold fallback, but settled counts are capped at the accepted threshold so the UI can show values like `200+` instead of chasing the live chain height forever.

### Step 6 — Backend acknowledges

Backend returns a 2xx response. Scanner records `webhook_delivered_at` and the intent lifecycle ends.

---

## 3. Direct-address payment mode

Scanner `0.1.8` adds a non-smart-contract rail for cases where the buyer transfers tokens directly to a backend-assigned address instead of calling `ERC20FeeProxy`.

This rail is currently EVM ERC-20 only. Tron/TON direct balance reads are future scope.

### Mode A — synchronous balance check

```
Buyer                Backend              Scanner              EVM RPC
  │                     │                    │                    │
  │  open checkout      │                    │                    │
  │────────────────────►│                    │                    │
  │                     │ POST /balances/check                    │
  │                     │───────────────────►│ eth_call balanceOf │
  │                     │◄───────────────────│◄────────────────── │
  │  address + amount   │                    │                    │
  │◄────────────────────│                    │                    │
  │  direct token transfer ──────────────────────────────────────►│
  │  click "I paid"     │                    │                    │
  │────────────────────►│                    │                    │
  │                     │ POST /balances/check                    │
  │                     │───────────────────►│ eth_call balanceOf │
  │                     │◄───────────────────│◄────────────────── │
  │  payment accepted if delta >= expected amount                  │
```

Backend responsibilities:

1. Allocate or select the payment address.
2. Call scanner `POST /balances/check` to store a base-unit `baselineBalance`.
3. Show the address/token/amount to the buyer.
4. When buyer clicks "I paid", call `POST /balances/check` again.
5. Compare `(currentBalance - baselineBalance)` to the expected base-unit amount.
6. Persist evidence and run the normal payment/ledger transition only after chain, token, address, and amount checks pass.

### Mode B — balance watch

```
Backend              Scanner                 EVM RPC
  │ POST /balance-watches │                    │
  │──────────────────────►│ initial balanceOf  │
  │◄──────────────────────│                    │
  │                       │ every 5m, then 10/20/40m
  │                       │───────────────────►│
  │                       │ balance changed    │
  │◄──────────────────────│ signed webhook     │
  │ payment accepted      │                    │
  │ DELETE /balance-watches/{watchId}          │
  │──────────────────────►│ status=stopped     │
```

Backend `2.8.60` exposes scanner helper functions in `amnPayAdapter.ts`:

| Helper | Scanner endpoint |
|---|---|
| `checkScannerTokenBalance` | `POST /balances/check` |
| `createScannerBalanceWatch` | `POST /balance-watches` |
| `stopScannerBalanceWatch` | `DELETE /balance-watches/{watchId}` |

Backend `2.8.60` also accepts signed `balance_changed` scanner webhooks on the existing AMN scanner webhook route. The current webhook handler records `amnScannerBalanceWatch` metadata and returns `202`; it does not yet mark the payment funded on balance change alone. The product decision rule still needs to be implemented by the backend work described in the PRD.

Recommended watch ID shape: `<paymentId>-balance-c<chainId>-<TOKEN>`. The webhook handler maps this back to the payment ID prefix.

Scanner cadence:

| Age | Interval |
|---|---|
| First 24h | 5 min |
| 24–48h | 10 min |
| 48–72h | 20 min |
| 72h–7d | 40 min |
| After 7d | `expired` |

Backend must stop a watch when payment is accepted, cancelled, manually resolved, or no longer relevant.

---

## 4. Failure paths

### Webhook delivery failure

If the backend returns non-2xx or is unreachable, the scanner retries:

```
attempt 1: after 5 s
attempt 2: after 30 s
attempt 3: after 2 min
attempt 4: after 10 min
attempt 5: after 1 h
→ status = webhook_failed
```

`webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`.

On startup the scanner reconciles any `confirmed` intents with `webhook_delivered_at IS NULL` (crash recovery).

### Intent expiry

Intents in `pending` or `confirming` status older than `INTENT_TTL_HOURS` (default 24 h) are moved to `expired` by a background ticker running every hour.

`confirming` intents can get stuck if a transaction is deep-reorganised and never re-included; the TTL frees the destination address for reuse.

### Amount underpayment

Transfers where the on-chain amount is less than `intent.Amount` are silently skipped. The intent remains `pending` until the TTL.

### Wrong token or destination

The EVM log decoder validates all three fields (token, destination, amount). Mismatches are logged as `REJECT` and skipped. The intent remains `pending`.

### Balance watch change but payment not complete

`balance_changed` means the watched balance changed; it is not a final paid signal by itself. Backend must reject or keep waiting when:

- `delta` is less than the expected payment amount.
- The balance decreased or moved by an unrelated amount.
- The watch address/token/chain do not match the payment metadata.
- The payment was already completed, cancelled, refunded, or superseded.

---

## 5. Key differences from Request Network integration

| Dimension | Request Network | AMN Pay Scanner |
|---|---|---|
| Dependency | RN SDK + API | None (direct RPC) |
| Payment reference | RN-generated | Internal HMAC derivation |
| EVM matching | By reference hash (RN) | By Topics[1] / topicRef (indexed) |
| Tron | Not supported | TRC20 Transfer events via TronGrid |
| TON | Not supported | Jetton transfers via TonCenter v3 |
| Confirmations | RN handled | Per-chain configurable |
| Webhook | RN webhook → backend adapter | Scanner → backend directly |
| State store | External (RN cloud) | Internal SQLite |
| Direct address payments | Not supported | EVM ERC-20 balance check/watch rail |
