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

# Two-Stage Withdrawal

> User-signed EIP-712 withdrawal authorizations: stage-1 typed-data preparation, user signature, stage-2 submission, and async operation polling.

Withdrawing funds from a portfolio requires an authorization signed by the
end-user's owner wallet. Stage 1 prepares the full EIP-712 typed-data
envelope; the user signs it in their wallet; stage 2 submits the signature
and dispatches the onchain transfer. Stage 2 responds `202` and returns an
`operationId` you poll for onchain confirmation.

* Scope: `portfolios:withdraw` on both stages.
* Idempotency anchor: `message.nonce` returned by stage 1 (also surfaced as
  `authorizationId`).
* Authorization TTL: 10 minutes from stage-1 response.
* One request = one recipient on one chain. Multi-chain withdrawals require
  separate authorizations.

## Sequence

```
Integrator          Glider API                 End-user Wallet
    |                    |                           |
    |--1. POST /v2/portfolios/{id}/withdraw/signature
    |                    |                           |
    |<--200 { typedData, authorizationId, expiresAt }
    |                    |                           |
    |--2. signTypedData(typedData)----------------->|
    |                    |                           |
    |<--signature--------|---------------------------|
    |                    |                           |
    |--3. POST /v2/portfolios/{id}/withdraw
    |      { message: typedData.message, signature }
    |                    |                           |
    |<--202 { operationId, submittedAt }-------------|
    |                    |                           |
    |--4. GET /v2/portfolios/{id}/operations/{opId}  (poll 2–5s)
    |                    |                           |
    |<--200 { state: "completed" }-------------------|
```

## Stage 1 — Prepare the authorization

`POST /v2/portfolios/{portfolioId}/withdraw/signature`

Validates the request, checks live onchain balances, and returns the full
EIP-712 typed-data envelope along with a nonce and 10-minute expiry.

```bash theme={null}
curl -X POST https://api.glider.fi/v2/portfolios/pf_01.../withdraw/signature \
  -H 'x-api-key: gldr_sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "recipientAccountId": "eip155:1:0x4444444444444444444444444444444444444444",
    "assets": [
      {
        "assetId": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
        "amountRaw": "1000500000"
      }
    ]
  }'
```

Response:

```json theme={null}
{
  "success": true,
  "data": {
    "authorizationId": "0xdeadbeef...deadbeef",
    "expiresAt": "2026-04-17T12:10:00.000Z",
    "typedData": {
      "primaryType": "Withdrawal",
      "domain": {
        "name": "Glider Withdrawal Authorization",
        "version": "1",
        "chainId": 1,
        "verifyingContract": "0x2222222222222222222222222222222222222222"
      },
      "types": {
        "Withdrawal": [
          { "name": "portfolioId", "type": "string" },
          { "name": "recipientAccountId", "type": "string" },
          { "name": "assets", "type": "WithdrawalAsset[]" },
          { "name": "nonce", "type": "bytes32" },
          { "name": "expiresAt", "type": "uint256" }
        ],
        "WithdrawalAsset": [
          { "name": "assetId", "type": "string" },
          { "name": "amountRaw", "type": "string" }
        ]
      },
      "message": {
        "portfolioId": "pf_01JWZG3KH9P4N5QXJVNK7M3WTV",
        "recipientAccountId": "eip155:1:0x4444444444444444444444444444444444444444",
        "assets": [
          {
            "assetId": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
            "amountRaw": "1000500000"
          }
        ],
        "nonce": "0xdeadbeef...deadbeef",
        "expiresAt": 1776384600
      }
    }
  }
}
```

### Exceptions to the v2 conventions

Inside the signed envelope only, two fields break the usual v2 naming rules
because EIP-712 requires them:

* `domain.verifyingContract` is a **bare EVM address**, not a CAIP-10. This
  is the value the user's wallet renders during signing.
* `message.expiresAt` is **Unix seconds as a number**, not an ISO-8601
  string. Typed-data hashing is binary.

Do not propagate these exceptions outside the envelope. The response-level
`expiresAt` at `data.expiresAt` is still an ISO-8601 string.

### Recipient rules

* Must be **chain-bound** CAIP-10 (`eip155:<chainId>:<addr>`). Chain-agnostic
  form is rejected with `API_211`.
* The recipient chain must match every asset's chain. Otherwise `API_213`.
* The portfolio must have a smart account on the recipient's chain. Otherwise
  `API_215`.

## Stage 2 — Have the user sign, then submit

The user signs `typedData` with `signTypedData` in their wallet. Pass the
full object directly — viem, ethers, wagmi, and privy all accept the
standard shape:

```ts theme={null}
// viem
const signature = await walletClient.signTypedData(typedData);

// ethers v6
const signature = await signer.signTypedData(
  typedData.domain,
  { Withdrawal: typedData.types.Withdrawal,
    WithdrawalAsset: typedData.types.WithdrawalAsset },
  typedData.message,
);
```

Submit to `POST /v2/portfolios/{portfolioId}/withdraw` with the signature
and `typedData.message` echoed verbatim as `body.message`.

```bash theme={null}
curl -X POST https://api.glider.fi/v2/portfolios/pf_01.../withdraw \
  -H 'x-api-key: gldr_sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "message": { /* ...typedData.message, unmodified... */ },
    "signature": "0x9412d70d...39e01b"
  }'
```

Response:

```json theme={null}
{
  "success": true,
  "data": {
    "operationId": "op_01JWZEE2MF30KVRMRX53N88VA4",
    "submittedAt": "2026-04-17T12:05:00.000Z"
  }
}
```

### Echo the inner message exactly

`body.message` **must** be the full `typedData.message` from stage 1, byte
for byte — any mutation breaks signature verification. Common mistakes:

* Re-serializing `amountRaw` as a number instead of a string.
* Changing `nonce` casing or stripping the `0x` prefix.
* Re-ordering `assets[]` — array order is part of the hash.
* Recomputing `expiresAt` as an ISO string instead of the original Unix
  seconds integer.

Both EOA (ECDSA) and ERC-1271 (smart-contract wallet) signatures are
accepted.

## Stage 3 — Poll for onchain confirmation

Stage 2 returns `202`. The transfer is dispatched asynchronously. Poll:

```bash theme={null}
curl https://api.glider.fi/v2/portfolios/pf_01.../operations/op_01... \
  -H 'x-api-key: gldr_sk_your_api_key'
```

Operations transition `accepted → running → completed | failed | cancelled`.
Poll every 2–5 seconds until terminal.

## Error Handling

| Code      | HTTP | Cause                                                   | Retry safe?              |
| --------- | ---- | ------------------------------------------------------- | ------------------------ |
| `API_200` | 404  | Portfolio not found or not owned by tenant              | No                       |
| `API_210` | 400  | Insufficient balance                                    | No                       |
| `API_211` | 400  | Invalid recipient (chain-agnostic, or not EVM)          | No                       |
| `API_212` | 400  | Duplicate `assetId` in the request                      | No                       |
| `API_213` | 400  | Recipient chain does not match asset chain              | No                       |
| `API_214` | 400  | `message.portfolioId` does not match the path parameter | No                       |
| `API_215` | 400  | Portfolio has no smart account on the recipient's chain | No                       |
| `API_216` | 400  | Authorization expired (past `message.expiresAt`)        | No — re-run stage 1      |
| `API_217` | 400  | Signature does not recover to the portfolio owner       | No                       |
| `API_007` | 409  | Same `nonce`, previous call still running               | Yes — back off and retry |
| `API_008` | 409  | Same `nonce`, different body — replay conflict          | No                       |
| `API_506` | 503  | Signature verifier temporarily unavailable              | Yes — retry with backoff |

See [Idempotency](/guides/idempotency) for the full 409 model.

## Common Pitfalls

* **Omitting `message` from stage 2.** The route requires the full
  `typedData.message`, not just `{ nonce, signature }`.
* **Using different `assets` order in stage 2.** The typed-data hash depends
  on array order. Do not sort or rewrite.
* **Running stage 1 more than 10 minutes before stage 2.** The authorization
  has expired. Stage 2 returns `API_216` — start over.
* **Treating `expiresAt` inside `message` as an ISO string.** Inside the
  signed envelope it is always Unix seconds. The sibling `data.expiresAt` at
  the response-envelope level is the ISO-8601 version for human display.
