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. Use POST /v1/payments/transfer-batches when recipients are registered counterparty accounts. For arbitrary Solana addresses, call POST /v1/payments/transfers once per recipient and reconcile the returned transfer IDs.

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": "cp_4837",
        "counterpartyAccountId": "cpa_4837",
        "amount": "150.00"
      }
    ]
  }'

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) 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.

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_EXISTS with the existing batch in error.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.

payroll-worker.ts
// 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}`);
}

externalId is required, and a batch without one is refused with 400. It only protects you if every attempt at the run sends the same value, so derive it rather than generating it.

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. 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-Key for 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 recipients processing: read the batch with GET /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 failed returns 409 TRANSFER_BATCH_NOTHING_TO_RETRY, and a retry that loses to a concurrent one returns 409 TRANSFER_BATCH_RETRY_CLAIMED. Nothing is sent. While a batch is processing, a recipient its request never sent becomes failed only 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_CHANGED naming it in error.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_MINIMUM and that recipient stays failed. Send it in a new batch with a larger amount.
  • Only recipients that are still failed are 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

For destinations that are not registered counterparty accounts, a run can send one transfer per recipient instead of a batch. A typical pattern:

  1. Resolve recipients in your application — list (or stream) the rows to be paid, with destination address and amount per row.
  2. Pick a batch identifier you control (payout_2026-05-14_marketplace_sellers).
  3. Iterate — for each recipient, call POST /v1/payments/transfers with 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).
  4. Capture the returned Transfer.id and Transfer.status. Persist the SDP transfer ID alongside the recipient row so you can re-poll status later.
  5. 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 matching finalized transfer.

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 registered counterparty account, pass counterpartyAccountId with it. See Pay a counterparty.

Dedup and retries for single transfers

Two layers protect a run of single transfers against double-spends if a worker dies mid-run:

  1. Idempotency keys — POST /v1/payments/transfers requires an Idempotency-Key. Generate one stable key per recipient row and reuse it for retries. A retry with the same key and request returns the original transfer; reusing the key for a different request returns 422 IDEMPOTENCY_KEY_REUSED, and a retry while the original is still running returns 409 IDEMPOTENCY_KEY_IN_FLIGHT.
  2. Application mapping — persist the SDP transfer ID for each recipient row before moving to the next row. On restart, replay with the same idempotency key or skip rows that already have an associated Transfer.id.

The on-chain signature is unique across SDP transfers once populated. Use it as a cross-indexer integrity check, not as the retry mechanism.

Using a fresh idempotency key creates a new transfer intent, even if the payload is otherwise identical.

Terminal
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"
  }'

Reconciling a run

After the loop finishes, list all outbound transfers in the run's time window and tally against your recipient table:

Terminal
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_..."

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). The error field on the transfer record explains what reached the chain. The recipient was not paid; you can retry by submitting a new transfer.
  • pending past the expected confirmation window — usually a backend or RPC hiccup. Re-poll with GET /v1/payments/transfers/{id} rather than re-submitting; you only want a new transfer if status settles to failed.
  • Mid-run worker crash — resume with each row's original Idempotency-Key; persist the returned Transfer.id before advancing.
  • 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.
Is this page helpful?