> Solana Developer Platform docs, served as Markdown. The [docs index](https://platform-beta.solana.com/docs.md) lists every page and [llms.txt](https://platform-beta.solana.com/docs/ai/llms.txt) the key ones; [SKILL.md](https://platform-beta.solana.com/SKILL.md) has the rules for building on SDP.

# Indexing and reconciliation

List transfers, read a wallet's observed payments, dedupe, and reconcile against your own ledger.

`GET /v1/payments/transfers` lists the transfers SDP has recorded in your project, including what you sent (single, batch and recurring) and paid payment requests. Filter by direction, status, token, wallet, counterparty and date. To also see payments SDP never recorded, such as a customer's direct transfer to your address, pass one wallet's `custodyWalletId` with `includeObserved=true`.

## Listing inbound transfers

The minimal inbound request:

**curl**

```bash title="Terminal"
curl "https://api.solana.com/v1/payments/transfers?direction=inbound&pageSize=50" \
  -H "Authorization: Bearer sf_..."
```

**TypeScript**

```typescript title="list-inbound-transfers.ts"
const url = new URL("https://api.solana.com/v1/payments/transfers");
url.searchParams.set("direction", "inbound");
url.searchParams.set("pageSize", "50");

const res = await fetch(url, {
  headers: { Authorization: "Bearer sf_..." },
});
const { data, meta } = await res.json();
// data: Transfer[]
// meta: { total, page, pageSize, hasMore, requestId }
```

Without `includeObserved`, this returns recorded inbound transfers only, such as paid payment requests.

### Query parameters

| Parameter | Type | Notes |
| --- | --- | --- |
| `direction` | `inbound` \| `outbound` | Inbound is a payment into one of your wallets. |
| `status` | string | One or more of `processing`, `confirmed`, `finalized`, `failed`, comma-separated. |
| `custodyWalletId` | string | One wallet's SDP `id` (`cwlt_…` from `GET /v1/wallets`). |
| `includeObserved` | boolean | Default `false`. With `custodyWalletId`, adds the wallet's recent on-chain activity; see [Observed transfers](#observed-transfers). |
| `token` | string | A well-known symbol (`SOL`, `USDC`) or a mint address. A well-known token matches either form. |
| `counterpartyId` | string | Transfers tied to one counterparty (`cpty_…`). |
| `search` | string | 3 or more characters, matched against ids, signatures, addresses, memo and counterparty names. |
| `from`, `to` | ISO 8601 datetime | Window on `createdAt`. Include a time zone, such as `2026-05-14T00:00:00Z`. |
| `sortBy`, `sortDirection` | string | Default `createdAt`, `desc`. |
| `page`, `pageSize` | integer | Default `1` and `20`. `pageSize` is at most `100`. |

[List transfers](/docs/reference/api/payments/list-payment-transfers) has every parameter.

## Observed transfers

With `custodyWalletId` and `includeObserved=true`, the list adds what the wallet's recent on-chain history shows and SDP has no record of:

- SDP builds these rows from the wallet's latest signatures (at most 200) on each read, and does not store them. Use them for recent activity, not history.
- Their `id` starts with `xfr_observed_`. It stays the same across reads, but `GET /v1/payments/transfers/{transferId}` does not find it.
- `status` is `confirmed` or `failed`, never `finalized`. `custodyWalletId` is `null`, and there is no `memo`.
- Reads with `includeObserved=true` have their own quota: 30 a minute per API key and 120 per organization. Past it they return `429 RATE_LIMITED`.

## Deduplication

- **Recorded transfers**: key them by `id` (`xfr_…`), which exists before any `signature` does. Once set, `signature` is unique across SDP's recorded transfers, so it also dedupes against another chain indexer.
- **Observed rows**: key them by `id`, not `signature`. One transaction can produce more than one row.

## Reconciling an order

For a customer's direct payment, the on-record keys are the receiving wallet and `destination`: observed rows carry no memo or reference. Give each order its own receiving wallet (see [Accept payments](/docs/payments/accept-overview#match-payments-to-orders)), then for each open order:

1. **Read** every page of the wallet's inbound activity, while `meta.hasMore` is `true`: `GET /v1/payments/transfers?custodyWalletId=<cwlt_…>&includeObserved=true&direction=inbound&pageSize=100`.
2. **Match** a transfer whose `token` and `amount` fit the order.
3. **Check finality**: observed rows stop at `confirmed`, so check with Solana RPC (`getSignatureStatuses`) that the `signature` is `finalized` before any irreversible step.
4. **Apply** the payment once: mark the order paid, store the `signature`, and start downstream work (email, shipment, ledger entry).
5. **Leave unmatched transfers** for the next pass, or flag them for review after a grace period.

A paid payment request needs none of this: SDP records it as an inbound `finalized` transfer.

## A polling worker

A worker that checks each open order's wallet on every tick:

**TypeScript**

```typescript title="reconcile-tick.ts"
// Observed reads have their own quota (30 a minute per API key), and each
// page is one read. Past it, the worker waits out Retry-After and reads the
// same page again, so no order is skipped.
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function reconcileTick(openOrders: Order[]) {
  orders: for (const order of openOrders) {
    let page = 1;
    while (true) {
      const url = new URL("https://api.solana.com/v1/payments/transfers");
      url.searchParams.set("custodyWalletId", order.custodyWalletId);
      url.searchParams.set("includeObserved", "true");
      url.searchParams.set("direction", "inbound");
      url.searchParams.set("pageSize", "100");
      url.searchParams.set("page", String(page));

      const res = await fetch(url, {
        headers: { Authorization: `Bearer ${process.env.SDP_API_KEY}` },
      });
      if (res.status === 429) {
        await sleep(Number(res.headers.get("Retry-After")) * 1000);
        continue; // the same page again
      }
      const { data, meta } = await res.json();

      for (const tr of data) {
        if (tr.status === "failed" || (await alreadyApplied(tr.id))) continue;
        if (tr.token !== order.mint || !sameAmount(tr.amount, order.amount)) continue;
        // Observed rows stop at "confirmed": check finality with your own RPC.
        if (!(await isFinalized(tr.signature))) continue;
        await markOrderPaid(order, tr); // stores tr.id and tr.signature
        continue orders; // paid: apply it once, then move to the next order
      }
      if (!meta.hasMore) break;
      page++;
    }
  }
}
```

Stop polling an order's wallet once the order is paid or expired, so the quota goes to open orders. `sameAmount` compares decimal strings by value: `"100"` and `"100.00"` are the same amount.

## Related

- [Verifying a payment](/docs/payments/accept-verification): statuses and finality.
- [Accept overview](/docs/payments/accept-overview): payment requests and shared addresses.
- [Payouts and disbursements](/docs/payments/send-payouts): the same list endpoint, used for outbound runs.
- [Payments API reference](/docs/reference/api/payments): every payments endpoint.
