This is the one group where a defect moves funds. Everywhere else in this contract a bug produces a wrong number; here it produces a wrong transfer, and one of these operations cannot be undone by anyone at any price.
It is also the one group that can never be tested in this family. Tier 4 of the testing strategy is explicit: money-moving calls are never a test. They are answered in production, by a consumer, with real funds. So the guarantees below are the only thing standing between a host and a mistake, and if this guide is not good enough to be trusted, do not call these endpoints.
The decision is the host's
The package will place a withdrawal you ask for. It will not decide that one should happen, will not retry one, and will not pick a default for anything that determines where funds go.
Every parameter that decides a destination is required and never defaulted. Not the network, not the address, not the memo. A package choosing a default network is a package choosing where your money goes.
What is available where
| Coinbase | Gemini | Webull | Schwab | Robinhood | |
|---|---|---|---|---|---|
| deposit address | — | ✅ | — | — | — |
| networks | — | ✅ | — | — | — |
| allowlist read / request / remove | — | ✅ | — | — | — |
| withdrawal fee estimate | — | ✅ | — | — | — |
| withdraw | — | ✅ | — | — | — |
| payment methods | ✅ read only | ✅ list / add | — | — | — |
| internal transfer | ✅ | ✅ | — | — | — |
Gemini is the only venue in this family whose API moves funds off the venue, and that is a
statement about the venues, not about how far these packages got. Robinhood Crypto publishes
nine operations and none is a transfer; Schwab's Trader API is accounts, orders and market
data; Webull and Coinbase Advanced Trade fund through their own applications. Each is
recorded with its source in that package's docs/reference/*/negative-claims.md.
Coinbase is not a blank row, and reading it as one loses two live calls. Advanced Trade
publishes no deposit address, no networks list, no allowlist and no withdrawal, so
withdraw/5 and everything around it is :unsupported there. But transfer_internal/4
is live — funds between two of the account's own portfolios — and
list_payment_methods/2 and get_payment_method/3 read the funding sources.
add_payment_method/2 is not served: a bank is linked through the consumer product's flow,
which needs a person. Gemini is the mirror image on that last pair — it serves
list_payment_methods/2 and add_payment_method/2 and not get_payment_method/3.
So a host withdrawing is writing against Gemini today. Write it against the
contract anyway — the callbacks are the same shape everywhere, and the venues that answer
{:error, :not_supported} do so uniformly.
Before you call withdraw/5
Four things, in order, and none of them is optional:
list_networks/2first. The same asset exists on several chains and the addresses are not interchangeable.get_deposit_address/3takes a network because nothing else in the contract says which ones a venue accepts. Funds sent to an address on a chain the venue does not credit are gone — the single most expensive mistake this surface allows. Network naming is not standardised: one venue'sethereumis another'sERC20. Rows come back as the venue's own maps because normalising them would invent a vocabulary no venue accepts back.- Check the allowlist, and check it is active.
list_approved_addresses/1returnsTypes.ApprovedAddress, which carries whether each address is usable yet. Venues time-lock a newly added address.request_approved_address/4returning success is not permission to withdraw — it is a request. Read it back. - Estimate, and do not record the estimate as a fee.
estimate_withdrawal_fee/4is separate fromwithdraw/5because the venues expose it separately and because the estimate can differ from the charge. - Decide the memo. Some networks — Solana, XRP, Cosmos — need one. An exchange address on a memo network without a memo is credited to nobody and is generally not recoverable.
The memo: what the package can and cannot do for you
It cannot tell you whether a network needs one. Types.DepositAddress's :memo_required
comes back nil, not false, and the difference is the whole point: false would be a
claim that no memo is needed, and nil says the venue did not tell us. Absence is never
substituted with a safe-looking value.
What it does do: opts[:memo_required] is a caller's assertion, not a lookup. Passing
true with no memo is refused locally rather than sent. A package must never synthesise a
memo.
Idempotency, and why a retry is the dangerous part
A retry without an idempotency key withdraws twice. Gemini accepts clientTransferId —
"a unique UUID for idempotent withdrawals. If provided, duplicate requests with the same
clientTransferId will not create additional transfers."
This package always sends one. Not when asked; always. A withdrawal is exactly the request whose response is most likely to be lost in transit and most damaging to repeat, and "the caller should have passed a key" is not a defence when the caller is a retry loop several layers up that never knew this was a withdrawal.
Pass your own through opts when you want to control it — that is what makes a deliberate
retry safe.
A payment method being listed does not mean it is usable
Rows are the venue's own maps: a bank account, a card and a balance are different things with different fields, and flattening them into one struct drops whichever one you needed.
Venues hold new bank accounts pending verification, and the status is in the row.
A caller filtering on presence rather than status picks a method the venue will refuse.
add_payment_method/2 succeeding does not make the method usable either — Gemini verifies
out of band, which needs a person and some days.
transfer_internal/4 is not withdraw/5
Moving funds between two accounts at the same venue: nothing leaves the venue, no chain is
involved, no network fee is charged, and no allowlist applies. A caller reaching for
withdraw/5 for an internal move pays a network fee it did not need to — and exposes the
funds to an address mistake that the internal path structurally cannot have.
opts[:from] and opts[:to] are both required.
What a {:refused, reason} means here
The venue understood and declined — insufficient balance, an address not allowlisted, a
missing role on the key. That is not a package error and retrying it is pointless; the
reason carries the venue's own words.
Gemini publishes a get_roles/1 call answering "will the venue let this key do that", and
asking it is cheaper than discovering a missing role from a refused withdrawal.
What this package never does
- never retries a money-moving call on your behalf
- never defaults a network, an address, or a memo
- never treats a success without the field it needed as a success
- never turns a
nilthe venue sent into afalseyou can act on - never decides that a transfer should happen
That last one is D2, and it is the reason this guide is short on convenience and long on preconditions.