Deposit funds

Preview, build, sign, submit, and track a deposit request by request, in cURL or TypeScript.

A deposit takes six requests and one signature. This page shows each request and its response, so you can build the flow in any language. For the shortest path, start with the Quickstart.

Before you begin

You need the Quickstart's setup: SDP_API_URL for a beta or experimental deployment, a sandbox API key with earn.execute, a funded test wallet in test-wallet.json, and Node.js for signing. See Before you begin. The cURL examples also require jq 1.6 or later.

Work in the folder that holds test-wallet.json, with @solana/kit installed for the signer:

Shell
npm init -y && npm pkg set type=module && npm install @solana/kit@8
export SDP_API_KEY="sf_..."
export OWNER_ADDRESS="$(solana-keygen pubkey test-wallet.json)"

Save this signer as sign.mjs in the same folder. It signs SDP's transaction without changing it and prints the signed transaction:

JavaScriptsign.mjs
// Sandbox only: signs a base64 transaction with test-wallet.json.
import { readFile } from "node:fs/promises";
import {
  createKeyPairFromBytes,
  getBase64EncodedWireTransaction,
  getBase64Encoder,
  getTransactionDecoder,
  partiallySignTransaction,
} from "@solana/kit";

const secretKey = new Uint8Array(JSON.parse(await readFile("test-wallet.json", "utf8")));
const keyPair = await createKeyPairFromBytes(secretKey);
const unsigned = getTransactionDecoder().decode(getBase64Encoder().encode(process.argv[2]));
const signed = await partiallySignTransaction([keyPair], unsigned);
process.stdout.write(getBase64EncodedWireTransaction(signed));

Use earn-api.ts and test-wallet.ts from the Quickstart. The steps below are one file, deposit.ts, that you run with npx tsx deposit.ts.

1. Choose a strategy

List the catalog and keep an active, fundable strategy whose first deposit mint is devnet USDC and needs no slippage floor. Strategies explains each check.

curl --fail-with-body -sS "${SDP_API_URL:?Set SDP_API_URL}/v1/earn/strategies?pageSize=100" \
  -H "Authorization: Bearer $SDP_API_KEY" > strategies.json

# The first active, fundable devnet USDC strategy that needs no deposit floor.
STRATEGY_ID="$(jq -er --arg mint "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" '
  [.data.strategies[] | select(.fundable and .status == "active"
    and .depositSlippage == null and .depositMints[0] == $mint)][0].id
  // error("No devnet USDC strategy is open for deposits")' strategies.json)" || exit 1
export STRATEGY_ID
export AMOUNT="25"  # in the deposit token
echo "$STRATEGY_ID"
import { randomUUID } from "node:crypto";
import { earnRequest } from "./earn-api";
import { ownerAddress, signWithTestWallet } from "./test-wallet";

type Strategy = {
  id: string;
  status: string;
  fundable: boolean;
  depositMints: string[];
  depositSlippage: unknown;
  withdrawalSlippage: { defaultToleranceBps: number } | null;
};

const { strategies }: { strategies: Strategy[] } = await earnRequest(
  "/v1/earn/strategies?pageSize=100",
);
const strategy = strategies.find(
  (s) => s.fundable && s.status === "active" && s.depositSlippage === null
    && s.depositMints[0] === "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
);
if (!strategy) throw new Error("No devnet USDC strategy is open for deposits");
const amount = "25"; // in the deposit token

2. Preview the deposit

POST /v1/earn/vault-deposit-previews quotes the shares the deposit would receive right now. It moves nothing.

curl --fail-with-body -sS "${SDP_API_URL:?Set SDP_API_URL}/v1/earn/vault-deposit-previews" \
  -H "Authorization: Bearer $SDP_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -nc --arg strategyId "$STRATEGY_ID" --arg amount "$AMOUNT" \
    '{strategyId: $strategyId, amount: $amount}')" > preview.json

jq -e '.data.blockingIssues == []' preview.json || echo "Resolve blockingIssues first" >&2
const preview = await earnRequest("/v1/earn/vault-deposit-previews", {
  strategyId: strategy.id,
  amount,
});
if (preview.blockingIssues.length > 0) {
  throw new Error(`Preview blocked: ${JSON.stringify(preview.blockingIssues)}`);
}
JSONResponse (trimmed)
{
  "data": {
    "strategyId": "earn_strategy_3b6a81ce-00b0-4c5a-9761-cb359932e97e",
    "sharesOut": "25",
    "shareDecimals": 6,
    "blockingIssues": []
  }
}

Resolve every blockingIssues entry before you build, even on a 200. A preview does not reserve the rate. For a strategy whose depositSlippage is not null, use sharesOut to set minSharesOut, as Slippage floors shows. Ignore the response's feeSponsored: it describes Treasury custody-wallet movements, and your customer's wallet pays its own fees unless you set a feePayer.

3. Build the transaction

POST /v1/earn/external-wallet/deposit-transactions returns an unsigned transaction for the customer's wallet.

curl --fail-with-body -sS "${SDP_API_URL:?Set SDP_API_URL}/v1/earn/external-wallet/deposit-transactions" \
  -H "Authorization: Bearer $SDP_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -nc --arg strategyId "$STRATEGY_ID" --arg ownerAddress "$OWNER_ADDRESS" \
    --arg amount "$AMOUNT" \
    '{strategyId: $strategyId, ownerAddress: $ownerAddress, amount: $amount}')" \
  > build.json
const { transaction: built } = await earnRequest(
  "/v1/earn/external-wallet/deposit-transactions",
  { strategyId: strategy.id, ownerAddress, amount },
);
JSONResponse (trimmed)
{
  "data": {
    "transaction": {
      "transactionId": "earn_external_wallet_transaction_b116f3ed-5f19-41b1-85cd-8afe6b37875d",
      "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...",
      "lastValidBlockHeight": "14846",
      "sponsored": false,
      "ownerAddress": "Af89Mkuvo6AcLDbs7Fxs1FPsnnbD9KrY9NfmaFXHqmPo",
      "provider": "kamino",
      "tokenMint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
      "amount": "25",
      "minSharesOut": null
    }
  }
}

Keep transactionId and the base64 transaction together. The network accepts the transaction until block height lastValidBlockHeight, about a minute after the build, so sign and submit promptly. The customer's wallet pays the network fee, plus rent for the accounts a first deposit creates, unless you name a feePayer; see Fees and sponsorship.

4. Sign it

The customer's wallet signs the exact bytes, without changing or broadcasting them. Here the test wallet stands in. In production, sign in your app, as the Quickstart shows.

export TRANSACTION_ID="$(jq -r '.data.transaction.transactionId' build.json)"
export SIGNED_TRANSACTION="$(node sign.mjs "$(jq -r '.data.transaction.transaction' build.json)")"
test -n "$SIGNED_TRANSACTION" || echo "Signing failed: is @solana/kit installed here?" >&2
const signedTransaction = await signWithTestWallet(built.transaction);

5. Submit it

POST /v1/earn/external-wallet/deposits takes the signed transaction. SDP checks it against the build, records the deposit, then broadcasts it. The Idempotency-Key header is required.

# Save the idempotency key with the signed body before the first send; retry with both.
export IDEMPOTENCY_KEY="$(node -e 'console.log(crypto.randomUUID())')"
jq -nc --arg transactionId "$TRANSACTION_ID" --arg signedTransaction "$SIGNED_TRANSACTION" \
  '{transactionId: $transactionId, signedTransaction: $signedTransaction}' > submit.json

curl --fail-with-body -sS "${SDP_API_URL:?Set SDP_API_URL}/v1/earn/external-wallet/deposits" \
  -H "Authorization: Bearer $SDP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  --data-binary @submit.json > deposit.json
// Save the idempotency key with the signed body before the first send; retry with both.
const idempotencyKey = randomUUID();
const { deposit } = await earnRequest(
  "/v1/earn/external-wallet/deposits",
  { transactionId: built.transactionId, signedTransaction },
  idempotencyKey,
);

Persist the returned positionId with the selected strategy's withdrawalSlippage in your backend's position record. Keep null explicitly when no floor is supported. A strategy can leave the catalog while its positions remain withdrawable; use this saved policy when building an exit.

JSONResponse (trimmed)
{
  "data": {
    "deposit": {
      "movementId": "earn_movement_505a6c34-f804-4abd-b6a9-315d031bdf1f",
      "positionId": "earn_position_2f8b6b04-7147-487e-b760-b1caa4fbcb8b",
      "direction": "deposit",
      "status": "submitted",
      "settlement": "atomic",
      "signature": "3dhkSPLiQZQPXL96fWzMdWJtdbtAffkBKXw2xhsYbV3VNSnYrVAcdx1czsnLsE1ou4SvLecGdSYe4QpAuZdfjJAf",
      "amount": "25",
      "tokenMint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
      "tokenAmount": null,
      "replayed": false
    }
  }
}

On some strategies a deposit's tokenAmount stays null until finalized, so read the deposited amount there. If the response is lost or times out, send the same body with the same idempotency key. SDP returns the original deposit, in its current state, with replayed: true instead of depositing twice. Retry safely covers the other cases.

6. Track it to finality

Poll GET /v1/earn/external-wallet/movements/{movementId} until the status is finalized or failed.

MOVEMENT_ID="$(jq -r '.data.deposit.movementId' deposit.json)"
# Every second for a minute, then every five seconds, for up to three minutes.
for attempt in $(seq 84); do
  curl --fail-with-body -sS "${SDP_API_URL:?Set SDP_API_URL}/v1/earn/external-wallet/movements/$MOVEMENT_ID" \
    -H "Authorization: Bearer $SDP_API_KEY" > movement.json
  jq -e '.data.movement.status | IN("finalized", "failed")' movement.json >/dev/null && break
  if [ "$attempt" -le 60 ]; then sleep 1; else sleep 5; fi
done
jq '.data.movement | {status, settlement, tokenAmount, failureReason}' movement.json
import { waitForMovement } from "./earn-api";

const movement = await waitForMovement(deposit.movementId);
if (movement.status === "failed") throw new Error(movement.failureReason ?? "Deposit failed");
JSONResponse (trimmed)
{
  "data": {
    "movement": {
      "movementId": "earn_movement_505a6c34-f804-4abd-b6a9-315d031bdf1f",
      "direction": "deposit",
      "status": "finalized",
      "settlement": "atomic",
      "amount": "25",
      "tokenAmount": "25",
      "tokenMint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
      "failureReason": null
    }
  }
}

With settlement: "atomic", you may show the deposit as done at confirmed while you keep tracking to finalized. A provider_order deposit stays confirmed until the provider completes it, so treat it as done only at finalized; see How a movement settles.

7. Read the position

GET /v1/earn/external-wallet/positions returns the wallet's live positions.

curl --fail-with-body -sS --get "${SDP_API_URL:?Set SDP_API_URL}/v1/earn/external-wallet/positions" \
  -H "Authorization: Bearer $SDP_API_KEY" \
  --data-urlencode "ownerAddress=$OWNER_ADDRESS"
const { positions } = await earnRequest(
  `/v1/earn/external-wallet/positions?${new URLSearchParams({ ownerAddress })}`,
);
console.log(JSON.stringify(positions, null, 2));
JSONResponse (trimmed)
{
  "data": {
    "ownerAddress": "Af89Mkuvo6AcLDbs7Fxs1FPsnnbD9KrY9NfmaFXHqmPo",
    "positions": [
      {
        "id": "earn_position_2f8b6b04-7147-487e-b760-b1caa4fbcb8b",
        "label": "Kamino Vault USDC",
        "tokenMint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
        "shares": "25",
        "withdrawableShares": "25",
        "tokenValue": "25"
      }
    ],
    "hasMore": false,
    "nextCursor": null
  }
}

Show a missing shares or tokenValue as unavailable, never as zero. Balances and earnings covers earnings, history, and pagination.

Errors

StatusCodeWhenWhat to do
400BAD_REQUESTThe submit has no Idempotency-Key, or the signed transaction differs from the build, or a signature is missing or invalid.Send a key. Otherwise build again and sign the exact bytes.
403INSUFFICIENT_PERMISSIONSThe API key lacks the grant the route needs, named in details.action.Use an API key with earn.execute.
403FORBIDDENdetails.reason is provider_not_entitled: your organization is not activated for the provider.Ask your SDP contact to activate it.
404NOT_FOUNDSDP has no build with that transactionId.Check the ID, or build again.
409CONFLICTThe idempotency key was already used for a different build, or the same build arrives under a new idempotency key.Retry with the original build and idempotency key. Use a new idempotency key only for a new deposit.

These codes replace the ones on the shared Idempotency page; see Retry safely.

Next, withdraw funds or ship Embedded Yield.