{
  "info": {
    "name": "Solana Developer Platform Public API",
    "description": "Public Postman collection generated from the SDP OpenAPI contract. Internal-only endpoint families are excluded.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{sdpApiKey}}",
        "type": "string"
      }
    ]
  },
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "const header = pm.request.headers.find((h) => h.key.toLowerCase() === \"idempotency-key\");",
          "const saved = header && /^\\{\\{(idempotencyKey\\.[^}]+)\\}\\}$/.exec(header.value);",
          "const variable = saved && saved[1];",
          "if (variable && !pm.collectionVariables.get(variable)) {",
          "  pm.collectionVariables.set(variable, pm.variables.replaceIn(\"{{$guid}}\"));",
          "}",
          "if (variable) pm.variables.set(\"idempotencyKeySent\", pm.collectionVariables.get(variable));"
        ]
      }
    },
    {
      "listen": "test",
      "script": {
        "type": "text/javascript",
        "exec": [
          "const header = pm.request.headers.find((h) => h.key.toLowerCase() === \"idempotency-key\");",
          "const saved = header && /^\\{\\{(idempotencyKey\\.[^}]+)\\}\\}$/.exec(header.value);",
          "const variable = saved && saved[1];",
          "const sent = pm.variables.get(\"idempotencyKeySent\");",
          "if (variable && sent && pm.collectionVariables.get(variable) === sent) {",
          "  const status = pm.response.code;",
          "  const replayed = pm.response.headers.get(\"Idempotent-Replayed\") === \"true\";",
          "  let body;",
          "  try { body = pm.response.json(); } catch (e) {}",
          "  const code = body && body.error ? body.error.code : undefined;",
          "  const rejected = status === 422 && code !== \"IDEMPOTENCY_KEY_REUSED\";",
          "  const data = body && typeof body.data === 'object' ? body.data : null;",
          "  const moving = new Set([\"pending\",\"processing\",\"awaiting_payment\",\"settling\",\"activating\",\"canceling\",\"updating\",\"submitted\"]);",
          "  const inFlight = data !== null && [data.transfer, data.batch, data.recurringPayment, data.collectionAttempt]",
          "    .some((record) => record && moving.has(record.status));",
          "  // A 2xx whose body is unreadable or names no data says nothing final: keep the key.",
          "  const settled = status >= 200 && status < 300 ? data !== null && !inFlight : replayed;",
          "  if (settled || rejected) {",
          "    pm.collectionVariables.unset(variable);",
          "  }",
          "}"
        ]
      }
    }
  ],
  "variable": [
    {
      "key": "baseUrl",
      "value": "https://api.solana.com",
      "type": "string"
    },
    {
      "key": "sdpApiKey",
      "value": "sf_your_api_key",
      "type": "string"
    }
  ],
  "item": [
    {
      "name": "Health",
      "item": [
        {
          "name": "Health check",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/health",
            "description": "Returns service health and build metadata."
          }
        },
        {
          "name": "Readiness check",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/health/ready",
            "description": "Performs readiness checks for downstream dependencies."
          }
        }
      ]
    },
    {
      "name": "Wallets",
      "item": [
        {
          "name": "List wallets",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/wallets",
            "description": "Lists active wallets under active Config and Connection owners, oldest first. Provider narrows the list to one custody provider."
          }
        },
        {
          "name": "Create wallet",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/wallets",
            "description": "Provisions a new wallet under exactly one provider account: the project's Managed config for `provider`, or the Custody Connection named by `connectionId`. A body naming both, or neither, is rejected with `400`.\n\nWith an `Idempotency-Key`, SDP never records a second wallet for one key: a retry returns the wallet an earlier attempt recorded, even after the 24-hour key window. A provider that accepts a request id also receives one derived from the key.\n\nOn a provider that dedupes on the request id, a `5xx` is not stored: a same-key retry runs again and gets back the wallet the first attempt made, even when SDP failed to record it, for as long as the provider keeps the request id (permanently, or for 24 hours; the Idempotency guide lists each provider). On any other provider a failure is stored as a `503`, and a new key is a new provider request. A `503` can mean the provider created the wallet before the error. With a provider that accepts no request id, a failure that left no SDP wallet row makes even the same key a new provider request after 24 hours. Before any retry after an ambiguous failure, whatever the key, check `GET /v1/wallets` and, if the wallet is not there, the provider account.\n\nThe provider request id and the recorded-wallet lookup both include the credential. After an API key rotation, once the key's 24-hour record has expired, the same key under the new credential is a new provider request and can create a second wallet: list wallets with `GET /v1/wallets` and check the provider account before retrying a create.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"provider\": \"privy\",\n  \"label\": \"Treasury wallet\",\n  \"purpose\": \"transfer\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Delete wallet",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/wallets",
            "description": "Deletes the wallet from its exact owning custody target when that Provider supports wallet deletion. Provider, when supplied, is a consistency assertion.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"provider\": \"privy\",\n  \"walletId\": \"privy_wallet_123\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "List wallet signing configs",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/wallets/configs",
            "description": "Returns the active Config-owned wallet signing configurations for the requested scope, one per provider."
          }
        },
        {
          "name": "Aggregate wallet balances",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/wallets/aggregate",
            "description": "Aggregates tracked balances for active wallets under the same owner-aware inclusion rules as the wallet list, without exposing one aggregate-level owner or runtime-admission value. Returns 503 if any included wallet balance cannot be read, rather than returning an incomplete total."
          }
        },
        {
          "name": "Get wallet public key",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/wallets/public-key",
            "description": "Returns the persisted public key for an exact active walletId. walletId is required (400 without it); an API key with selected wallets gets 404 for a wallet it does not list. Resolution is DB-backed only."
          }
        },
        {
          "name": "Check signer via simulated memo transaction",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/wallets/signer-check",
            "description": "Signs a server-authored memo message with the named wallet, verifies the signature, and simulates the transaction. Nothing is broadcast and no sponsorship is spent. The wallet is the only readonly signer and the request cannot supply memo text.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"walletId\": \"privy_wallet_123\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get wallet by ID",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/wallets/{{walletId}}",
            "description": "Returns active wallet metadata, exact Config or Connection ownership, runtime execution admission, Provider, public key, and by default the current SOL balance. If the balance read fails, returns 503 instead of a zero balance. Set includeBalance=false for a metadata-only read."
          }
        },
        {
          "name": "Update wallet",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/wallets/{{walletId}}",
            "description": "Updates the display label of an active wallet under its exact Config or Connection owner.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"label\": \"Treasury signer\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        }
      ]
    },
    {
      "name": "Projects",
      "item": [
        {
          "name": "List projects",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/projects",
            "description": "Lists projects in the organization."
          }
        },
        {
          "name": "Get project",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/projects/{{projectId}}",
            "description": "Gets project details."
          }
        },
        {
          "name": "Get project provider availability",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/projects/{{projectId}}/provider-availability",
            "description": "Lists every provider this deployment's release channel publishes, with whether this project can use it, counting the deployment's provider credentials for the project's environment. The same rule gates every provider entry point, so a provider reported unavailable is refused there with 403, or 503 when the deployment does not hold its credentials. A project outside the caller's organization, or another project for a project-bound API key, returns 404."
          }
        },
        {
          "name": "List project integrations",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/integrations",
            "description": "Lists the project's integrations and how each reaches the project. A provider is listed only when the project can use it, decided by the same rule that admits its use, so an integration listed here is never refused for availability at its entry point. The project comes from the `x-project-id` header, or from the API key's own project."
          }
        }
      ]
    },
    {
      "name": "Payments",
      "item": [
        {
          "name": "Get wallet balances",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/wallets/{{walletId}}/balances",
            "description": "Retrieves balances for a custody wallet. If any SOL or SPL balance read fails, returns 503 instead of partial or zero balances. Optional minimumSlot requires confirmed account state at or after that Solana slot; the response acknowledges the bound in data.balanceReadContext.minimumSlot. Wallet lifecycle and provisioning are managed through /v1/wallets."
          }
        },
        {
          "name": "List transfers",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/transfers",
            "description": "Lists persisted payment transfers for the authenticated scope. Set custodyWalletId to select one exact SDP wallet; observed address history is opt-in with includeObserved=true."
          }
        },
        {
          "name": "Execute transfer (custody)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.createPaymentTransfer}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/transfers",
            "description": "Executes an on-chain transfer using the exact SDP Wallet ID (`id` from `/v1/wallets`) and server-side custody signing. Requires an Idempotency-Key: a retry with the same key and request returns the original transfer instead of sending a second one. A 200 may return a processing transfer with its signature when broadcast or confirmation is still being reconciled; do not create a replacement transfer for that payment. A native SOL transfer is refused with 409 `AMOUNT_BELOW_RENT_MINIMUM`, before anything is written or sent, when it pays less than the rent-exempt minimum to an address with no account or would leave the source wallet holding more than nothing but less than the minimum. When the source wallet's policy at its custody provider refuses to sign, the transfer fails with 422 `CUSTODY_POLICY_REFUSED` and nothing is signed or sent; the message names the provider whose policy to review. 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 is recorded `failed` with that reason in `error`; a retry with the same Idempotency-Key returns the same error, so fund the wallet and send again with a new key. A source wallet with no token account for the token is refused earlier with 400 `BAD_REQUEST`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"projectId\": \"prj_example\",\n  \"sourceCustodyWalletId\": \"cwlt_example\",\n  \"destination\": \"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\",\n  \"token\": \"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\n  \"amount\": \"100.00\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get transfer",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/transfers/{{transferId}}",
            "description": "Retrieves details for a specific transfer."
          }
        },
        {
          "name": "Estimate transfer batch",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/transfer-batches/estimate",
            "description": "Validates an exact-wallet transfer batch request and estimates transaction chunking and fees. `warnings` lists what would make the batch fail: a native SOL amount below the rent-exempt minimum sent to an address with no account, or a source wallet left below the minimum; create refuses those with 409 `AMOUNT_BELOW_RENT_MINIMUM`. Returns 503 `ESTIMATE_NOT_AVAILABLE` when Solana RPC cannot price a chunk's fee; the estimate stores nothing, so retry it.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"projectId\": \"prj_example\",\n  \"sourceCustodyWalletId\": \"cwlt_example\",\n  \"token\": \"SOL\",\n  \"recipients\": [\n    {\n      \"externalId\": \"payroll_row_001\",\n      \"counterpartyId\": \"cpty_example\",\n      \"counterpartyAccountId\": \"cpacct_example\",\n      \"amount\": \"25.00\"\n    }\n  ],\n  \"externalId\": \"payroll_2026_06_30\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "List transfer batches",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/transfer-batches",
            "description": "Lists transfer batches for the authenticated organization or project scope."
          }
        },
        {
          "name": "Create transfer batch",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.createPaymentTransferBatch}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/transfer-batches",
            "description": "Executes an outbound transfer batch from one exact SDP Wallet ID, chunks recipients into Solana transactions, and returns the batch, recipient, and transfer records. Requires an Idempotency-Key: a retry with the same key and request returns the original batch without another on-chain submission. Recipient order does not change the request. A native SOL batch is refused with 409 `AMOUNT_BELOW_RENT_MINIMUM`, before anything is written or sent, when a recipient whose address has no account gets less than the rent-exempt minimum (`details.recipientIndexes`) or the source wallet would be left holding more than nothing but less than the minimum (`details.sourceRemainder`). A token batch is refused with 409 `SPONSORED_RENT_OVER_CAP` when one recipient's new token account costs more rent than the sponsor funds in a transaction (`details.recipientIndex`). A recipient's counterparty account archived while the batch is being prepared returns 404, and one whose address changed returns 409 `TRANSFER_BATCH_DESTINATION_CHANGED` (`details.counterpartyAccountIds`); either way nothing is written or sent, so the same request can be sent again. A chunk the source wallet cannot fund fails its recipients and transfer with the reason in `error` (the wallet does not have enough funds); the batch still returns 200. An `externalId` already used by a batch on the same project and source wallet never pays again: the same request from the same credential returns that batch, and any other returns `409 TRANSFER_BATCH_RUN_EXISTS` with its `batchId` in `error.details`. To finish a batch after a partial failure, call `POST /v1/payments/transfer-batches/{batchId}/retry`: it sends only the batch's `failed` recipients. Never resend the whole batch under a new Idempotency-Key, which pays the recipients that succeeded again.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"projectId\": \"prj_example\",\n  \"sourceCustodyWalletId\": \"cwlt_example\",\n  \"token\": \"SOL\",\n  \"recipients\": [\n    {\n      \"externalId\": \"payroll_row_001\",\n      \"counterpartyId\": \"cpty_example\",\n      \"counterpartyAccountId\": \"cpacct_example\",\n      \"amount\": \"25.00\"\n    }\n  ],\n  \"externalId\": \"payroll_2026_06_30\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Retry a transfer batch's failed recipients",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.retryPaymentTransferBatch}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/transfer-batches/{{batchId}}/retry",
            "description": "Sends the batch's `failed` recipients again, from the same source wallet, and no other recipient. A failed recipient was not paid: its transaction never reached the network or the network rejected it. Recipients still `pending` or `processing` may land and are never sent again. Each resent recipient keeps its row and links to its new chunk transfer.\n\nTakes no body and requires an `Idempotency-Key`. A retry with the same key sends nothing again: while its response is stored, it returns that saved response, and after a server error or once the response has expired, the batch as it stands. Read the batch's current state with `GET /v1/payments/transfer-batches/{batchId}`, and use a new key for each retry you mean to send.\n\nAny credential that may send from the batch's wallet can retry it. Each recipient is paid the address the batch recorded for it. Only recipients that are still `failed` are ever sent, so a retry under a new key never pays anyone twice.\n\nReturns `409`, sending nothing: `TRANSFER_BATCH_NOTHING_TO_RETRY` when the batch has no failed recipients (while it is `processing`, its unsent recipients become `failed` only once its request can no longer send them), `TRANSFER_BATCH_RETRY_CLAIMED` when a concurrent retry claimed them first, and `TRANSFER_BATCH_DESTINATION_CHANGED` (`details.recipientIds`) when a failed recipient's counterparty account has changed its address since (send that recipient in a new batch). A native SOL retry is refused with `409` `AMOUNT_BELOW_RENT_MINIMUM`, as create is, when a failed recipient would still be paid less than the rent-exempt minimum at an address with no account; its recipients stay `failed`."
          }
        },
        {
          "name": "Get transfer batch",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/transfer-batches/{{batchId}}",
            "description": "Retrieves a transfer batch with recipient rows and chunk transfer summaries."
          }
        },
        {
          "name": "List recurring payments",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/recurring-payments",
            "description": "Lists SDP-custody outbound recurring payments for the authenticated organization or project scope."
          }
        },
        {
          "name": "Create recurring payment",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.createPaymentRecurringPayment}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/recurring-payments",
            "description": "Creates an SDP-custody outbound recurring payment intent from a custody wallet to a counterparty crypto-wallet account. This stores backend state only; activation and collection are added by follow-up endpoints.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"sourceCustodyWalletId\": \"cwlt_example\",\n  \"counterpartyId\": \"cpty_example\",\n  \"counterpartyAccountId\": \"cpacct_example\",\n  \"token\": \"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\n  \"amount\": \"25.00\",\n  \"periodHours\": 720,\n  \"firstCollectionAt\": \"2099-01-01T00:00:00.000Z\",\n  \"metadataUri\": \"https://example.com/subscriptions/monthly-usdc.json\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get recurring payment",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/recurring-payments/{{id}}",
            "description": "Retrieves an SDP-custody outbound recurring payment record."
          }
        },
        {
          "name": "Update recurring payment",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.updatePaymentRecurringPayment}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/recurring-payments/{{id}}",
            "description": "Updates an SDP-custody recurring payment. Pending records are updated directly. Active metadata and due-date edits are applied in place, while active term, source, destination, or token edits create a replacement Solana subscription, authorize it, cancel the old subscription, and then swap the recurring payment to the replacement records.\n\nSend `expectedUpdatedAt` from the version you reviewed to make the edit apply to that version only: when the recurring payment has changed since, the edit returns `409` `RECURRING_PAYMENT_CHANGED` and changes nothing.\n\nRequires an `Idempotency-Key`, because an edit can sign and broadcast a replacement subscription. A same-key retry of a completed edit replays its answer. A retry of an interrupted edit gets `409` `IDEMPOTENCY_KEY_IN_FLIGHT` with `Retry-After` until the attempt is stale, then resumes from the last step SDP recorded. If a step was sent but not yet recorded when the edit was interrupted, which is rare, the retry can fail or send that step again, so read the recurring payment with `GET` before editing it again.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"sourceCustodyWalletId\": \"cwlt_example\",\n  \"counterpartyId\": \"cpty_example\",\n  \"counterpartyAccountId\": \"cpacct_example\",\n  \"token\": \"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\n  \"amount\": \"25.00\",\n  \"periodHours\": 720,\n  \"firstCollectionAt\": \"2099-01-01T00:00:00.000Z\",\n  \"nextCollectionDueAt\": \"2099-02-01T00:00:00.000Z\",\n  \"metadataUri\": \"https://example.com/subscriptions/monthly-usdc.json\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Activate recurring payment",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.activatePaymentRecurringPayment}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/recurring-payments/{{id}}/activate",
            "description": "Activates a pending SDP-custody recurring payment by creating the Solana subscriptions plan, authorizing the subscription, and storing the resulting on-chain identifiers and signatures.\n\nA fresh activation signs the terms it read: when an edit lands while it runs, or `expectedUpdatedAt` names an earlier version, it returns `409` `RECURRING_PAYMENT_CHANGED` before claiming the schedule or signing.\n\nBoth the counterparty and its account must be active before a fresh activation; an unavailable recipient returns `404` before claiming the schedule or signing. Recovery can confirm saved transactions, but cannot sign a new step for an unavailable recipient.\n\nThe source wallet gives the Subscriptions program's authority for that token an approval of 0, and each collection approves exactly its own amount in the same transaction as the pull, so the token program's `Approve` instruction appears in activation and collection transactions. If your custody provider's wallet policy allowlists programs or instructions, allow `Approve` to that authority, or the provider refuses the collection and SDP pauses the recurring payment with `pausedReason` `custody_policy_refused`.",
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Cancel recurring payment",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.cancelPaymentRecurringPayment}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/recurring-payments/{{id}}/cancel",
            "description": "Permanently stops an active or paused SDP-custody recurring payment by submitting the Solana subscriptions immediate cancellation, which ends its on-chain subscription at once rather than at the end of the billing period. A canceled recurring payment never collects or resumes again.\n\nEvery recurring payment from one wallet in one token relies on one token approval. When no other live recurring payment uses it, it is revoked right after the cancel; otherwise it stays for the schedules that still use it. The response's `tokenApproval` says which: `revoked`, `kept` with the recurring payments still using it in `sharedWith`, or `revoking` while a collection that was already processing settles, after which the background sweep revokes it. `GET` returns the same `tokenApproval` for a canceled recurring payment."
          }
        },
        {
          "name": "List payment requests",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/requests",
            "description": "Lists the caller's project's payment requests, newest first. Requires `payment_requests.read`. An open request past its `expiresAt` reads as `expired`. Records never carry `publicToken`."
          }
        },
        {
          "name": "Create payment request",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/requests",
            "description": "Creates a payment request: an amount of one token for a payer to send to a custody wallet in the caller's project, through the request's hosted payment link. Requires `payment_requests.manage`. `walletId` is the provider wallet ID (`walletId` from `GET /v1/wallets`), not the SDP Wallet ID transfers take; an unknown wallet, or one outside the project, returns 404. `amount` is a decimal string with at most the mint's decimals, so `0.05` and `0.050` are the same request; more decimals return 400. A native SOL request is refused with 409 `AMOUNT_BELOW_RENT_MINIMUM` when the wallet has no account and the amount is below the rent-exempt minimum. The response is the only one besides `GET /v1/payments/requests/{paymentRequestId}/link` that carries `publicToken`. Accepts an Idempotency-Key: a retry with the same key and request returns the original payment request instead of creating a second one.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"walletId\": \"privy_wallet_123\",\n  \"token\": \"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\n  \"amount\": \"10.00\",\n  \"expiresAt\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get payment request",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/requests/{{paymentRequestId}}",
            "description": "Retrieves one payment request in the caller's project; another project's request returns 404. Requires `payment_requests.read`. An open request past its `expiresAt` reads as `expired`. The record never carries `publicToken`; use `GET /v1/payments/requests/{paymentRequestId}/link`."
          }
        },
        {
          "name": "Get payment request link",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/requests/{{paymentRequestId}}/link",
            "description": "Returns the token behind a payment request's hosted payment link, `/pay/{publicToken}`. Anyone holding it can build the request's fee-sponsored payment transaction, so this route requires `payment_requests.manage`, not `payment_requests.read`. Another project's request returns 404. The token is returned whatever the request's status."
          }
        },
        {
          "name": "Cancel payment request",
          "request": {
            "method": "POST",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/requests/{{paymentRequestId}}/cancel",
            "description": "Cancels an open payment request, so its payment link stops building and sponsoring transactions. Requires `payment_requests.manage`. Canceling a request that is already canceled returns it unchanged. A paid or expired request returns 409, and so does one whose fee-sponsored transaction can still land: retry once that transaction's blockhash expires. A payer who signed before the cancel can still land the payment; SDP records it as paid, with `paidLate` set when it landed after the cancel."
          }
        },
        {
          "name": "Collect recurring payment",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.collectPaymentRecurringPayment}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/recurring-payments/{{id}}/collect",
            "description": "Manually starts a due active SDP-custody recurring payment collection, creating a linked payment transfer and collection attempt.\n\nWhen admitting a new collection, SDP requires an active counterparty and account in the schedule's project. Archival that commits before admission prevents a new collection from signing. If either recipient record is archived or unavailable, the schedule pauses with `pausedReason` `destination_unavailable` and the request returns `409` without signing. That `409`, and every later collect or resume while the recipient stays unavailable, tells the caller to cancel the schedule and create a new one. A collection admitted earlier may still finish or recover.\n\nIf submission cannot be confirmed immediately, a `200` response returns the same transfer as `processing` with its known signature; reconciliation settles it, and the next due time advances only after exact on-chain confirmation.\n\nWhen the source wallet cannot cover the amount, the request returns `400` `TRANSACTION_FAILED`, and the failed collection attempt and its transfer record the same reason in `error`, so the collection history shows why."
          }
        },
        {
          "name": "Resume recurring payment",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.resumePaymentRecurringPayment}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/recurring-payments/{{id}}/resume",
            "description": "Resumes a paused SDP-custody recurring payment. SDP pauses a recurring payment when a due period fails 5 times, at once when the custody provider's policy refuses to sign a collection, or before a new collection when its counterparty or account is archived or unavailable.\n\nResume requires both recipient records to be active; while either is archived or unavailable it returns 409, telling the caller to cancel the schedule and create a new one. Its on-chain subscription is still in place, so nothing is signed: the recurring payment returns to active, the failure count starts again, and a due period that has already ended moves to the next one. A canceled recurring payment cannot be resumed."
          }
        },
        {
          "name": "List recurring payment collection attempts",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/recurring-payments/{{id}}/collection-attempts",
            "description": "Lists every collection attempt a recurring payment has made over its whole history, newest first: its current subscription and every subscription an edit replaced. A recurring payment that has not been activated has none."
          }
        },
        {
          "name": "List subscription plans",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/subscription-plans",
            "description": "Lists recurring-payment subscription plans."
          }
        },
        {
          "name": "Create subscription plan",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/subscription-plans",
            "description": "Creates a recurring-payment subscription plan record. This stores SDP backend state and Solana subscriptions program identifiers; it does not by itself create the on-chain plan.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"ownerWalletId\": \"wal_merchant\",\n  \"token\": \"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\n  \"amount\": \"25.00\",\n  \"periodHours\": 720,\n  \"programPlanId\": \"17014118346046923173\",\n  \"planPda\": \"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\",\n  \"destinationAddress\": \"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\",\n  \"pullerWalletId\": \"wal_collector\",\n  \"metadataUri\": \"https://example.com/subscriptions/monthly-usdc.json\",\n  \"status\": \"active\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Prepare subscription plan creation",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/subscription-plans/{{planId}}/prepare-create",
            "description": "Prepares an unsigned Solana subscriptions program create-plan transaction from an SDP subscription plan. This derives and stores the plan PDA but does not submit the transaction.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"destinations\": [\n    \"\"\n  ],\n  \"pullers\": [\n    \"\"\n  ],\n  \"endTs\": \"1770000000\",\n  \"metadataUri\": \"https://example.com/subscriptions/monthly-usdc.json\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get subscription plan",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/subscription-plans/{{planId}}",
            "description": "Retrieves a recurring-payment subscription plan record."
          }
        },
        {
          "name": "Update subscription plan",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/subscription-plans/{{planId}}",
            "description": "Updates mutable subscription plan fields and on-chain identifiers.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"planPda\": \"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\",\n  \"pullerWalletId\": \"wal_collector\",\n  \"status\": \"active\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "List subscriptions",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/subscriptions",
            "description": "Lists recurring-payment subscriptions."
          }
        },
        {
          "name": "Create subscription",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/subscriptions",
            "description": "Creates a recurring-payment subscription record tied to a counterparty. The customer must still sign the Solana subscription authorization transaction. The subscriber must be the customer's own wallet: a subscriber that is one of the organization's SDP wallets answers 409, because to pay on a schedule from an SDP wallet you create a recurring payment, which keeps the wallet's token approval bounded.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"planId\": \"psp_example\",\n  \"counterpartyId\": \"cpty_example\",\n  \"subscriberAddress\": \"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Prepare subscription authorization",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/subscriptions/{{subscriptionId}}/prepare-authorization",
            "description": "Prepares the subscriber-signed Solana transaction that initializes the subscription authority and subscribes to the plan. The transaction must still be signed and submitted by the client. A subscriber that is one of the organization's SDP wallets answers 409: pay from an SDP wallet through a recurring payment.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"subscriberTokenAccount\": \"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\",\n  \"expectedPlanCreatedAt\": \"0\",\n  \"expectedSubscriptionAuthorityInitId\": \"0\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Prepare subscription cancellation",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/subscriptions/{{subscriptionId}}/prepare-cancel",
            "description": "Prepares the subscriber-signed Solana transaction that cancels a subscription. The transaction must still be signed and submitted by the client.",
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Prepare subscription resume",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/subscriptions/{{subscriptionId}}/prepare-resume",
            "description": "Prepares the subscriber-signed Solana transaction that resumes a canceled subscription before revocation. The transaction must still be signed and submitted by the client. A subscription that belongs to a recurring payment answers 409: a recurring payment's cancel is final.",
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Prepare subscription collection",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/payments/subscriptions/{{subscriptionId}}/prepare-collection",
            "description": "Prepares the collector-signed Solana subscriptions transfer transaction for an active subscription. The transaction must still be signed and submitted by the collector/fee-payer flow. A subscription that belongs to a recurring payment, or whose subscriber is one of the organization's SDP wallets, answers 409: SDP collects from an SDP wallet only through a recurring payment.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"receiverTokenAccount\": \"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get subscription",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/subscriptions/{{subscriptionId}}",
            "description": "Retrieves a recurring-payment subscription record."
          }
        },
        {
          "name": "List subscription collection attempts",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/payments/subscriptions/{{subscriptionId}}/collection-attempts",
            "description": "Lists collection attempts for a recurring-payment subscription."
          }
        },
        {
          "name": "List transactions",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/transactions",
            "description": "Lists the project's transactions across SDP modules with stable cursor pagination.\n\nRequires `transactions.read`. Every row also needs the `read` action of the module it comes from (which its `manage` or `execute` includes): a Payments row needs `transfers.read` for a transfer or deposit, `transfer_batches.read` for a batch item, `recurring.read` for a recurring collection, `payment_requests.read` for a payment request's fulfilment; every other module's rows need that module's `read`. Rows the caller cannot read are left out. Requesting a module, or a Payments `kind`, without its action returns `403` `INSUFFICIENT_PERMISSIONS` naming it.\n\n`search` matches transaction id, module id, or signature as a prefix, not a substring. The endpoint is metered (60/min per credential, 240/min per organization); exceeding the quota returns `429`, and `503` indicates the quota backend was unavailable."
          }
        }
      ]
    },
    {
      "name": "Counterparties",
      "item": [
        {
          "name": "Get counterparty field options",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/counterparties/metadata",
            "description": "Returns the enum option sets and country list needed to build a counterparty form."
          }
        },
        {
          "name": "List counterparties",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/counterparties",
            "description": "Lists counterparties for the authenticated organization."
          }
        },
        {
          "name": "Create counterparty",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/counterparties",
            "description": "Creates a counterparty. If externalId is provided, it must be unique within the organization.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"externalId\": \"customer_42\",\n  \"entityType\": \"individual\",\n  \"displayName\": \"Jane Doe\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get counterparty",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/counterparties/{{counterpartyId}}",
            "description": "Gets counterparty details by id."
          }
        },
        {
          "name": "Update counterparty",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/counterparties/{{counterpartyId}}",
            "description": "Updates counterparty attributes. At least one field must be provided. Use null on externalId to clear it.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"externalId\": \"customer_42\",\n  \"entityType\": \"business\",\n  \"displayName\": \"Jane Q. Doe\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Archive counterparty",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": "{{baseUrl}}/v1/counterparties/{{counterpartyId}}",
            "description": "Archives a counterparty and its active accounts and provider links atomically. Archived counterparties are hidden from default lists. New transfers and batches cannot use those accounts. Recurring payments pause before their next new collection; work already in flight keeps its recovery path."
          }
        },
        {
          "name": "List counterparty accounts",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/counterparties/{{counterpartyId}}/accounts",
            "description": "Lists payment accounts for a counterparty. Crypto-wallet accounts can be used by payment flows that send funds to a counterparty wallet."
          }
        },
        {
          "name": "Create counterparty account",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/counterparties/{{counterpartyId}}/accounts",
            "description": "Creates a payment account for a counterparty. For accountKind \"crypto_wallet\", details.network must be \"solana\" and details.address must be a Solana wallet address. A counterparty holds one active account per network and address: an address already on one of its active accounts answers 409 `CONFLICT` (\"This address is already on this counterparty\"). An archived account does not count, so re-adding its address creates a new account.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"accountKind\": \"crypto_wallet\",\n  \"label\": \"USDC wallet\",\n  \"details\": {\n    \"network\": \"solana\",\n    \"address\": \"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\"\n  },\n  \"providerAccountData\": {}\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get counterparty account",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/counterparties/{{counterpartyId}}/accounts/{{counterpartyAccountId}}",
            "description": "Gets a counterparty payment account by id."
          }
        },
        {
          "name": "Update counterparty account",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/counterparties/{{counterpartyId}}/accounts/{{counterpartyAccountId}}",
            "description": "Updates a counterparty payment account. At least one field must be provided. Changing details to an address already on another of the counterparty's active accounts answers 409 `CONFLICT`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"label\": \"Primary USDC wallet\",\n  \"details\": {\n    \"network\": \"solana\",\n    \"address\": \"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\"\n  },\n  \"providerAccountData\": {}\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Archive counterparty account",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": "{{baseUrl}}/v1/counterparties/{{counterpartyId}}/accounts/{{counterpartyAccountId}}",
            "description": "Archives a counterparty payment account. Archived accounts are hidden from default lists."
          }
        }
      ]
    },
    {
      "name": "Earn",
      "item": [
        {
          "name": "List strategies",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/earn/strategies",
            "description": "Lists the strategies your project can see, largest first by total value locked. The list defaults to your project's cluster: devnet for sandbox, mainnet for production. Pass `cluster` to browse the other cluster's strategies; those rows are always `fundable: false`. A listed strategy is not always open for deposits, so check `fundable` and `status` before you offer it."
          }
        },
        {
          "name": "Get a strategy",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/earn/strategies/{{strategyId}}",
            "description": "Gets one strategy by ID, on either cluster. `fundable` still reports whether the strategy exists on your project's cluster."
          }
        },
        {
          "name": "Preview a deposit",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/vault-deposit-previews",
            "description": "Quotes how many shares a deposit would receive right now, from the provider's live accounting. When the strategy's `depositSlippage` is not null, set `minSharesOut` from this quote: take your tolerance off `sharesOut`. Resolve every `blockingIssues` entry before you build. The preview is read-only, takes no Idempotency-Key, and applies a deposit's access checks, provider activation included. It returns 501 when the provider cannot quote deposits.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"strategyId\": \"earn_strategy_3b6a81ce-00b0-4c5a-9761-cb359932e97e\",\n  \"amount\": \"25\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get position totals",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/earn/external-wallet/positions/summary",
            "description": "Gets live position totals across every customer wallet in your project, grouped by strategy and token. Wallet addresses are left out unless you pass `includeOwnerAddresses=true`; add `includePositions=true` to also receive each wallet's positions, which saves one read per wallet. A total that depends on an unavailable live value is left out, never reported as zero or partial."
          }
        },
        {
          "name": "List positions",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/earn/external-wallet/positions",
            "description": "Lists one customer wallet's live positions, one page at a time. `ownerAddress` is required. A wallet with no positions in your project (never deposited, fully withdrawn, or held under another organization) returns an empty `positions` list, not 404. Live fields are left out, never set to zero, when the provider read is unavailable."
          }
        },
        {
          "name": "List movements",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/earn/external-wallet/movements",
            "description": "Lists one customer wallet's deposits and withdrawals, newest first, one page at a time. Movement statuses are `requested`, `submitted`, `confirmed`, `finalized` and `failed`; only `finalized` and `failed` are final. `ownerAddress` is required, and a wallet with no movements returns an empty `movements` list, not 404. Show `tokenAmount` in `tokenMint` units: on a withdrawal it is the observed payout once finalized, while `amount` and `denomination` carry the onchain quantity, which is shares. Reads keep working after a provider stops taking deposits."
          }
        },
        {
          "name": "Get a movement",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/earn/external-wallet/movements/{{movementId}}",
            "description": "Gets one deposit or withdrawal and refreshes its onchain status when it can. Use it to track a movement after submit, or to recover a submit whose response was lost. If the chain read fails, you get the last recorded status while SDP keeps reconciling in the background. An atomic movement can show as done at `confirmed`, but only `finalized` and `failed` are final. For `settlement: provider_order`, chain confirmation is not payout: the provider still has to complete the order."
          }
        },
        {
          "name": "Get earnings",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/earn/external-wallet/earnings",
            "description": "Gets one customer wallet's live balance and earnings per deposit token. `earned` is live value plus observed withdrawal payouts (`totalWithdrawn`) minus finalized deposits (`totalDeposited`), and appears only when it is exact. Otherwise it is left out with an `earnedUnavailableReason`, such as a live value that is unavailable, movements that are still settling, or a withdrawal payout SDP did not observe; never show it as zero. A wallet with no positions returns `positionCount: 0` and an empty `totalsByToken`, not 404. Totals cover the positions the wallet holds now: a fully withdrawn position drops out here but stays in the movements list. Live value reads the wallet's whole balance in the strategy, so shares acquired outside SDP raise it."
          }
        },
        {
          "name": "Build a deposit transaction",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/deposit-transactions",
            "description": "Builds an unsigned deposit transaction for the customer's wallet to sign. Nothing moves until you submit the signed transaction. The build applies provider access checks and can name your own `feePayer`, which then signs alongside the customer. A swap-funded deposit that cannot fit in one transaction returns a separate swap and follow-up deposit instead of `transaction`, so check the response before you request a signature.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"strategyId\": \"earn_strategy_3b6a81ce-00b0-4c5a-9761-cb359932e97e\",\n  \"ownerAddress\": \"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM\",\n  \"amount\": \"25\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Submit a deposit",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.createEarnExternalWalletDeposit}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/deposits",
            "description": "Submits a deposit transaction signed by the customer's wallet. SDP checks that the signed bytes match the built transaction and that every required signature is valid (the customer's, plus your fee payer's when the build named one), records the movement, then broadcasts. Idempotency-Key is required: a retry with the same key returns the original movement with `replayed: true`, and each built transaction can be submitted once. Reusing a key with a different body, or submitting one build under a second key, returns `409 CONFLICT`. Submit the original signed bytes even after the blockhash expires, since the wallet may already have broadcast them. After an uncertain response, retry with the same key and body, then read the movement; never replace a signed transaction whose outcome is unknown.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"transactionId\": \"earn_external_wallet_transaction_e7a88258-1d19-48f3-8d0e-81fefa4bf3b3\",\n  \"signedTransaction\": \"AUyx/sEc49/1l6jxo3nbViPAIUqdrXCo8Qx5SfDvIbtUI0zex3Bi6Cyhhn1QTVU8zWDzXTRyIX2aCmq9v7yk5AOA\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Preview a withdrawal",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/withdrawal-previews",
            "description": "Quotes what withdrawing these shares would pay right now, from the strategy's live accounting. When the strategy's `withdrawalSlippage` is not null, set `minAmountOut` from this quote: take your tolerance off `assetsOut`, rounded down to `assetDecimals`. The preview is read-only, takes no Idempotency-Key, and still works after a strategy is deprecated or its provider stops taking deposits. It does not check the wallet's balance, so keep `shares` within the position's `withdrawableShares`. It returns 501 when the provider cannot quote withdrawals.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"positionId\": \"earn_position_2f8b6b04-7147-487e-b760-b1caa4fbcb8b\",\n  \"shares\": \"10\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Build a withdrawal transaction",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/withdrawal-transactions",
            "description": "Builds an unsigned withdrawal transaction for a position, for the customer's wallet to sign. Withdrawals keep working after a provider stops taking deposits. Send `minAmountOut` when the strategy's `withdrawalSlippage` is not null, and omit it when null. A provider-order route may not support a preview: it places an order that pays out later, not an instant redemption.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"positionId\": \"earn_position_2f8b6b04-7147-487e-b760-b1caa4fbcb8b\",\n  \"shares\": \"10\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Submit a withdrawal",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.createEarnExternalWalletWithdrawal}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/withdrawals",
            "description": "Submits a withdrawal transaction signed by the customer's wallet. It works like a deposit submit: SDP checks every signature against the exact built message, records the movement before broadcast, requires an Idempotency-Key, and accepts each built transaction once, with the same `409 CONFLICT` answers. Submit the original signed bytes even after the blockhash expires, since the wallet may already have broadcast them. After an uncertain response, retry with the same key and body, then read the movement instead of building a new withdrawal. Withdrawals stay available while Earn is disabled.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"transactionId\": \"earn_external_wallet_transaction_0ebe7320-4b8e-4d1a-a5f0-6c2d9e8b1f47\",\n  \"signedTransaction\": \"AUyx/sEc49/1l6jxo3nbViPAIUqdrXCo8Qx5SfDvIbtUI0zex3Bi6Cyhhn1QTVU8zWDzXTRyIX2aCmq9v7yk5AOA\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "List withdrawal options",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/withdrawal-options",
            "description": "Lists the withdrawal routes a position supports right now: instant, provider order, solver queue and operator redemption, each read from live provider state. A position offers instant or provider order, never both; the withdrawal endpoints build whichever it offers. Call this when the customer starts a withdrawal, and let them choose when more than one route is available. Never infer a route from the strategy's `liquidityTerm`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"positionId\": \"earn_position_0432ffeb-c574-4b11-80dc-a09defb68fcc\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Preview a withdrawal request",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/queued-withdrawal-previews",
            "description": "Previews a withdrawal request: a solver queue's discounted payout and timing, or an operator redemption's intermediate-token conversion and payout. Check the mechanism against the position's withdrawal options first, and resolve every `blockingIssues` entry before the customer confirms. The preview is read-only and safe to retry. It does not check the wallet's balance, so keep `shares` within the position's `withdrawableShares`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"positionId\": \"earn_position_0432ffeb-c574-4b11-80dc-a09defb68fcc\",\n  \"mechanism\": \"solverQueue\",\n  \"shares\": \"15\",\n  \"discountBps\": 10,\n  \"deadlineSeconds\": 60\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Build a withdrawal request transaction",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/withdrawal-request-transactions",
            "description": "Builds an unsigned withdrawal request transaction for a position, for the customer's wallet to sign. A solver queue request escrows shares; an operator redemption converts shares into an intermediate token that the operator settles later. Neither transaction pays the customer. Save the `withdrawalRequestId` the submit returns and track the request until it is fulfilled, canceled, or failed.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"positionId\": \"earn_position_0432ffeb-c574-4b11-80dc-a09defb68fcc\",\n  \"mechanism\": \"solverQueue\",\n  \"shares\": \"15\",\n  \"discountBps\": 10,\n  \"deadlineSeconds\": 60\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "List withdrawal requests",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/earn/external-wallet/withdrawal-requests",
            "description": "Lists one customer wallet's withdrawal requests, one page at a time. `ownerAddress` is required. On startup and after reconnecting, pass `settled=false` to restore every request that still needs fulfillment, classification or cancellation, and page until `hasMore` is false."
          }
        },
        {
          "name": "Submit a withdrawal request",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.createEarnExternalWalletWithdrawalRequest}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/withdrawal-requests",
            "description": "Submits a withdrawal request transaction signed by the customer's wallet. SDP checks every signature against the exact built message, records the request before broadcast, then sends it. Idempotency-Key is required: a retry with the same key returns the original request, and each built transaction can be submitted once. Reusing a key with a different body, or submitting one build under a second key, returns `409 CONFLICT`. An expired build can still be submitted late. If its reservation was released or replaced, SDP accepts it only once the exact signed transaction is finalized as successful, so after a `409 CONFLICT` retry the same bytes and key until that shows. A late operator redemption whose original cycle already closed is refused for operator reconciliation, so it cannot claim a newer request at the same address. A transaction that recovery finds finalized as failed returns `400 TRANSACTION_FAILED`; build a new one. A recovery RPC outage returns `503 PROVIDER_UNAVAILABLE`: retry the original bytes and key. An unresolved 16-signature history window returns `503` and needs support to reconcile. An expired operator redemption build whose creation history is unavailable also returns `503`: keep and retry the original signed intent rather than signing a replacement.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"transactionId\": \"earn_external_wallet_withdrawal_request_transaction_54c50679-1b52-494d-84fa-106df66452ff\",\n  \"signedTransaction\": \"AUyx/sEc49/1l6jxo3nbViPAIUqdrXCo8Qx5SfDvIbtUI0zex3Bi6Cyhhn1QTVU8zWDzXTRyIX2aCmq9v7yk5AOA\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Get a withdrawal request",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/v1/earn/external-wallet/withdrawal-requests/{{withdrawalRequestId}}",
            "description": "Gets one withdrawal request with its latest recorded status. Creating a request escrows shares or converts them to an intermediate token; it does not pay out. Only `fulfilled`, `cancelled` and `failed` are final. Keep polling a `closedOrUnknown` request: SDP has not yet confirmed whether it paid out or returned the customer's tokens."
          }
        },
        {
          "name": "Build a cancellation transaction",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/withdrawal-request-cancel-transactions",
            "description": "Builds an unsigned transaction that cancels a withdrawal request, for the customer's wallet to sign. A solver queue request can be canceled only at `expiredCancelable`, because a solver may fulfill it until its deadline; before then the build returns `409 CONFLICT`. An operator redemption that allows cancellation can be canceled while `pending`, which leaves the customer holding the intermediate token, not the original shares. Requires `earn.manage`. Cancellation stays available without production access and while Earn is disabled; a suspended organization still cannot authenticate.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"withdrawalRequestId\": \"earn_vault_withdrawal_request_3c905ad2-c92c-4258-9675-1899c41569b9\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        },
        {
          "name": "Submit a cancellation",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{idempotencyKey.createEarnExternalWalletWithdrawalRequestCancellation}}",
                "type": "text"
              }
            ],
            "url": "{{baseUrl}}/v1/earn/external-wallet/withdrawal-request-cancellations",
            "description": "Submits a cancellation transaction signed by the customer's wallet. SDP verifies it, records it before broadcast, and returns the withdrawal request. Idempotency-Key is required, with the same `409 CONFLICT` answers as the other submits. Requires `earn.manage`, and stays available without production access and while Earn is disabled; a suspended organization still cannot authenticate. Keep polling until the request is `cancelled`, `fulfilled` or `failed`, because a fulfillment can race the cancellation.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"transactionId\": \"earn_external_wallet_withdrawal_request_transaction_9a3c1e27-5d84-4f0b-b6e2-7f19c4a8d035\",\n  \"signedTransaction\": \"AUyx/sEc49/1l6jxo3nbViPAIUqdrXCo8Qx5SfDvIbtUI0zex3Bi6Cyhhn1QTVU8zWDzXTRyIX2aCmq9v7yk5AOA\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          }
        }
      ]
    }
  ]
}
