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 "https://api.solana.com/v1/payments/transfers?direction=inbound&pageSize=50" \
-H "Authorization: Bearer sf_..."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. |
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 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
idstarts withxfr_observed_. It stays the same across reads, butGET /v1/payments/transfers/{transferId}does not find it. statusisconfirmedorfailed, neverfinalized.custodyWalletIdisnull, and there is nomemo.- Reads with
includeObserved=truehave their own quota: 30 a minute per API key and 120 per organization. Past it they return429 RATE_LIMITED.
Deduplication
- Recorded transfers: key them by
id(xfr_…), which exists before anysignaturedoes. Once set,signatureis unique across SDP's recorded transfers, so it also dedupes against another chain indexer. - Observed rows: key them by
id, notsignature. 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:
- Read every page of the wallet's inbound activity, while
meta.hasMoreistrue:GET /v1/payments/transfers?custodyWalletId=<cwlt_…>&includeObserved=true&direction=inbound&pageSize=100. - Match a transfer whose
tokenandamountfit the order. - Check finality: observed rows stop at
confirmed, so check with Solana RPC (getSignatureStatuses) that thesignatureisfinalizedbefore any irreversible step. - Apply the payment once: mark the order paid, store the
signature, and start downstream work (email, shipment, ledger entry). - 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:
// 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: statuses and finality.
- Accept overview: payment requests and shared addresses.
- Payouts and disbursements: the same list endpoint, used for outbound runs.
- Payments API reference: every payments endpoint.