Idempotency

Retry mutations safely with Idempotency-Key, especially when moving money.

Clients time out, connections drop and workers die mid-run. Send an Idempotency-Key header so a retry returns the first attempt's answer instead of doing the work twice.

Terminal
curl -X POST https://api.solana.com/v1/payments/transfers \
  -H "Authorization: Bearer sf_..." \
  -H "Idempotency-Key: 0192f8a4-6c1e-7d4b-8f3a-b7e52c8d9f0a" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

Where keys apply

Required. These routes move money. A request without a key returns 400 IDEMPOTENCY_KEY_REQUIRED, and nothing runs.

  • POST /v1/payments/transfers
  • POST /v1/payments/transfer-batches
  • POST /v1/payments/transfer-batches/{batchId}/retry
  • POST /v1/payments/recurring-payments
  • PATCH /v1/payments/recurring-payments/{id} (an edit to an active schedule signs and broadcasts a replacement subscription)
  • POST /v1/payments/recurring-payments/{id}/activate
  • POST /v1/payments/recurring-payments/{id}/cancel
  • POST /v1/payments/recurring-payments/{id}/collect
  • POST /v1/payments/recurring-payments/{id}/resume

Accepted. The key is optional, and honored when sent.

  • Wallets: POST /v1/wallets, POST /v1/wallets/initialize, POST /v1/wallets/signer-check, PATCH /v1/wallets/{walletId}, DELETE /v1/wallets
  • Subscription plans and subscriptions: POST /v1/payments/subscription-plans, PATCH /v1/payments/subscription-plans/{planId}, POST /v1/payments/subscriptions
  • Counterparties: POST, PATCH and DELETE on /v1/counterparties and on counterparty accounts
  • Compliance: POST /v1/compliance/address-screenings

Not used. POST /v1/payments/transfer-batches/estimate moves and stores nothing. The subscription prepare-* routes and subscription-plan prepare-create return an unsigned transaction bound to a fresh blockhash, which a stored replay would hand back after it expired. Neither reads the key.

Modules with their own key handling say so on their pages.

Choosing a key

  • A key is 1–255 printable ASCII characters. Anything else returns 400.
  • Generate one key per logical operation (a UUIDv4 or UUIDv7 works well), store it with the operation before you send it, and send the same key on every retry of that operation.
  • A new operation needs a new key, even when its request is identical to an earlier one.
  • Keys are scoped to your organization, project, HTTP method and route, and bound to the credential that sent them. The same key value on two different routes is two separate keys.

A money request, saved with its key before the first send and retried under that key:

send-transfer.ts
// Save the key with the operation first, so a restarted worker retries under it.
const payout = await db.payouts.create({ ...input, idempotencyKey: crypto.randomUUID() });

async function sendTransfer(payout: Payout): Promise<Response> {
  for (let attempt = 1; attempt <= 8; attempt++) {
    let response: Response;
    try {
      response = await fetch("https://api.solana.com/v1/payments/transfers", {
        method: "POST",
        headers: {
          Authorization: "Bearer sf_...",
          "Content-Type": "application/json",
          "Idempotency-Key": payout.idempotencyKey,
        },
        body: JSON.stringify(payout.request),
      });
    } catch {
      // The connection dropped: the transfer may have run, so retry under the same key.
      await backoff(attempt);
      continue;
    }
    if (!(await isRetryable(response))) return response;
    await backoff(attempt, response.headers.get("Retry-After"));
  }
  throw new Error(`Transfer ${payout.id} is still unanswered: retry later with the same key`);
}

async function isRetryable(response: Response): Promise<boolean> {
  // A money route answers a same-key retry after a 5xx from the record the first attempt wrote.
  if (response.status >= 500 || response.status === 429) return true;
  if (response.status !== 409) return false;
  const { error } = await response.clone().json();
  return error?.code === "IDEMPOTENCY_KEY_IN_FLIGHT";
}

function backoff(attempt: number, retryAfter?: string | null): Promise<void> {
  const ms = Math.max(Number(retryAfter ?? 0) * 1000, 250 * 2 ** attempt);
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Every retry above sends the same body and the same key. Don't change the request to "fix" a retry: a different request under the key is 422 IDEMPOTENCY_KEY_REUSED.

What a retry returns

Access is checked before the key on every request, so a retry passes exactly the checks a new request passes, and a request refused for access never claims the key.

SituationResponse
Same key, same request, original finishedThe original status and body, with the response header Idempotent-Replayed: true.
Same key, original still running409 IDEMPOTENCY_KEY_IN_FLIGHT with Retry-After. Wait, then retry with the same key.
Same key, original never recorded its outcome (it crashed)Until 15 minutes plus the route's lease have passed since the original was sent (about 16 minutes, or about 20 for transfer batches), 409 IDEMPOTENCY_KEY_IN_FLIGHT with Retry-After, as if it were still running. After that, transfers, transfer batches, recurring payments and POST /v1/wallets take over and answer from the record the original wrote. A batch whose original stopped before sending some recipients answers with those recipients failed: they were never sent, so send them again with POST /v1/payments/transfer-batches/{batchId}/retry. Every other route returns 409 IDEMPOTENCY_KEY_OUTCOME_UNKNOWN with no Retry-After, to this and every later retry with the key: read the resource with GET to see whether it ran, then send again with a new key.
Same key, different request or different credential422 IDEMPOTENCY_KEY_REUSED. Nothing runs.
Same key, but the credential has since lost access to the wallet or resource403. The stored response is not returned.
Original was refused with 400, 401, 403, 409 or 429 before it wrote anythingNothing was stored. Fix the cause and retry with the same key; the request runs again.
A transfer or transfer batch failed after writing its record (for example 400 TRANSACTION_FAILED, or 409 for a signer mismatch)Stored: the same error, with Idempotent-Replayed: true. After 24 hours the record answers with the same status and code. The key is spent: send the request again with a new key to try again.
A recurring-payment action failedAn action refused before it sent anything leaves the schedule as it was. If a cancel sent its transaction and confirming it then failed, SDP keeps the attempt and the schedule stays canceling until the attempt is recovered; poll GET /v1/payments/recurring-payments/{id}. A 400, 401, 403, 409 or 429 (such as TRANSACTION_FAILED) is not stored, and a 5xx is not replayed, so a same-key retry runs the action again. Any other 4xx, such as 422 SIGNING_REJECTED or 404, is stored and replayed with Idempotent-Replayed: true for 24 hours; send a new key to try again sooner.
A recurring-payment action meets another key's unfinished attempt on the scheduleCancel and collect return 409 (already processing), not stored; read the schedule with GET. An interrupted collect finishes on its own from the transaction SDP saved before sending it. Resume sends nothing on chain and commits in one step, so it is never left half-done. An interrupted activate or cancel is resumed in the background from the last step SDP recorded; SDP first reads each step it may already have sent from the chain, and a step is not sent again when the chain shows it landed. An interrupted PATCH resumes only when that PATCH is retried with its key (see below). A PATCH may finish that attempt and then applies its own change.
A keyed recurring-payment cancel, resume or PATCH changes nothing (such as canceling a canceled schedule)It still records its attempt, so a same-key retry, even after 24 hours, is answered from it and never runs over a later change.
Original returned a 5xxStored and replayed: use a new key to try again. The routes that move money are the exception: a same-key retry runs again and answers from the record the first attempt wrote, so it never sends twice. That answer also carries Idempotent-Replayed: true.

"Same request" means the same path, query parameters, body and credential. Recipient order in a transfer batch, and "1.0" versus "1" for an amount, do not change the request.

Keys are scoped to the credential that sent them. After API key rotation, the replacement key is a new credential: for 24 hours, a retry with the old Idempotency-Key gets 422. Once the old record expires, that retry can run again and move money. A new Idempotency-Key can do the same. Before resending after rotation, read the resource with GET (for example, list transfers or read the recurring payment) to learn whether the original ran.

An organization that has been deleted or has lost production access is refused before the key is read: 401 or 403, even for a retry of a request that completed.

Replays are snapshots

A replay returns the response as it was first sent: a transfer stored as processing replays as processing. To see current state, read the resource with GET. Never re-send a request to learn its outcome.

An answer from the original record (a same-key retry after a 5xx or the 24-hour window) reflects its current state. A recurring-payment lifecycle action may still be mid-transition; poll GET /v1/payments/recurring-payments/{id} until it settles.

An update left updating does not finish in the background. Retry its PATCH with the same key. Until the interrupted attempt is 15 minutes old, the retry gets 409 IDEMPOTENCY_KEY_IN_FLIGHT with Retry-After. After that, it resumes from the last recorded step without resending one the chain shows landed.

Retention

Keys are kept for 24 hours after the original request. After that, a same-key retry on most accepted routes starts a new request. On the routes that move money, the record the first attempt wrote still holds its key, so a late retry is answered from that record, or refused if the request differs; a failed transfer answers with the status and code it failed with.

Creating wallets

One POST /v1/wallets key maps to one recorded wallet. Same-key retries return it, even after 24 hours; a different request under that key returns 422. Privy and Coinbase CDP also receive a provider request ID derived from the key, so they dedupe creation. A provider failure is stored as 503 for 24 hours. A new key is a new provider request.

An ambiguous 503 may mean the provider created a wallet before SDP received the result. Other providers do not dedupe on the request ID. If SDP wrote no wallet row, a same-key retry after 24 hours can create another wallet. Before retrying with any key, check GET /v1/wallets and, if needed, the provider account.

Provider request IDs and recorded-wallet lookups include the credential. After API key rotation and expiry of the 24-hour record, the old Idempotency-Key under the new API key becomes a new provider request and can create a second wallet. List wallets and check the provider account first.

The dashboard and the Postman collection

The dashboard sends a key on every request to the routes that require one, and keeps it until it gets a definitive answer, so a retried click never moves money twice.

The Postman collection saves one key per required operation in idempotencyKey.<operationId>. It creates the key only when the variable is empty and clears it only after a final-payment 2xx, a replayed refusal, or a 422 other than IDEMPOTENCY_KEY_REUSED.

While a transfer, batch, or schedule remains pending or processing, Postman keeps the key. Poll with GET instead of resending, then clear the variable when final. It also keeps the key after timeouts, 5xx, 409, 429, and 400, 401, or 403 refusals; the original request may still have run.

422 IDEMPOTENCY_KEY_REUSED means the request differs from the original. Restore that request to retry. To start a new operation, first check with GET that the original settled, then clear the variable yourself.

Recommendations

  • Send a key on every mutation that accepts one, not only where it is required.
  • Retry timeouts, connection errors and 409 IDEMPOTENCY_KEY_IN_FLIGHT with backoff, reusing the key.
  • Treat 409 IDEMPOTENCY_KEY_OUTCOME_UNKNOWN as final for that key: check with GET, then use a new key.
  • After a 5xx, retry with the same key on the routes that move money; elsewhere, use a new key.
  • Outside a key rotation, treat 422 IDEMPOTENCY_KEY_REUSED as a bug in your client: the key was attached to two different operations.
  • Persist the returned resource ID before moving to the next operation, so a restarted worker can resume without guessing.
Is this page helpful?