> ## Documentation Index
> Fetch the complete documentation index at: https://docs.turtle.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Withdraw Funds

> Recover reward tokens a token-based stream no longer needs to keep

<Note>
  All requests require an API key via the `X-API-Key` header. This endpoint requires a secret key (`sk_live_`) and must be called from your backend.
  See [Authentication](/sdk/authentication/api-keys) for details.
</Note>

<Warning>
  To withdraw funds, the organization attached to the API key must have the `organization:incentivize:streams` permission. If your organization does not have that permission enabled, contact the Turtle team to request it.
</Warning>

## Overview

`POST /v2/streams/{id}/withdrawals` signs a withdrawal of the reward tokens a stream holds beyond what it must keep to pay its rewards.

The endpoint does not move any funds. It validates the request against the stream's on-chain balances, reserves the withdrawal, and returns `txParams` with the backend-signed authorization that the stream admin wallet must submit to `withdrawFunds` on the corresponding `StreamFactory` before the signature expires.

Withdrawals are only available on token-based streams. Point-based streams hold no on-chain funds.

<Info>
  A stream accepts only one admin operation (a withdrawal or a budget increase) at a time. While a signed withdrawal is pending, the stream rejects new ones. Use [Get Active Admin Operation](/sdk/streams/get-active-admin-operation) to see which operation is blocking a stream, until when, and to recover its `txParams` if your client lost them.
</Info>

## How Much Can Be Withdrawn

The withdrawable amount is computed from the stream contract's on-chain balance at request time:

| Value | Definition |
| - | - |
| Total funded | Reward tokens the stream contract holds, plus the rewards users have already claimed from it |
| Required funded | What the stream must keep to cover its rewards (see below) |
| Max withdrawable | Total funded minus required funded |

The required amount depends on whether the stream has finished processing:

* **Stream still running:** the stream must keep its full reward budget (`totalAmount`). Only the surplus above that budget can be withdrawn, for example tokens sent directly to the stream contract.
* **Stream fully processed:** once the stream's last snapshot reaches its `endTimestamp`, or the stream has distributed its whole budget, it only needs to keep what it has already distributed. The undistributed leftover becomes withdrawable.

If you omit `amount`, the API withdraws the full max withdrawable amount. If nothing can be withdrawn, the request is rejected.

## Endpoint

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST "https://earn.turtle.xyz/v2/streams/550e8400-e29b-41d4-a716-446655440000/withdrawals" \
    -H "X-API-Key: sk_live_xxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "to": "0x1111111111111111111111111111111111111111",
      "amount": "1500000000",
      "signatureExpirationSeconds": 3600
    }'
  ```

  ```typescript TypeScript theme={null}
  const streamId = '550e8400-e29b-41d4-a716-446655440000';

  const response = await fetch(
    `https://earn.turtle.xyz/v2/streams/${streamId}/withdrawals`,
    {
      method: 'POST',
      headers: {
        'X-API-Key': process.env.TURTLE_SECRET_KEY!,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        to: '0x1111111111111111111111111111111111111111',
        amount: '1500000000',
        signatureExpirationSeconds: 3600,
      }),
    }
  );

  const data = await response.json();
  ```
</CodeGroup>

To withdraw everything that can be withdrawn to the stream admin, send an empty body:

```bash theme={null}
curl -X POST "https://earn.turtle.xyz/v2/streams/550e8400-e29b-41d4-a716-446655440000/withdrawals" \
  -H "X-API-Key: sk_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Parameters

**Path Parameters**

<ParamField path="id" type="uuid" required>
  Stream identifier, as returned in `id` by [Get Streams](/sdk/streams/get-streams). The stream must belong to the organization attached to the API key.
</ParamField>

**Request Body**

<ParamField body="to" type="string">
  EVM address that receives the withdrawn tokens. Defaults to the stream admin (`stream.admin`).
</ParamField>

<ParamField body="amount" type="string">
  Amount to withdraw, in the reward token's smallest unit (for example, `1500000000` is 1,500 USDC with 6 decimals). Must be positive and no greater than the max withdrawable amount. Defaults to the max withdrawable amount.
</ParamField>

<ParamField body="signatureExpirationSeconds" type="integer" default={300}>
  How long the returned signature stays valid, in seconds. Accepts values from `300` (5 minutes, the default) to `86400` (one day). Raise it when the stream admin is a multisig that needs time to collect owner signatures. The stream accepts no other withdrawal or budget increase until the signature expires, or until this withdrawal executes and its transaction is final.
</ParamField>

## Response Example

```json theme={null}
{
  "message": "Successfully generated withdraw funds signature",
  "txParams": {
    "chainId": 8453,
    "sender": "0x1111111111111111111111111111111111111111",
    "params": {
      "params": {
        "stream": "0x2222222222222222222222222222222222222222",
        "to": "0x1111111111111111111111111111111111111111",
        "amount": "1500000000"
      },
      "salt": "0x3f9a6c2e1b7d4f8a0c5e9b2d6f1a4c8e7b3d0f5a9c2e6b1d4f8a7c3e0b5d9f2a",
      "deadline": 1790859600,
      "signature": "MEUCIA...base64..."
    }
  }
}
```

## Response Fields

<ResponseField name="message" type="string" required>
  Status message.
</ResponseField>

<ResponseField name="txParams" type="object" required>
  Signed withdrawal payload. Submit it to `withdrawFunds` on the `StreamFactory` for `txParams.chainId`.
</ResponseField>

<ResponseField name="txParams.chainId" type="integer" required>
  Chain where the stream lives and where the transaction must be submitted.
</ResponseField>

<ResponseField name="txParams.sender" type="string" required>
  Stream admin wallet. It is the only wallet that can submit the transaction.
</ResponseField>

<ResponseField name="txParams.params.params.stream" type="string" required>
  Stream contract address.
</ResponseField>

<ResponseField name="txParams.params.params.to" type="string" required>
  Recipient of the withdrawn tokens.
</ResponseField>

<ResponseField name="txParams.params.params.amount" type="string" required>
  Amount to withdraw in the reward token's smallest unit, in decimal-string form to preserve precision.
</ResponseField>

<ResponseField name="txParams.params.salt" type="string" required>
  Unique 32-byte value (hex) that makes the signature single-use.
</ResponseField>

<ResponseField name="txParams.params.deadline" type="integer" required>
  Unix timestamp, in seconds, after which the signature is no longer accepted on-chain. Returned as a JSON number.
</ResponseField>

<ResponseField name="txParams.params.signature" type="string" required>
  EIP-712 signature bytes serialized as base64 in JSON.
</ResponseField>

<Note>
  Unlike the [Create Stream](/sdk/streams/create-stream) payload, the keys inside `txParams.params.params` are camelCase (`stream`, `to`, `amount`), and `txParams.params.deadline` is a JSON number rather than a string.
</Note>

## Broadcast the Withdrawal Transaction

The backend does not return a serialized raw transaction. It returns a signed authorization that the stream admin uses to call `withdrawFunds` on the `StreamFactory` for the stream's chain. No token approval is needed: the factory moves the tokens out of the stream contract.

The wallet that sends the transaction must:

* match `txParams.sender`
* be connected to `txParams.chainId`
* submit the transaction before `txParams.params.deadline`

Use the `StreamFactory` address for `txParams.chainId` from [StreamFactory addresses by chain](/sdk/streams/create-stream#streamfactory-addresses-by-chain).

### TypeScript example

Request the withdrawal from your backend with the secret key, then pass `txParams` to the admin wallet:

```typescript theme={null}
import { ethers } from 'ethers';

const STREAM_FACTORY_BY_CHAIN: Record<number, string> = {
  1: '0xf44399a74ee5ddef7fa3d064cf66b011ee4a6cae',
  56: '0x298d2967588b5c93a137ce1a05d0b8cfffb3c120',
  43114: '0x4559605e3003fda8c059e14af4f16ba9a004335a',
  8453: '0x4559605e3003fda8c059e14af4f16ba9a004335a',
  11155111: '0xdfdff939d728585ce8a2cf2d4166f043d917d8d2',
};

const STREAM_FACTORY_ABI = [
  'function withdrawFunds((address stream,address to,uint256 amount) params,bytes32 salt,uint40 deadline,bytes signature) external',
];

type WithdrawFundsTxParams = {
  chainId: number;
  sender: string;
  params: {
    params: { stream: string; to: string; amount: string };
    salt: string;
    deadline: number;
    signature: string;
  };
};

function base64ToBytes(value: string): Uint8Array {
  return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
}

// txParams comes from your backend, which called
// POST /v2/streams/{id}/withdrawals with the secret key.
async function submitWithdrawal(txParams: WithdrawFundsTxParams) {
  const streamFactoryAddress = STREAM_FACTORY_BY_CHAIN[txParams.chainId];

  if (!streamFactoryAddress) {
    throw new Error(`Unsupported StreamFactory for chainId ${txParams.chainId}`);
  }

  if (Date.now() / 1000 >= txParams.params.deadline) {
    throw new Error('The withdrawal signature has expired; request a new one');
  }

  const provider = new ethers.BrowserProvider(window.ethereum);
  await provider.send('eth_requestAccounts', []);

  const signer = await provider.getSigner();
  const signerAddress = await signer.getAddress();
  const connectedChainId = Number((await provider.getNetwork()).chainId);

  if (signerAddress.toLowerCase() !== txParams.sender.toLowerCase()) {
    throw new Error(`Connected wallet ${signerAddress} is not the stream admin ${txParams.sender}`);
  }

  if (connectedChainId !== txParams.chainId) {
    throw new Error(`Connected chain ${connectedChainId} does not match txParams.chainId ${txParams.chainId}`);
  }

  const streamFactory = new ethers.Contract(
    streamFactoryAddress,
    STREAM_FACTORY_ABI,
    signer,
  );

  const withdrawTx = await streamFactory.withdrawFunds(
    {
      stream: txParams.params.params.stream,
      to: txParams.params.params.to,
      amount: txParams.params.params.amount,
    },
    txParams.params.salt,
    txParams.params.deadline,
    base64ToBytes(txParams.params.signature),
  );

  const receipt = await withdrawTx.wait();

  console.log('Withdrawal tx hash:', receipt?.hash);
}
```

<Tip>
  If the user closes the page or dismisses the wallet prompt before submitting, do not request a new withdrawal: the stream stays blocked by the first one. Call [Get Active Admin Operation](/sdk/streams/get-active-admin-operation) to get the same `txParams` back and submit them before the deadline.
</Tip>

## One Operation at a Time

Each stream accepts a single admin operation in flight. A withdrawal signed by this endpoint, or a budget increase made outside the Earn API, blocks new withdrawals on the same stream until the earlier of:

* the signature deadline, plus a one-minute tolerance for chain clock skew
* the moment the transaction that executed it becomes final on the stream's chain

This prevents two signed operations from both drawing on the same balance. A request made while another operation is blocking the stream returns `403` with the time it stops blocking. See [Get Active Admin Operation](/sdk/streams/get-active-admin-operation) for the exact `blockingUntil` value and the finality window per chain.

## Operational Notes

<AccordionGroup>
  <Accordion title="Withdrawals are not broadcast automatically">
    The endpoint does not submit any transaction. It returns the payload required to call `withdrawFunds` on the `StreamFactory`. Until the stream admin submits it, no funds leave the stream contract.
  </Accordion>

  <Accordion title="Only the stream admin can submit the transaction">
    The signature is bound to the stream admin (`txParams.sender`). Any other wallet calling `withdrawFunds` with it is rejected by the contract. The `to` address only decides who receives the tokens.
  </Accordion>

  <Accordion title="Use a longer expiration for multisig admins">
    The default signature lifetime of 5 minutes suits a single-key wallet. For a multisig such as a Safe, set `signatureExpirationSeconds` up to `86400` so the owners have time to sign. Keep in mind that the stream accepts no other withdrawal or budget increase for that whole window, unless the withdrawal executes and becomes final first.
  </Accordion>

  <Accordion title="Each signature can be used once">
    The `salt` makes every signature single-use. After the withdrawal executes, submitting the same `txParams` again reverts on-chain. Request a new withdrawal once the stream is free if you need to withdraw more.
  </Accordion>

  <Accordion title="The stream must be confirmed on-chain">
    A token-based stream can only be withdrawn from after its creation transaction is confirmed and `stream.contractAddress` is set. See [Create Stream](/sdk/streams/create-stream#operational-notes).
  </Accordion>

  <Accordion title="Concurrent requests on the same stream">
    The stream is locked while a withdrawal request is being processed. A second request for the same stream that arrives at the same time returns `409 Conflict`. Wait a few seconds and retry.
  </Accordion>

  <Accordion title="A valid API key alone is not enough">
    This endpoint requires three things at the same time: a valid `X-API-Key` header, a secret key, and the `organization:incentivize:streams` permission on the organization attached to that key.
  </Accordion>
</AccordionGroup>

## Error Handling

<AccordionGroup>
  <Accordion title="Missing or invalid API key">
    **Status Code:** 401 Unauthorized

    ```json theme={null}
    {
      "error": "API key required. Pass it via the X-API-Key header."
    }
    ```

    A key that is present but invalid, inactive, or expired returns `401` as well, with `"Invalid API key"`, `"API key is inactive"`, or `"API key has expired"`.

    **Solution:** Pass a valid `X-API-Key` header.
  </Accordion>

  <Accordion title="Publishable key used">
    **Status Code:** 403 Forbidden

    ```json theme={null}
    {
      "error": "This endpoint requires a secret API key"
    }
    ```

    **Solution:** Call this endpoint from your backend with a secret key (`sk_live_`).
  </Accordion>

  <Accordion title="Permission denied">
    **Status Code:** 403 Forbidden

    ```json theme={null}
    {
      "error": {
        "status": "PERMISSION_DENIED",
        "error": "organization lacks required permission: organization:incentivize:streams"
      }
    }
    ```

    **Solution:** Use an API key associated with an organization that has the `organization:incentivize:streams` permission.
  </Accordion>

  <Accordion title="Another operation is blocking the stream">
    **Status Code:** 403 Forbidden

    ```json theme={null}
    {
      "error": {
        "status": "PERMISSION_DENIED",
        "error": "failed to validate withdraw: stream has a pending operation blocking new ones until 2026-10-01T13:01:00Z at the latest"
      }
    }
    ```

    **Solution:** Call [Get Active Admin Operation](/sdk/streams/get-active-admin-operation). If the operation has not been submitted yet (`confirmedTxHash` is `null`) and `txParams.params.deadline` has not passed, submit its `txParams`. Otherwise, wait until `blockingUntil` and retry.
  </Accordion>

  <Accordion title="Invalid request">
    **Status Code:** 400 Bad Request

    ```json theme={null}
    {
      "error": {
        "status": "INVALID_ARGUMENT",
        "error": "failed to validate withdraw: requested withdrawal of 2000000000 exceeds the max withdrawable amount of 1500000000"
      }
    }
    ```

    **Common causes:**

    * `amount` is greater than the max withdrawable amount
    * `amount` is zero or negative
    * the stream has nothing to withdraw (`maxWithdrawableAmount is non-positive`)
    * the stream is point-based (`withdrawals are only supported on reward-token streams`)
    * `signatureExpirationSeconds` is outside `300`–`86400`
  </Accordion>

  <Accordion title="Stream not found">
    **Status Code:** 404 Not Found

    This happens when the `id` path parameter does not correspond to a stream owned by the organization attached to the API key.
  </Accordion>

  <Accordion title="Stream busy">
    **Status Code:** 409 Conflict

    ```json theme={null}
    {
      "error": {
        "status": "ABORTED",
        "error": "stream is busy with another operation; retry shortly"
      }
    }
    ```

    **Solution:** Another request is processing the same stream. Retry after a few seconds.
  </Accordion>

  <Accordion title="Rate limit exceeded">
    **Status Code:** 429 Too Many Requests

    ```json theme={null}
    {
      "error": "Rate limit exceeded"
    }
    ```

    Usage is metered per key, hourly and monthly. Every response carries the hourly budget in the `X-RateLimit-*` headers; keys with a monthly limit also get `X-Monthly-*` headers. A monthly overrun returns `"Monthly usage limit exceeded"` instead.

    **Solution:** Back off until the window resets — hourly limits also return `Retry-After` — or ask the Turtle team for a higher limit. See [Rate limits](/sdk/authentication/api-keys#rate-limits).
  </Accordion>

  <Accordion title="Unexpected internal error">
    **Status Code:** 500 Internal Server Error

    **Solution:** Retry the request and contact Turtle if the issue persists.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.