{/* Keep this guide and the dashboard integration snippets (apps/sdp-web/src/app/dashboard/markets/earn/earn-integration-snippets.ts) in sync: they document the same flow. */}

Embedded Yield lets your platform offer vault yield to your end users without SDP ever holding their funds. Your backend asks SDP to **build** an unsigned Solana transaction for the customer's own wallet, the customer signs it in your app, and your backend **submits** the signed bytes back. SDP verifies the signature, records the movement, then broadcasts it. Withdrawals work the same way in reverse.

Two properties define the surface:

- **Non-custodial.** SDP holds no key for your customers' wallets. The customer's signature is the authorization, every time.
- **Server-side.** Your SDP API key stays on your backend. The browser or mobile app only ever sees unsigned transaction bytes and returns signed ones.

All endpoints live under `/v1/earn`. Authenticate with `Authorization: Bearer <SDP_API_KEY>`; the key needs the `earn:read` and `earn:write` permissions. Start in sandbox, which uses Solana devnet. A sandbox strategy is depositable only when its `hostCluster` is `devnet`, `fundable` is `true`, and `status` is `active`.

## The endpoints at a glance

| Step | Endpoint |
| --- | --- |
| Discover strategies | `GET /v1/earn/strategies` |
| Preview a direct deposit | `POST /v1/earn/vault-deposit-previews` |
| Build a deposit | `POST /v1/earn/external-wallet/deposit-transactions` |
| Submit the signed deposit | `POST /v1/earn/external-wallet/deposits` |
| Poll a movement | `GET /v1/earn/external-wallet/movements/{movementId}` |
| Balance and earnings | `GET /v1/earn/external-wallet/earnings?ownerAddress=…` |
| Activity feed | `GET /v1/earn/external-wallet/movements?ownerAddress=…` |
| Live positions | `GET /v1/earn/external-wallet/positions?ownerAddress=…` |
| Portfolio totals | `GET /v1/earn/external-wallet/positions/summary` |
| Preview a withdrawal | `POST /v1/earn/external-wallet/withdrawal-previews` |
| Build a withdrawal | `POST /v1/earn/external-wallet/withdrawal-transactions` |
| Submit the signed withdrawal | `POST /v1/earn/external-wallet/withdrawals` |

Every per-owner read addresses the customer's wallet the same way: a required `ownerAddress` query parameter.

## 1. Pick a strategy

`GET /v1/earn/strategies` returns the catalogue, ranked by deposit size, with the live APY your UI can show. Each row carries the `strategyId` the deposit build takes.

Two fields gate what you may offer:

- **`fundable`** answers whether the instrument exists on your environment's cluster. `false` is definitive. `true` is necessary but not sufficient: the strategy must also be `active`, and your organization entitled to the provider.
- **`status`** is the catalogue lifecycle; only `active` strategies accept new deposits. Positions in a strategy that later pauses stay fully withdrawable: money out is never gated by money-in rules.

Each row also carries **`depositSlippage`** and **`withdrawalSlippage`**. Non-null means that direction's builder refuses to run without an explicit protection floor (`minSharesOut` on deposits, `minAmountOut` on withdrawals): quote first, then derive the floor from the live figure minus the tolerance your customer chose (`defaultToleranceBps` is a suggested start). Null means the floor is optional there.

Jupiter Lend currently contributes one strategy: its mainnet USDT Earn market. Because Jupiter Lend is mainnet-only, the strategy can appear in a sandbox catalogue as a browse-only row with `fundable: false`; do not offer a deposit action for that row. Deposits mint jlUSDT receipt tokens to the signing wallet, and withdrawals redeem those receipt tokens back to USDT when protocol liquidity is available.

## 2. Deposit: build, sign, submit, poll

```ts
const SDP_API_URL = "https://api.solana.com";
// For a local SDP API, use the configured base URL, usually http://127.0.0.1:8787.

function sdpHeaders(extra: Record<string, string> = {}) {
  const apiKey = process.env.SDP_API_KEY;
  if (!apiKey) throw new Error("SDP_API_KEY is required");
  return {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
    ...extra,
  };
}

async function sdpFetch(path: string, init?: RequestInit) {
  const response = await fetch(`${SDP_API_URL}${path}`, init);
  const result = await response.json().catch(() => null);
  if (!response.ok) {
    const code = result?.error?.code ? ` ${result.error.code}` : "";
    const message = result?.error?.message ?? "Request failed";
    throw new Error(`SDP ${response.status}${code}: ${message}`);
  }
  return result.data;
}

/** Exact decimal floor without a JavaScript number round-trip. */
function floorForTolerance(quote: string, decimals: number, toleranceBps: number) {
  if (!Number.isInteger(toleranceBps) || toleranceBps < 1 || toleranceBps > 1_000) {
    throw new Error("slippage tolerance must be 1-1000 basis points");
  }
  const [whole, fraction = ""] = quote.split(".");
  if (!/^\d+$/.test(whole ?? "") || !/^\d*$/.test(fraction) || fraction.length > decimals) {
    throw new Error("provider quote is not a valid decimal at the reported mint scale");
  }
  const atoms = BigInt((whole ?? "0") + fraction.padEnd(decimals, "0"));
  if (atoms === 0n) throw new Error("provider quote returned zero output");
  const floored = (atoms * BigInt(10_000 - toleranceBps)) / 10_000n || 1n;
  const digits = floored.toString().padStart(decimals + 1, "0");
  if (decimals === 0) return digits;
  const wholeResult = digits.slice(0, -decimals);
  const fractionResult = digits.slice(-decimals).replace(/0+$/, "");
  return fractionResult ? `${wholeResult}.${fractionResult}` : wholeResult;
}
```

**Build.** SDP simulates the deposit before handing anything out, so a wallet that cannot fund it fails here with a readable 400 instead of a landed, failed transaction.

```ts
let minSharesOut: string | undefined;
if (strategy.depositSlippage?.quoteRequired) {
  const quote = await sdpFetch("/v1/earn/vault-deposit-previews", {
    method: "POST",
    headers: sdpHeaders(),
    body: JSON.stringify({ strategyId: strategy.id, amount: "25" }),
  });
  if (quote.blockingIssues.length > 0) {
    throw new Error(quote.blockingIssues.map((issue: { message: string }) => issue.message).join("; "));
  }
  minSharesOut = floorForTolerance(
    quote.sharesOut,
    quote.shareDecimals,
    strategy.depositSlippage.defaultToleranceBps,
  );
}

const { transaction } = await sdpFetch("/v1/earn/external-wallet/deposit-transactions", {
  method: "POST",
  headers: sdpHeaders(),
  body: JSON.stringify({
    strategyId: strategy.id,
    ownerAddress, // the customer's wallet
    amount: "25", // decimal string in the vault token's units
    sourceTokenMint: strategy.depositMints[0], // shortest path: no swap
    ...(minSharesOut ? { minSharesOut } : {}),
  }),
});
// { transactionId, transaction, lastValidBlockHeight, ... }
```

**Sign.** Hand the base64 `transaction.transaction` to the customer's wallet, for example with wallet-adapter:

```ts
const tx = VersionedTransaction.deserialize(Buffer.from(transaction.transaction, "base64"));
const signed = await wallet.signTransaction(tx);
const signedTransaction = Buffer.from(signed.serialize()).toString("base64");
```

A built transaction expires with its blockhash (about a minute). If the customer walks away, build a fresh one; the expired build is inert.

**Submit.** The `Idempotency-Key` header is required. Reuse the same key when retrying: a retry returns the original movement with `replayed: true` instead of moving money twice, and each built transaction is consumable exactly once.

```ts
const { deposit } = await sdpFetch("/v1/earn/external-wallet/deposits", {
  method: "POST",
  headers: sdpHeaders({ "Idempotency-Key": idempotencyKey }),
  body: JSON.stringify({ transactionId: transaction.transactionId, signedTransaction }),
});
// { movementId, positionId, status, signature, ... }
```

**Poll.** Each poll of the movement detail reads the transaction's exact signature on chain and advances the recorded status immediately, so state lands as fast as the network decides it. A background reconciler covers RPC outages and expiry.

```ts
const { movement } = await sdpFetch(
  `/v1/earn/external-wallet/movements/${encodeURIComponent(deposit.movementId)}`,
  { headers: sdpHeaders() }
);
```

Statuses are `requested`, `submitted`, `confirmed`, `finalized`, `failed`. Only `finalized` and `failed` are terminal; `confirmed` is optimistic and can still be dropped by a fork, so keep polling past it.

## 3. Who pays fees and rent

**By default, the customer's wallet pays everything**: the network fee, plus about 0.002 SOL of rent when a first deposit creates their share token account (refunded when the position fully exits). A customer holding only stablecoins cannot complete that transaction.

**To pay on your customers' behalf, name a `feePayer` on the build.** The named wallet, one you control, becomes the transaction's fee payer and funds the account rent, and the built transaction then requires its signature alongside the customer's. Co-sign server-side after the customer signs, in either order, then submit:

```ts

// 1. Build with your sponsor wallet as the fee payer.
const { transaction } = await sdpFetch("/v1/earn/external-wallet/deposit-transactions", {
  method: "POST",
  headers: sdpHeaders(),
  body: JSON.stringify({ strategyId, ownerAddress, amount, minSharesOut, feePayer: SPONSOR_ADDRESS }),
});

// 2. The customer signs in your app, exactly as before.

// 3. Co-sign with the sponsor's key on your backend, then submit.
const decoded = getTransactionDecoder().decode(Buffer.from(customerSignedTransaction, "base64"));
const coSigned = await partiallySignTransaction([sponsorKeyPair], decoded);
const signedTransaction = getBase64EncodedWireTransaction(coSigned);
```

What to know when sponsoring:

- **Both signatures are required.** The submit verifies every signature and refuses a missing or invalid fee-payer signature by name, so a co-signing bug on your side is distinguishable from a customer-side one.
- **The fee payer is committed at build time.** It is inside the signed message, so it cannot be swapped at submit; rebuild if you need a different sponsor.
- **Keep a SOL float.** The sponsor wallet pays the fee on every sponsored transaction and the rent on every first deposit. The build simulates with your sponsor as the payer and refuses with a 400 naming it when it cannot pay.
- **Rent comes back to you.** SDP records your sponsor as the rent funder, and a full exit refunds the share account's rent to the recorded funder, not to the customer.
- **Mind the clock.** Build, customer signature, your co-signature, and submit must all fit inside the blockhash window (roughly a minute). Co-sign programmatically, not through a manual approval step.
- Passing the customer's own address as `feePayer` simply means the default. Program-derived addresses cannot sign, so an off-curve fee payer can never be submitted.
- Applies to `withdrawal-transactions` identically, and to the standalone swap transaction of a split swap-funded deposit, so every transaction the flow hands out can be sponsored.

The Treasury surface uses SDP's configured paymaster when Earn sponsorship is enabled for the cluster. Otherwise the organization custody wallet pays its own fee and rent. This is separate from the partner-controlled `feePayer` on Embedded Yield builds.

## 4. Show balances and activity

- `GET /v1/earn/external-wallet/earnings?ownerAddress=…` returns balance and total earned per deposit token. `earned` is stated only when exact; otherwise it is absent with a named `earnedUnavailableReason`. Render a dash for an absent figure, never $0.
- `GET /v1/earn/external-wallet/movements?ownerAddress=…` is the customer's activity feed, newest first, keyset-paged via `before`.
- `GET /v1/earn/external-wallet/positions?ownerAddress=…` lists live positions. Page it to completion; a silently short list hides withdrawable money. Read `id` and `withdrawableShares` here to drive withdrawals. If live RPC hydration fails, `shares`, `withdrawableShares`, and `tokenValue` are absent, never zero. Show an unavailable state and disable withdrawal until a fresh read returns the ceiling.
- `GET /v1/earn/external-wallet/positions/summary` aggregates your whole project across customers, by strategy and token.

## 5. Withdraw

Same build-sign-submit shape. The build names the customer's **position** (never a strategy), so exits keep working even when a vault is delisted or its provider is disabled for new deposits.

When the strategy's `withdrawalSlippage` is non-null (or whenever you want a floor), preview first and derive `minAmountOut` from the live quote:

```ts
// What would these shares pay right now? Read-only; nothing is built.
const quote = await sdpFetch("/v1/earn/external-wallet/withdrawal-previews", {
  method: "POST",
  headers: sdpHeaders(),
  body: JSON.stringify({ positionId, shares }),
});
// quote: { assetsOut, assetDecimals, blockingIssues } — floor = assetsOut minus
// your customer's tolerance, quantized to assetDecimals.

const { transaction } = await sdpFetch("/v1/earn/external-wallet/withdrawal-transactions", {
  method: "POST",
  headers: sdpHeaders(),
  body: JSON.stringify({ positionId, shares, minAmountOut }), // + feePayer to sponsor it
});
// customer signs (and your sponsor co-signs, if sponsoring) → submit:
const { withdrawal } = await sdpFetch("/v1/earn/external-wallet/withdrawals", {
  method: "POST",
  headers: sdpHeaders({ "Idempotency-Key": idempotencyKey }),
  body: JSON.stringify({ transactionId: transaction.transactionId, signedTransaction }),
});
```

## Swap-funded deposits

Pass `sourceTokenMint` (USDC, USDG, PYUSD or USDT on your cluster) to accept a stablecoin the vault does not take: SDP prepends a Jupiter swap inside the same transaction, `amount` becomes the source amount, and the response's `transaction.swap` reports the derived deposit.

If the composed transaction cannot fit one Solana packet, the response is `{ requiresSeparateSwap: true, swap, followUp }` instead: have the wallet sign `swap.transaction` and broadcast it yourself, then build again with the `followUp` body, which echoes your original `minSharesOut` and `feePayer` so the follow-up keeps the same protections.

## Idempotency, in one place

| Call | Rule |
| --- | --- |
| Builds | No key. A build moves no money and expires with its blockhash. |
| Submits | `Idempotency-Key` header required. Same key on retry returns the original movement (`replayed: true`); a different key against an already-consumed build is a 409. |

A `400` means the request or current vault state must change before retrying. A `503 PROVIDER_UNAVAILABLE` means live provider or RPC state could not be read; retry the read-only preview or build with bounded backoff. For an uncertain submit response, retry the exact signed transaction with the same idempotency key.

Full request and response shapes: [Earn API reference](/docs/reference/api/earn).