Payouts and disbursements
Run batched outbound transfers, deduplicate retries, and reconcile a payout batch with SDP.
A payout run is a batch of outbound transfers — marketplace seller payouts, payroll, affiliate disbursements, royalty distributions. SDP does not expose a "batch" object; you call POST /v1/payments/transfers once per recipient, tag each transfer with a shared batch identifier, and use GET /v1/payments/transfers?direction=outbound to reconcile the run.
This page covers the recommended pattern: how to structure the loop, how to deduplicate on retries, and how to confirm every recipient was paid.
Designing a payout run
A typical pattern:
- Resolve recipients in your application — list (or stream) the rows to be paid, with destination address and amount per row.
- Pick a batch identifier you control (
payout_2026-05-14_marketplace_sellers). - Iterate — for each recipient, call
POST /v1/payments/transferswith the destination, amount, token, and a memo that encodes the batch ID and a per-row identifier (payout_2026-05-14_marketplace_sellers/row_4837). - Capture the returned
Transfer.idandTransfer.status. Persist the SDP transfer ID alongside the recipient row so you can re-poll status later. - Reconcile — once the batch is fully submitted, run
GET /v1/payments/transfers?direction=outbound&from=…&to=…and confirm every row in your application has a matchingfinalizedtransfer.
Send transfers sequentially or in modest concurrency (4–8 in flight); high concurrency increases the chance of priority-fee contention and blockhash expiry without much throughput gain.
Dedup and retries
Two layers protect you against double-spends if a worker dies mid-run:
- Application-level keys — persist the SDP transfer ID for each recipient row before moving to the next row. On retry, skip rows that already have an associated
Transfer.id. This is the dedup mechanism you should rely on most. - Transfer-record uniqueness — the on-chain
signaturecolumn isUNIQUEacross the SDPpayment_transferstable. Each transfer request inserts its own row first and the signature is recorded once the transaction is built/submitted, so a duplicate signature is blocked at the DB layer (the second write errors out) rather than silently merged. Use this as a backstop integrity check, not as your primary dedup — the application-level keys above are what you actually rely on.
If your retry path replays a transfer with identical inputs but a fresh request, SDP builds a new transaction with a new recent blockhash, which means a new signature. The application-level key — persisted recipient → transfer ID mapping — is what prevents the recipient from being paid twice in that case.
curl -X POST https://api.solana.com/v1/payments/transfers \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"source": "wal_example",
"destination": "7xKXz...9fGh",
"token": "9aBCd...4eEf",
"amount": "150.00",
"memo": "payout_2026-05-14_marketplace_sellers/row_4837"
}'async function payoutRecipient(row: PayoutRow): Promise<string> {
if (row.transferId) return row.transferId; // already submitted
const response = await fetch(
"https://api.solana.com/v1/payments/transfers",
{
method: "POST",
headers: {
"Authorization": "Bearer sk_test_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
source: row.sourceWallet,
destination: row.destination,
token: row.token,
amount: row.amount,
memo: `${row.batchId}/row_${row.id}`,
}),
}
);
const { data } = await response.json();
// The transfer record is under data.transfer (standard SDP success envelope).
await row.persistTransferId(data.transfer.id); // persist BEFORE returning
return data.transfer.id;
}HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.solana.com/v1/payments/transfers"))
.header("Authorization", "Bearer sk_test_...")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("""
{
"source": "%s",
"destination": "%s",
"token": "%s",
"amount": "%s",
"memo": "%s/row_%s"
}""".formatted(sourceWallet, destination, token, amount, batchId, rowId)))
.build();Reconciling a run
After the loop finishes, list all outbound transfers in the run's time window and tally against your recipient table:
curl "https://api.solana.com/v1/payments/transfers?direction=outbound&from=2026-05-14T00:00:00Z&to=2026-05-14T23:59:59Z&pageSize=100" \
-H "Authorization: Bearer sk_test_..."async function* listOutboundTransfers(from: string, to: string) {
let page = 1;
while (true) {
const url = new URL("https://api.solana.com/v1/payments/transfers");
url.searchParams.set("direction", "outbound");
url.searchParams.set("from", from);
url.searchParams.set("to", to);
url.searchParams.set("page", String(page));
url.searchParams.set("pageSize", "100");
const res = await fetch(url, {
headers: { Authorization: "Bearer sk_test_..." },
});
const { data, meta } = await res.json();
yield* data;
if (!meta.hasMore) return;
page += 1;
}
}Filter the returned transfers on memo.startsWith(batchId) (or whatever convention you chose) to scope to the batch. Cross-reference each recipient row by SDP Transfer.id.
Failure modes
Per-transfer outcomes you should expect to handle:
failed— the transaction was built and submitted but the network rejected it (insufficient funds, frozen account, expired blockhash). Theerrorfield on the transfer record explains what reached the chain. The recipient was not paid; you can retry by submitting a new transfer.pendingpast the expected confirmation window — usually a backend or RPC hiccup. Re-poll withGET /v1/payments/transfers/{id}rather than re-submitting; you only want a new transfer ifstatussettles tofailed.- Mid-run worker crash — application-level keys (persisted recipient → transfer ID) are how you resume safely. Skip rows that already have a
Transfer.idand resume the loop. - Source wallet runs out of SOL for fees — submissions start failing with fee-related errors. If SDP is configured to sponsor fees this won't happen; otherwise top up the source wallet's SOL balance before continuing.
Related
- Basic payment — the underlying transfer endpoint.
- Payment with memo — the memo field used above to tag a batch.
- Indexing and reconciliation — the same list endpoint, used inbound.
- Wallet policies — destination allowlist and daily-limit guardrails for payout wallets.