Basic payment
Send one token transfer from a custody wallet to a Solana address, from the dashboard or the API.
A basic payment moves tokens from a custody wallet you control to a Solana wallet address. Send it from Payments in the dashboard or with POST /v1/payments/transfers.
Pass sourceCustodyWalletId as the wallet's SDP id (cwlt_… from GET /v1/wallets). Pass destination as the recipient's wallet (owner) address, not a token account: for an SPL token, SDP derives the recipient's associated token account and creates it if it is missing.
Prerequisites
- A custody wallet that holds the tokens you want to send, with a custody signer configured so SDP can sign for it.
- In the dashboard: a role that can send payments, and a contact to pay. Add contacts under Payments > Contacts.
- With the API: a key holding the
transfers.executegrant whose wallet access includes the source wallet. SDP checks the grant at the route and the key's access to the wallet again when it signs.
How it works
Set up the transfer
Specify the source, destination, token, and amount.
Go to Payments in the sidebar. The overview shows your Available balance, recent Activity, and the Move money actions.

Click Pay. The Single tab opens on the Details step, "Who are you paying?". Fill in:
- Contact: the person or business you are paying.
- Destination: one of the contact's Solana addresses. Click Add a Solana address to add one here.
- Source wallet: the wallet the payment leaves from.
- Amount: in whole-token units, such as
0.5, with the token picked beside it. - Memo (optional): a note SDP stores with the transfer. It is not written on chain.


Build the request body:
const body = {
sourceCustodyWalletId: "cwlt_123", // `id` from GET /v1/wallets
destination: "7xKXz...9fGh", // recipient wallet (owner) address
token: "9aBCd...4eEf", // mint address, SOL, or a symbol such as USDC
amount: "100.00", // whole-token units, as a string
memo: "Payment for invoice #1234",
};Execute the transfer
Submit the transfer. SDP signs with the source custody wallet and submits the transaction.
Click Continue to review, check the Review transfer summary, then click Send transfer.
The page waits while Solana finalizes the transfer, then shows the outcome: Transfer sent with its status and signature, Transfer submitted while it is still settling, or Transfer failed with the reason.
curl -X POST https://api.solana.com/v1/payments/transfers \
-H "Authorization: Bearer sf_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payment-invoice-1234" \
-d '{
"sourceCustodyWalletId": "cwlt_123",
"destination": "7xKXz...9fGh",
"token": "9aBCd...4eEf",
"amount": "100.00",
"memo": "Payment for invoice #1234"
}'const response = await fetch(
"https://api.solana.com/v1/payments/transfers",
{
method: "POST",
headers: {
"Authorization": "Bearer sf_...",
"Content-Type": "application/json",
"Idempotency-Key": "payment-invoice-1234",
},
body: JSON.stringify({
sourceCustodyWalletId: "cwlt_123",
destination: "7xKXz...9fGh",
token: "9aBCd...4eEf",
amount: "100.00",
memo: "Payment for invoice #1234",
}),
}
);
const { data } = await response.json();
// data.transfer is the transfer record: id, status, signatureHttpRequest 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", "payment-invoice-1234")
.POST(HttpRequest.BodyPublishers.ofString("""
{
"sourceCustodyWalletId": "cwlt_123",
"destination": "7xKXz...9fGh",
"token": "9aBCd...4eEf",
"amount": "100.00",
"memo": "Payment for invoice #1234"
}"""))
.build();A successful request waits for finality and returns the transfer at finalized. A processing answer means it is still settling: don't send it again, read it instead (step 3).
Verify the transfer
Check that the transfer settled.
Click View on explorer to open the transaction on Solana Explorer. The transfer also appears under Payments > Transactions with its status, such as Finalized.
Click a row to open the transaction's record, which has its own View on explorer link.
// data.transfer.status: "finalized" once Solana finalizes the transaction
// data.transfer.signature: the transaction signatureIf the response said processing, read the transfer until it settles:
curl 'https://api.solana.com/v1/payments/transfers/xfr_example' \
-H "Authorization: Bearer $SDP_API_KEY"Pay a counterparty
When destination is a saved counterparty's Solana address, pass that account's id (cpacct_…) as counterpartyAccountId. SDP ties the transfer to the counterparty, as it does for a transfer batch recipient: the transfer carries counterpartyId, and GET /v1/payments/transfers?counterpartyId= lists it. The dashboard's Pay flow does this for you.
The account must be an active crypto_wallet account in the same project, and
destination must be its address. An unknown or inactive account returns 404,
any other mismatch returns 400, and no transfer is created.
Only fields documented for your deployment are accepted. Unknown fields return
400 during validation, before looking up a wallet or transfer.
SOL and the rent-exempt minimum
A Solana wallet holds either nothing or at least the rent-exempt minimum (about 0.00089 SOL). SDP refuses a native SOL transfer with 409 AMOUNT_BELOW_RENT_MINIMUM if it would fund an empty address below that minimum or leave the source with a nonzero balance below it. Nothing is written or sent.
details.recipientIndexes ([0]) or details.sourceRemainder identifies the problem; details.minimumAmount gives the minimum. Fund the address, send at least the minimum, or send the source wallet's whole balance. The 409 is not stored under the Idempotency-Key, so the same request can run after the balance changes. Token transfers are unaffected: SDP sponsors rent for a missing token account.
Insufficient funds
When the source wallet holds less SOL, or less of the token in its token account, than the amount, the transfer fails with 400 TRANSACTION_FAILED and nothing is sent. The transfer is recorded failed, and its error says the wallet does not have enough funds. Fund the wallet, then send the payment again with a new Idempotency-Key: the failed key replays the same error.
A source wallet that has never held the token has no token account for it, so the request is refused earlier with 400 BAD_REQUEST ("Source wallet has no token account for this mint").
Deduplication
POST /v1/payments/transfers requires an Idempotency-Key; without one it returns 400 IDEMPOTENCY_KEY_REQUIRED. Use one stable key per payment and reuse it for retries. A same-key retry returns the original transfer. A changed request, wallet, or credential returns 422 IDEMPOTENCY_KEY_REUSED; an in-flight request returns 409 IDEMPOTENCY_KEY_IN_FLIGHT with Retry-After.
A recorded failure (such as 400 TRANSACTION_FAILED, or 422 CUSTODY_POLICY_REFUSED when the wallet's policy at its custody provider refused to sign) replays the same error; use a new key to try again. Replays are snapshots, so read the transfer with GET for its current status. See Idempotency for the full contract.
Persist the returned Transfer.id as your local primary key. Once populated, the on-chain signature is also unique across SDP transfers and can deduplicate records across SDP and another chain indexer.
See Payouts and disbursements for the same pattern applied to a run of transfers.
Related
- Payment with memo: carry an order reference in
memo. - Payouts and disbursements: pay many recipients in one batch.
- Verifying a payment: transfer statuses and finality.