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:

Terminal
curl "https://api.solana.com/v1/payments/transfers?direction=inbound&pageSize=50" \
  -H "Authorization: Bearer sf_..."
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

ParameterTypeNotes
directioninbound | outboundInbound is a payment into one of your wallets.
statusstringOne or more of processing, confirmed, finalized, failed, comma-separated.
custodyWalletIdstringOne wallet's SDP id (cwlt_… from GET /v1/wallets).
includeObservedbooleanDefault false. With custodyWalletId, adds the wallet's recent on-chain activity; see Observed transfers.
tokenstringA well-known symbol (SOL, USDC) or a mint address. A well-known token matches either form.
counterpartyIdstringTransfers tied to one counterparty (cpty_…).
searchstring3 or more characters, matched against ids, signatures, addresses, memo and counterparty names.
from, toISO 8601 datetimeWindow on createdAt. Include a time zone, such as 2026-05-14T00:00:00Z.
sortBy, sortDirectionstringDefault createdAt, desc.
page, pageSizeintegerDefault 1 and 20. pageSize is at most 100.

List 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), 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:

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.