Payouts and disbursements
Pay many recipients in one batch, retry without paying anyone twice, and reconcile a payout run with SDP.
A payout run pays many recipients at once: marketplace sellers, payroll, affiliates, royalties. Use POST /v1/payments/transfer-batches when every recipient is a saved counterparty account. To pay arbitrary Solana addresses, call POST /v1/payments/transfers once per recipient and reconcile the transfer ids it returns.
Native transfer batches
One request creates a durable batch, chunks its recipients into Solana transactions, and returns the batch, recipient, and transfer records:
curl -X POST https://api.solana.com/v1/payments/transfer-batches \
-H "Authorization: Bearer sf_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f0c9e7a-5b1d-4c2e-9a8f-6d4b2e1c0a97" \
-d '{
"externalId": "payroll-2026-05",
"sourceCustodyWalletId": "cwlt_123",
"token": "9aBCd...4eEf",
"recipients": [
{
"externalId": "row_4837",
"counterpartyId": "cpty_4837",
"counterpartyAccountId": "cpacct_4837",
"amount": "150.00"
}
]
}'Each recipient names a counterparty account (cpacct_…) that is an active crypto_wallet account in the project. counterpartyId is optional; if you pass it, it must be that account's counterparty. An unknown, archived or mismatched account returns 404. An account whose address changes while the batch is being prepared returns 409 TRANSFER_BATCH_DESTINATION_CHANGED naming it in error.details.counterpartyAccountIds; nothing is sent, so send the request again. A batch takes up to 500 recipients.
The two ids do different jobs:
Idempotency-Key(required) identifies this one request. Reuse it to retry that exact request after a timeout; recipient order does not change the request. Any unique value works, such as a UUID.externalId(required: a batch without one returns400) identifies the payout run. Derive it from your own data, such as the pay period, so every attempt at the run carries the same value. See One batch per payout run.
Use GET /v1/payments/transfer-batches/{batchId} to inspect recipient and chunk outcomes.
Batch execution does not add a priority fee. options.priorityFee is accepted for
compatibility and ignored, including auto, low, medium, and high. The
estimate reports priorityFeeLamports: "0".
One batch per payout run
The batch externalId names the payout run, and it is unique per project and source wallet. Whatever Idempotency-Key it carries, a batch with an externalId already used on that wallet never pays anyone again:
- The same request, from the same API key, returns the existing batch as it stands. A worker that restarts with a fresh key gets the original run back.
- Any other request (different recipients, amounts or options, or another API key) returns
409 TRANSFER_BATCH_RUN_EXISTSwith the existing batch inerror.details.batchId. A replay only goes to the API key that sent the run, so after a key rotation the new key gets the 409: read the batch by its id instead of resending.
Derive the externalId from data you already have, so a worker that crashes and restarts computes the same value without storing anything: payroll-2026-05, invoice-run-8812. Never generate it at random: a random value is new on every attempt and protects nothing. New recipients go in a new run with a new externalId, never in a renamed copy of a run that was already sent.
// Safe to run again after a crash: the run's id comes from the period, not from storage.
const externalId = `payroll-${period}`;
const response = await fetch("https://api.solana.com/v1/payments/transfer-batches", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SDP_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ externalId, sourceCustodyWalletId, token, recipients }),
});
const body = await response.json();
// 200: this run's batch, new or replayed. 409 TRANSFER_BATCH_RUN_EXISTS: the run was
// already sent with a different body; read error.details.batchId instead of resending.
// A 5xx may come after the run was sent: its outcome is unknown, so run again with the
// same externalId, which returns the batch if it exists. A 4xx (a 409
// AMOUNT_BELOW_RENT_MINIMUM, say) is refused before anything is sent: report it.
let batchId: string;
if (response.ok) {
batchId = body.data.batch.id;
} else if (body.error.code === "TRANSFER_BATCH_RUN_EXISTS") {
batchId = body.error.details.batchId;
} else if (response.status >= 500) {
throw new Error(`Payout run ${externalId} has an unknown outcome: run it again`);
} else {
throw new Error(`Payout run ${externalId} was refused: ${body.error.code}`);
}Recovering from a partial failure
A batch with failed recipients paid some recipients and not others. A failed recipient was not paid: its transaction never reached the network, or the network rejected it. Its error says why; when the source wallet could not cover the amount, it reads "The wallet used for this payment does not have enough funds", so fund the wallet before you retry. To finish the run, retry the batch:
curl -X POST https://api.solana.com/v1/payments/transfer-batches/{batchId}/retry \
-H "Authorization: Bearer sf_..." \
-H "Idempotency-Key: 8b2e4f1c-7d3a-4e9b-a6c5-1f0d9e8c7b6a"The retry sends only the batch's failed recipients, to the addresses the batch recorded for them, from the same wallet with the batch's options, and returns the batch with those recipients processing again. Recipients still pending or processing may still land, so they are never sent again. Each resent recipient keeps its row in the batch and links to its new transfer.
- Use a new
Idempotency-Keyfor each retry you mean to send. The same key never sends again, including after a timeout or a server error, so resend a lost answer under the same key. It returns the saved response while that is stored, which can still show recipientsprocessing: read the batch withGET /v1/payments/transfer-batches/{batchId}for its current state. If recipients fail again later, a retry under that old key sends nothing. - Any API key that may send from the batch's wallet can retry it, not only the one that created it.
- A batch with nothing
failedreturns409 TRANSFER_BATCH_NOTHING_TO_RETRY, and a retry that loses to a concurrent one returns409 TRANSFER_BATCH_RETRY_CLAIMED. Nothing is sent. While a batch isprocessing, a recipient its request never sent becomesfailedonly once that request can no longer send it, so retry after the batch settles. - A failed recipient whose counterparty account changed its address since the batch was sent is not paid either address: the retry returns
409 TRANSFER_BATCH_DESTINATION_CHANGEDnaming it inerror.details.recipientIds, and nothing is sent. Send that recipient in a new batch. - A native SOL recipient paid less than the rent-exempt minimum at an address with no account fails again for the same reason, so the retry is refused with
409 AMOUNT_BELOW_RENT_MINIMUMand that recipient staysfailed. Send it in a new batch with a larger amount. - Only recipients that are still
failedare ever sent, so a retry under a new key never pays anyone twice.
Never resend the whole batch under a new Idempotency-Key to finish it: that pays the recipients who already succeeded again.
The SOL rent-exempt minimum
A Solana wallet holds either nothing or at least the rent-exempt minimum (about 0.00089 SOL), and the network refuses a native SOL transfer that would break that, along with every recipient in the same transaction. SDP checks before it sends:
- A recipient whose address has no account must receive at least the minimum.
- The source wallet must end with nothing or at least the minimum. Chunks land in any order, so a batch that sends the wallet's whole balance also needs every chunk to move at least the minimum.
POST /v1/payments/transfer-batches/estimate lists what would fail in estimate.warnings (AMOUNT_BELOW_RENT_MINIMUM per recipient, SOURCE_BELOW_RENT_MINIMUM for the source). POST /v1/payments/transfer-batches refuses the batch with 409 AMOUNT_BELOW_RENT_MINIMUM, naming details.recipientIndexes and details.sourceRemainder, before anything is written or sent. The 409 is not stored under the Idempotency-Key, so the same request runs again once it would clear. Token batches are unaffected: SDP funds a missing token account's rent (tokenAccountRentLamports).
Token account rent per transaction
SDP funds new recipient token accounts up to a per-transaction rent limit. A chunk includes only as many new accounts as that limit covers; existing accounts are packed by count and size. The estimate's transactionCount is the number of transactions create submits. If one account exceeds the limit, create is refused with 409 SPONSORED_RENT_OVER_CAP (details.recipientIndex) before anything is written or sent. Create that token account first.
Paying recipients one transfer at a time
To pay destinations that are not saved counterparty accounts, send one transfer per recipient instead of a batch:
- Resolve recipients in your application: the rows to pay, each with a destination address and an amount.
- Pick a run id you control, such as
payout_2026-05-14_marketplace_sellers. - Send each recipient a
POST /v1/payments/transferswith the destination, amount, token, and a memo that holds the run id and the row (payout_2026-05-14_marketplace_sellers/row_4837). - Store the returned transfer
idandstatuswith the recipient row, so you can read the status again later. - Reconcile once every row is sent: list
GET /v1/payments/transfers?direction=outbound&from=…&to=…and check that every row 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.
To tie a single transfer to a saved counterparty account, pass counterpartyAccountId with it. See Pay a counterparty.
Dedup and retries for single transfers
Two layers keep a worker that dies mid-run from paying anyone twice:
- Idempotency keys:
POST /v1/payments/transfersrequires anIdempotency-Key. Give each recipient row one stable key and reuse it for retries. A retry with the same key and request returns the original transfer. Reusing the key for a different request returns422 IDEMPOTENCY_KEY_REUSED, and a retry while the original is still running returns409 IDEMPOTENCY_KEY_IN_FLIGHT. - Application mapping: store each row's transfer
idbefore you move to the next row. On restart, retry with the row's same key, or skip rows that already have a transferid.
A new key is a new payment, even with an identical body. The signature is unique across SDP transfers once set: use it to cross-check another indexer, not to retry.
curl -X POST https://api.solana.com/v1/payments/transfers \
-H "Authorization: Bearer sf_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payout-row-4837" \
-d '{
"sourceCustodyWalletId": "cwlt_123",
"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 sf_...",
"Content-Type": "application/json",
"Idempotency-Key": row.idempotencyKey,
},
body: JSON.stringify({
sourceCustodyWalletId: row.sourceCustodyWalletId,
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 sf_...")
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString("""
{
"sourceCustodyWalletId": "%s",
"destination": "%s",
"token": "%s",
"amount": "%s",
"memo": "%s/row_%s"
}""".formatted(sourceCustodyWalletId, 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 sf_..."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 sf_..." },
});
const { data, meta } = await res.json();
yield* data;
if (!meta.hasMore) return;
page += 1;
}
}To scope the list to one run, add search=<run id>, which matches part of the memo, or filter on memo.startsWith(runId) yourself. Match each recipient row by its transfer id.
Failure modes
Handle these outcomes for each transfer:
failed: the transfer never reached the network, or the network rejected it (insufficient funds, frozen account, expired blockhash).errorsays why. The recipient was not paid; to retry, send a new transfer with a newIdempotency-Key.processingfor longer than you expect: the request stopped waiting before the chain settled the transfer. Read it withGET /v1/payments/transfers/{transferId}instead of sending it again, and send a new transfer only oncestatusisfailed.- Mid-run worker crash: resume with each row's original
Idempotency-Key, and store the returned transferidbefore you move on. - Fee sponsor unavailable: SDP pays the network fee on every transfer it signs, so the source wallet never needs SOL for fees. When the sponsor can't pay, SDP refuses the transfer without charging the source wallet.
Related
- Basic payment: the transfer endpoint.
- Payment with memo: the memo field that tags a run.
- Indexing and reconciliation: the same list endpoint, used for inbound payments.
- Manage API keys: limit a payout key to the actions payouts need.