202 and returns an
operationId you poll for onchain confirmation.
- Scope:
portfolios:withdrawon both stages. - Idempotency anchor:
message.noncereturned by stage 1 (also surfaced asauthorizationId). - Authorization TTL: 10 minutes from stage-1 response.
- One request = one recipient on one chain. Multi-chain withdrawals require separate authorizations.
Sequence
Stage 1 — Prepare the authorization
POST /v2/portfolios/{portfolioId}/withdraw/signature
Stateless. The server validates the request, checks live onchain balances,
generates a 32-byte nonce, sets a 10-minute expiry, and returns the full
EIP-712 typed-data envelope.
Documented exceptions to the v2 conventions
Inside the signed envelope only, two fields break the usual v2 naming rules because EIP-712 requires them:domain.verifyingContractis a bare EVM address, not a CAIP-10. This is the value the user’s wallet renders during signing.message.expiresAtis Unix seconds as a number, not an ISO-8601 string. Typed-data hashing is binary.
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 withAPI_211. - The recipient chain must match every asset’s chain. Otherwise
API_213. - The portfolio must have a vault on the recipient’s chain. Otherwise
API_215.
Stage 2 — Have the user sign, then submit
The user signstypedData with signTypedData in their wallet. Pass the
full object directly — viem, ethers, wagmi, and privy all accept the
standard shape:
POST /v2/portfolios/{portfolioId}/withdraw with the signature
and typedData.message echoed verbatim as body.message.
Echo the inner message exactly
body.message must be the full typedData.message from stage 1, byte
for byte. The server re-builds the EIP-712 envelope and verifies the
signature recovers to the portfolio owner. Common mistakes that break
verification:
- Re-serializing
amountRawas a number instead of a string. - Changing
noncecasing or stripping the0xprefix. - Re-ordering
assets[]— array order is part of the hash. - Recomputing
expiresAtas an ISO string instead of the original Unix seconds integer.
Stage 3 — Poll for onchain confirmation
Stage 2 returns202. The transfer is dispatched asynchronously. Poll:
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 vault 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 |
Common Pitfalls
- Omitting
messagefrom stage 2. The route requires the fulltypedData.message, not just{ nonce, signature }. - Using different
assetsorder 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
expiresAtinsidemessageas an ISO string. Inside the signed envelope it is always Unix seconds. The siblingdata.expiresAtat the response-envelope level is the ISO-8601 version for human display.