Gemini's authenticated endpoints — internal. Balances, orders and trade history.
The boundary, precisely
The host authenticates. This module makes authenticated requests with what the host
hands it. Those are different jobs and the difference is the whole design: a caller
passes credentials into every one of these functions, they are used to sign that one
request, and nothing is kept. This module never obtains a credential, never stores one,
never refreshes one, and never decides which authentication scheme applies — see Auth,
which refuses to guess.
Which means trading works exactly as the facade intends: the host does auth, calls
place_order/3 with credentials, and gets a Core.Types.Order back.
Gemini has no market orders, and this package will not fake one
From the venue's own page:
The API doesn't directly support market orders because they provide you with no price protection. Instead, use the "immediate-or-cancel" order execution option, coupled with an aggressive limit price (i.e. very high for a buy order or very low for a sell order), to achieve the same result.
That advice is not something a package may take on a caller's behalf. "An aggressive limit price" means inventing a number the caller did not supply and sending it to a live exchange as a real order. How aggressive? Ten percent through the book? Fifty? The package cannot know, the caller never said, and the difference is money.
So order_type: :market is {:error, {:unsupported_order_type, :market}} — the venue
does not serve it, and the nearest thing requires a price only the caller can choose. A
caller who wants that behaviour asks for it explicitly, with their own limit:
%{order_type: :limit, time_in_force: :ioc, price: my_aggressive_price, …}This is the family's named failure mode in its most expensive form. Every other instance in this codebase costs a wrong number in a chart; this one costs a fill at a price nobody chose.
Order execution options are mutually exclusive
If you specify more than one option (or an unsupported option) in the options array, the exchange will reject your order.
So time_in_force maps to exactly one option, or none:
time_in_force | Gemini option | Meaning |
|---|---|---|
:gtc (default) | none | fills what it can, rests the remainder on the book |
:post_only / :maker_or_cancel | maker-or-cancel | adds liquidity only, cancels if it would take |
:ioc | immediate-or-cancel | takes what it can, cancels the rest |
:fok | fill-or-kill | fills entirely or cancels entirely |
No option may be combined with a stop-limit order.
A cancelled order is not a failed request
MOC, IOC and FOK orders that do not fill come back 200 with "is_cancelled": true.
That is the venue answering successfully; the order simply did not rest. It maps to
status: :cancelled on a {:ok, order}, never to an error — a caller that treated it as
a failure would retry an order the venue already handled.
Auth failures are refusals, not errors
Measured 2026-08-28 against the demo environment: an unauthenticated POST to any private
endpoint returns 401 MissingSecurityHeaders. Gemini's own error table documents
MissingApikeyHeader at 400, so the documented codes and the live ones disagree —
the fourth documentation divergence found on this venue.
Either way 400, 401 and 403 are permanent for the request as sent: retrying the
identical bytes cannot succeed. They are {:refused, reason}. A caller whose token
expired refreshes it and calls again with new credentials, which is a different request —
not a retry of this one.
Summary
Functions
Registers a bank account — /v1/payments/addbank, or /v1/payments/addbank/cad for a
Canadian one.
Cancels open orders in bulk, at the scope the caller states.
Cancels a clearing order — POST /v1/clearing/cancel.
Cancels one order by the venue's order id.
Commits a quote by its quoteId, moving the assets.
Confirms a clearing order — POST /v1/clearing/confirm. This executes a trade.
Wraps or unwraps in one call — the venue's /v1/wrap/{symbol}.
Creates a subaccount — POST /v1/account/create.
Submits a broker-facilitated clearing order — POST /v1/clearing/broker/new.
Creates a bilateral clearing order — POST /v1/clearing/new.
What the venue would charge to withdraw amount of asset over network to address —
/v2/withdraw/{network}/{ticker}/feeEstimate.
The funding amount report as a spreadsheet —
/v1/fundingamountreport/records.xlsx.
The funding payment report as JSON —
/v1/perpetuals/fundingpaymentreport/records.json.
The funding payment report as a spreadsheet —
/v1/perpetuals/fundingpaymentreport/records.xlsx.
The perpetuals margin account — POST /v1/margin.
The account's own record of itself — name, type, and the roles the key carries.
Every currency the account holds, with what is available and what is on hold.
One clearing order's state — POST /v1/clearing/status.
A fresh deposit address for asset on network — /v1/deposit/{network}/newAddress.
The fee tier this account trades at, from /v1/notionalvolume.
The spot margin account summary — POST /v1/margin/account.
Margin interest rates for every borrowable asset — POST /v1/margin/rates.
Every balance, each also valued in one notional currency — /v1/notionalbalances/{currency}.
One order's current state.
Orders for this account — resting by default, closed with history: true.
Open positions — POST /v1/positions.
The roles this API key carries — POST /v1/roles.
Staked positions, one per asset — POST /v1/balances/staking.
Movements in and out of staked positions — POST /v1/staking/history.
Rewards accrued over a window — POST /v1/staking/rewards.
Past fills for a symbol.
The account's own traded volume, as the venue aggregates it — /v1/tradevolume.
Everything that moved on this account — /v1/transactions.
Transfers in and out of the account.
Every subaccount in the group — POST /v1/account/list.
The addresses this account may withdraw to on network —
/v1/approvedAddresses/account/{network}.
Broker clearing orders — POST /v1/clearing/broker/list.
Clearing orders this account is party to — POST /v1/clearing/list.
Clearing trades — POST /v1/clearing/trades.
What Gemini charged this account for holding assets — /v1/custodyaccountfees.
Funding payments credited to or debited from this account —
POST /v1/perpetuals/fundingPayment.
The networks an asset moves over, or the assets a network carries.
The funding sources this account can move fiat through — /v1/payments/methods.
Places an order.
What a spot order would do to this account's margin — POST /v1/margin/order/preview.
Quotes a conversion between two assets, holding a rate the caller may then commit.
Exchanges a refresh token for a new access token —
POST https://exchange.gemini.com/auth/token.
Removes address from the allowlist for network —
/v1/approvedAddresses/{network}/remove.
Renames a subaccount — POST /v1/account/rename.
Asks the venue to add address to the withdrawal allowlist for network —
/v1/approvedAddresses/{network}/request.
Revokes an access token — POST /v1/oauth/revokeByToken.
Stakes amount of asset — POST /v1/staking/stake.
Confirms the credentials reach the venue, using its own heartbeat endpoint.
Moves amount of asset between two accounts at this venue —
/v1/account/transfer/{currency}.
Redeems amount of a staked asset — POST /v1/staking/unstake.
Withdraws amount of asset over network to address — /v2/withdraw/{network}/{ticker}.
Functions
@spec add_payment_method(map(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Registers a bank account — /v1/payments/addbank, or /v1/payments/addbank/cad for a
Canadian one.
Two endpoints, because the details differ by country. A US account is a routing and
account number; a Canadian one adds an institution and transit number. opts[:country]
selects, defaulting to the US endpoint — and a country this venue has no endpoint for is
refused rather than sent to the wrong one, where the fields would be read as the other
country's and the account registered wrong.
The venue verifies out of band. A successful response starts that; it does not finish
it, and the method is not usable until the venue says so — which list_payment_methods/2
reports.
@spec cancel_all_orders( map(), keyword() ) :: {:ok, %{cancelled: [String.t()], rejected: [String.t()]}} | {:error, term()} | {:refused, term()}
Cancels open orders in bulk, at the scope the caller states.
Gemini publishes both scopes as separate endpoints:
:session -> /v1/order/cancel/session
:account -> /v1/order/cancel/allopts[:scope] is required. The account scope reaches orders no API key placed —
including ones a person entered through the web interface, which the venue says
explicitly — and picking it for a caller who meant the session would cancel work nobody
asked about. Gemini's own documentation recommends the session scope; that is guidance
for the caller, not licence to choose here.
Returns the venue's own two lists. A non-empty rejected is not a failure of this
call — the venue answered, and some of those orders were already gone. Reporting an
error would tell a caller nothing was cancelled when most of it was.
@spec cancel_clearing_order(String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Cancels a clearing order — POST /v1/clearing/cancel.
Only an unconfirmed order can be cancelled; once both sides have confirmed there is a
trade, and a trade is not cancellable. The venue's result and details both travel,
because details is where it says why a cancel did not take.
@spec cancel_order(map(), String.t(), keyword()) :: {:ok, DpExchange.Core.Types.Order.t()} | {:error, term()} | {:refused, term()}
Cancels one order by the venue's order id.
@spec commit_conversion( String.t(), keyword() ) :: {:ok, DpExchange.Core.Types.Conversion.t()} | {:error, term()} | {:refused, term()}
Commits a quote by its quoteId, moving the assets.
/v1/instant/execute. A quote past its window does not fill at the quoted rate —
see Core.Types.Conversion, whose expires_at exists for exactly this. Ask
Conversion.expired?/2 before committing; the venue is still the authority on whether
a commit succeeds.
@spec confirm_clearing_order(String.t(), map(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Confirms a clearing order — POST /v1/clearing/confirm. This executes a trade.
The venue re-asks for every term. symbol, amount, price and side are required
alongside the clearing_id, and this package does not fill any of them in from the order
it is confirming: the point of re-stating them is that the confirming side says what it
believes it is agreeing to, and a package that read them back from the venue would confirm
whatever the venue had, which is the one thing the check exists to prevent.
side here is the confirming party's own side, which is the opposite of the side the
order was created with.
@spec convert(String.t(), String.t(), Decimal.t(), keyword()) :: {:ok, DpExchange.Core.Types.Conversion.t()} | {:error, term()} | {:refused, term()}
Wraps or unwraps in one call — the venue's /v1/wrap/{symbol}.
This is convert/4's one-step form and not the Instant pair. There is no quote to
accept: the venue executes at its own price and reports the rate in the result. A caller
that must see a price first uses quote_conversion/4 instead.
The direction comes from the two assets, exactly as it does for Instant, and it refuses
the same way when neither orientation is determinable — pass opts[:symbol] and
opts[:side] to say which.
Returns a Conversion already :settled. It has happened.
@spec create_account(String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Creates a subaccount — POST /v1/account/create.
name is a display name and the venue answers with a different string. It returns
account, a kebab-cased shortname derived from the name — spaces to hyphens, symbols
removed, lower-cased — and that shortname is what every other endpoint's account
parameter takes. A caller that kept the name it sent would address the wrong thing, or
nothing.
opts[:type] is "exchange" or "custody", and the venue's own default when it is
omitted is exchange. This package does not send one: choosing between an exchange
account and a custody account for a caller who did not is choosing what the account can do.
Requires the Administrator role.
@spec create_broker_clearing_order(map(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Submits a broker-facilitated clearing order — POST /v1/clearing/broker/new.
Not create_clearing_order/3: a broker order names both counterparties and the broker
is neither of them. opts[:source_counterparty_id] and opts[:target_counterparty_id] are
both required, and so is opts[:expires_in_hrs] — the venue marks it required here where
it is optional on the bilateral form.
side is assigned to the source, and the opposite side goes to the target. Passing the
two counterparties the wrong way round produces a valid order in which each side is trading
the direction the other meant.
The venue answers AwaitSourceTargetConfirm: both parties still have to confirm.
@spec create_clearing_order(map(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Creates a bilateral clearing order — POST /v1/clearing/new.
This is not place_order/3. A clearing order does not go to the book: it is one half
of a trade agreed directly with a named counterparty, and it does nothing until that
counterparty confirms it. A caller that treated a successful response as a fill has a
position it does not have.
symbol, amount, price and side are required by the venue and are not defaulted.
opts[:counterparty_id] names the other side; opts[:expires_in_hrs] bounds how long the
offer stands.
is_confirmed on the response is the field that matters. false means the trade has
not happened; the order sits until the counterparty confirms it or it expires.
@spec estimate_withdrawal_fee(String.t(), String.t(), Decimal.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
What the venue would charge to withdraw amount of asset over network to address —
/v2/withdraw/{network}/{ticker}/feeEstimate.
The address is part of the estimate, not decoration: fees differ by destination on some networks, so an estimate for one address does not hold for another.
This moves no funds. withdraw/6 does.
@spec funding_amount_report(String.t(), map(), keyword()) :: {:ok, binary()} | {:error, term()} | {:refused, term()}
The funding amount report as a spreadsheet —
/v1/fundingamountreport/records.xlsx.
Returns the venue's bytes unparsed, as {:ok, binary}. This package ships no spreadsheet
reader and will not grow one: a parsed cell is a number this package chose from a layout
the venue can change without notice, and the file is what the venue actually issued.
symbol is required by the venue. opts[:from] and opts[:to] must be given together
or not at all — the venue makes each mandatory if the other is present, and sending one
alone is refused here rather than silently returning a differently-bounded report.
@spec funding_payment_report( map(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
The funding payment report as JSON —
/v1/perpetuals/fundingpaymentreport/records.json.
The query string is part of what is signed. Gemini's private GETs put the full path,
query string included, in the signed request field; signing the bare path produces a
valid signature over the wrong string, and the venue reports that as a credential problem
rather than a parameter one. This builds the query once and uses the same string in both
places.
opts[:from] and opts[:to] are dates, opts[:rows] a count. The venue's own default is
8760 rows — a year of hourly funding — and this package does not send one, because a
page size chosen here would silently truncate a report the caller asked for in full.
@spec funding_payment_report_file( map(), keyword() ) :: {:ok, binary()} | {:error, term()} | {:refused, term()}
The funding payment report as a spreadsheet —
/v1/perpetuals/fundingpaymentreport/records.xlsx.
The same bytes-not-cells rule as funding_amount_report/3, and the same account scope as
funding_payment_report/2. Takes no symbol: a funding payment belongs to the account, not
to one contract.
@spec get_account_margin( map(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
The perpetuals margin account — POST /v1/margin.
Collateral, leverage, buying and selling power, and the estimated liquidation price,
which get_positions/2 does not publish. A caller judging how much room is left reads it
here.
Returned as the venue's own map. Its eleven fields divide margin four ways — by position, by open order, by buy side and by sell side — and a struct that kept only a total would drop the split a caller sizing its next order needs.
The account's own record of itself — name, type, and the roles the key carries.
@spec get_balances( map(), keyword() ) :: {:ok, [DpExchange.Core.Types.Balance.t()]} | {:error, term()} | {:refused, term()}
Every currency the account holds, with what is available and what is on hold.
@spec get_clearing_order(String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
One clearing order's state — POST /v1/clearing/status.
Read is_confirmed, not status. The status string is the venue's own description and
the boolean is the fact: an order that is not confirmed has not traded, whatever the
description says.
@spec get_deposit_address(String.t(), String.t(), map(), keyword()) :: {:ok, DpExchange.Core.Types.DepositAddress.t()} | {:error, term()} | {:refused, term()}
A fresh deposit address for asset on network — /v1/deposit/{network}/newAddress.
The network is not optional and must not be guessed. An address generated for the
wrong chain still looks like an address; funds sent to it on another chain are gone.
list_networks/2 is how a caller learns which networks this venue credits for an asset.
opts[:label] names the address at the venue. opts[:legacy] asks for a legacy
P2SH-P2PKH Litecoin address, which is the venue's own flag and defaults to false.
memo_required is nil, not false. Some networks — Solana, XRP, Cosmos — need a
destination tag or the deposit is unattributable, and this endpoint's response does not
say whether this one does. false would be a claim that no memo is needed; nil says
this package does not know, and a caller must check the network before sending.
The fee tier this account trades at, from /v1/notionalvolume.
Returned as the venue states it — basis points, per maker/taker, alongside the notional volume that determined the tier. Nothing is converted to a rate, because the venue's own units are what a caller will reconcile against.
@spec get_margin_account( map(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
The spot margin account summary — POST /v1/margin/account.
Not get_account_margin/2: that one is the perpetuals account. These are two margin
systems on one venue, and their fields are named differently on purpose — this one nests
every amount as %{"currency" => _, "value" => _} where the perpetuals one sends bare
decimals in dollars.
Returned as the venue's own map for that reason. Flattening the currency off an amount is how a caller ends up adding a BTC number to a USD one.
@spec get_margin_rates( map(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
Margin interest rates for every borrowable asset — POST /v1/margin/rates.
Three rates per currency, and they are not three ways of saying one thing. The venue
publishes borrowRate hourly, borrowRateDaily as hourly × 24 and borrowRateAnnual as
daily × 365, and all three travel: a caller that took the hourly rate for an annual one
would be out by four orders of magnitude, and the number would still look like a rate.
lastUpdated is milliseconds, and it matters — a borrow rate is a moving quote, not a
schedule.
@spec get_notional_balances(map(), String.t(), keyword()) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
Every balance, each also valued in one notional currency — /v1/notionalbalances/{currency}.
Not get_balances/2 in another unit. The amount is the venue's ledger; the
amountNotional beside it is Gemini's own valuation of that quantity, at a rate Gemini
chose and does not publish here. Rows are returned as the venue sends them so the two
cannot be read as one number.
The currency is a path segment, not a parameter. Gemini documents usd; anything else is
sent as given and the venue answers for itself, because a package that allowed only the
documented one would be wrong the day a second is added.
@spec get_order(map(), String.t(), keyword()) :: {:ok, DpExchange.Core.Types.Order.t()} | {:error, term()} | {:refused, term()}
One order's current state.
@spec get_orders( map(), keyword() ) :: {:ok, [DpExchange.Core.Types.Order.t()]} | {:error, term()} | {:refused, term()}
Orders for this account — resting by default, closed with history: true.
Two endpoints, not one with a filter. /v1/orders returns what is still on the book;
/v1/orders/history returns what is not. A caller asking for "orders" without saying
which gets the resting ones, the set that can still change.
History accepts symbol: and limit: (the venue's limit_orders, default 50, max 500)
and a since: DateTime. The venue's own default applies where the caller gives none —
this does not substitute one of its own, because a page size chosen here would silently
become the caller's answer.
@spec get_positions( map(), keyword() ) :: {:ok, [DpExchange.Core.Types.Position.t()]} | {:error, term()} | {:refused, term()}
Open positions — POST /v1/positions.
Gemini sends a negative quantity for a short, and Types.Position refuses to carry
one: :quantity is the size, always positive, and :side says which way. A sign
convention is a fact about one venue's JSON, not about the market, and a package that
passed it through would hand a caller a position that is exactly backwards while every
number in it stays plausible.
notional_value is negative for shorts too, and it is kept as the venue sent it —
that one is a signed value rather than a magnitude with a direction beside it, and
flipping it would change what the number means.
Realised and unrealised P&L stay apart. One has happened; the other is a mark-to-market opinion that may never be realised.
liquidation_price is nil here: /v1/positions does not publish one. That does not
mean the position is safe — get_account_margin/2 publishes
estimated_liquidation_price for the account, which is where a caller must look.
symbol is read through SymbolFormat, not carried raw. The venue's own example
response sends it lowercase ("btcgusdperp"), the same case /v1/symbols uses — this
used to pass row["symbol"] straight onto the struct unchanged, so a real position
arrived as symbol: "btcgusdperp" beside to_order/1's and every other reader's
uppercase form. Nothing here failed loudly: the test fixtures that exist all wrote
"BTCGUSDPERP" by hand, and none of them ever asserted on position.symbol at all, so
the venue's real casing was never exercised. Perpetuals take the same :nomatch path
through CanonicalPair.to_canonical/2 that produces the uppercased, unsplit form
SymbolFormat's own moduledoc documents — to_canonical_symbol/1 is the same
conversion to_order/1 already applies to a symbol from the same family of endpoints.
The roles this API key carries — POST /v1/roles.
Returns the venue's own booleans: isAuditor, isFundManager, isTrader. Auditor
cannot be combined with the others, and Fund Manager and Trader can — which is why
three booleans rather than one role.
This is the call that answers "will the venue let this key do that", and asking it is cheaper than discovering a missing role from a refused withdrawal.
@spec get_staking_balances( map(), keyword() ) :: {:ok, [DpExchange.Core.Types.StakingBalance.t()]} | {:error, term()} | {:refused, term()}
Staked positions, one per asset — POST /v1/balances/staking.
Three amounts, kept apart. A real response carries balance: 10, available: 0,
availableForWithdrawal: 10 — the whole position is redeemable and none of it is
tradable. A caller reading a single "available" would size an order against ten and place
it against zero, which is why StakingBalance refuses to collapse them.
A missing state is nil, not zero. A venue that did not report one has not said it is
none, and this package will not say it for it.
Zero-balance rows are kept. The host adapter this package replaces dropped them, which makes "the venue reports no position in ETH" and "the account holds nothing in ETH" the same answer; they are not.
@spec get_staking_history( map(), keyword() ) :: {:ok, [DpExchange.Core.Types.StakingTransaction.t()]} | {:error, term()} | {:refused, term()}
Movements in and out of staked positions — POST /v1/staking/history.
A redemption is a process, not an event. Rows carry amount, amountPaidSoFar and
amountRemaining, and the three differ for most of a redemption's life while the asset
unbonds. All three travel; nil on the last two means the venue does not report progress,
not that the operation is complete.
:type is the normalised atom and :venue_type keeps Gemini's own word — Deposit,
Redeem, Interest and others. A normalisation that loses the original cannot be audited
when it turns out to be wrong, and an unrecognised word maps to :other rather than to
the nearest one that fits.
@spec get_staking_rewards( map(), keyword() ) :: {:ok, [DpExchange.Core.Types.StakingReward.t()]} | {:error, term()} | {:refused, term()}
Rewards accrued over a window — POST /v1/staking/rewards.
The window is part of the value. The same number is a good day or a poor quarter
depending on it, so opts[:since] and opts[:until] are sent to the venue and the bounds
the venue reports back travel on the struct. Where the venue reports none, they stay nil
rather than being filled in from the request — the venue is free to clamp a window, and a
package that echoed the ask would report a period that was never served.
:apy_pct is the rate at accrual, which is not what get_staking_rates/1 reports
today. That is what lets a caller reconcile a reward against the rate that produced it.
@spec get_trade_history( map(), keyword() ) :: {:ok, [DpExchange.Core.Types.Fill.t()]} | {:error, term()} | {:refused, term()}
Past fills for a symbol.
Gemini requires a symbol here — there is no all-symbols variant — so a caller asking for everything is asking for one request per symbol, and it is theirs to decide whether to.
opts[:since] accepts a DateTime, converted to the venue's own unit — milliseconds,
per its own request examples (timestamp: 1591084414000) — the same conversion every
other filtered endpoint in this module uses. This used to reach maybe_put/3 instead,
which stringifies whatever it is handed rather than converting it: a DateTime became
"2026-08-28 17:00:01Z" on the wire, a shape the venue's timestamp field does not
parse, so the filter silently failed to narrow anything rather than erroring — the same
bug get_orders/2's history_params/1 already carries the fix for. opts[:limit] had
the same defect for limit_trades, sent as "100" instead of the documented integer.
@spec get_trade_volume( map(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
The account's own traded volume, as the venue aggregates it — /v1/tradevolume.
One row per symbol per day, with the maker and taker breakdown the venue's fee tiers are
computed from. Not get_trade_history/2 summed: this venue requires a symbol on every
fills request, so reproducing this means one request per symbol per period, and the answer
would still be this package's arithmetic against the venue's ledger.
Rows come back as the venue sends them. The fields differ enough between venues that a
normalised struct would be mostly nil, and a caller reading buy_maker_notional wants
the venue's number under the venue's name.
@spec get_transactions( map(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
Everything that moved on this account — /v1/transactions.
Wider than get_trade_history/2 and wider than get_transfers/2: fees, interest,
credits and adjustments alongside deposits and fills. Rows are the venue's own, because
the kinds do not share a shape.
opts[:since] and opts[:limit] narrow it, in the venue's own names.
Summing this is not a balance. get_balances/2 is the authority; this explains it.
Transfers in and out of the account.
Calls /v2/transfers. The v1 path this package used until the D6 migration is absent
from Gemini's published OpenAPI document, and v2's own description says why: "The v1
transfers endpoint is being retired. This v2 endpoint is the recommended" replacement.
The three parameters are unchanged — currency, timestamp, limit_transfers — so this
was a path swap and nothing more. v2 additionally accepts network, account and
show_completed_deposit_advances, and each returned transfer now carries a network
field naming the chain. None of that is surfaced here yet; the rows are passed through.
Every subaccount in the group — POST /v1/account/list.
The venue caps this at 500 and does not paginate. limit_accounts is both the maximum
and the default, so a group with more than 500 subaccounts returns a truncated list with
nothing to say it was truncated. This package sends no limit unless asked, and states the
cap here because there is no cursor to follow.
Each row's account is the kebab-cased shortname other endpoints address by; name is
the display name. counterparty_id is None on a custody account — the venue's own
string, not nil.
@spec list_approved_addresses( map(), keyword() ) :: {:ok, [DpExchange.Core.Types.ApprovedAddress.t()]} | {:error, term()} | {:refused, term()}
The addresses this account may withdraw to on network —
/v1/approvedAddresses/account/{network}.
An address on this list is not necessarily usable yet. The venue reports
status: "pending-time" for one still inside its time lock, and a withdrawal to it is
refused. Core.Types.ApprovedAddress.usable?/2 answers that, and returns nil where the
venue gave a pending status with no activation time — unknown, not "ready".
opts[:network] selects; there is no all-networks variant, because the venue keeps a list
per network.
@spec list_clearing_brokers( map(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
Broker clearing orders — POST /v1/clearing/broker/list.
Separate from list_clearing_orders/2 because the rows are a different shape: a broker
order names a source and a target counterparty and a source_side, where a
bilateral order names one counterparty and one side. Merging the two would leave a caller
reading side on a row that has none.
@spec list_clearing_orders( map(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
Clearing orders this account is party to — POST /v1/clearing/list.
Every filter is optional and none is defaulted: opts[:symbol], opts[:counterparty]
(which takes an id or an alias), opts[:side], and four timestamp bounds —
expiration_start, expiration_end, submission_start and submission_end.
Expiration and submission are different windows. An order submitted last week can expire tomorrow, and filtering on the wrong one returns a real list that is not the one asked for.
@spec list_clearing_trades( map(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
Clearing trades — POST /v1/clearing/trades.
Orders that completed, where list_clearing_orders/2 shows what is outstanding. Rows
are camelCase here and snake_case there; the venue's own keys are kept either way rather
than normalised into one shape that matches neither response.
opts[:limit] maps to the venue's limit_per_account — default 100, maximum 300 — and
opts[:since_nanos] to timestamp_nanos, which is nanoseconds, not the milliseconds
every other Gemini timestamp uses.
@spec list_custody_fees( map(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
What Gemini charged this account for holding assets — /v1/custodyaccountfees.
Custody fees come straight out of the balance with no trade behind them, so a consumer reconciling balances against fills alone finds a gap this is the only explanation for.
An empty list means nothing was charged in the window asked for. It does not mean the account holds nothing in custody, and it does not mean Gemini does not charge — an account with no custody balance and an account billed nothing this period return the same thing.
opts[:since] and opts[:limit] page it, under the venue's own parameter names.
@spec list_funding_payments( map(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
Funding payments credited to or debited from this account —
POST /v1/perpetuals/fundingPayment.
Not get_funding/2. That is the contract's rate; this is what this account actually
paid or received. A caller reconciling a balance needs the second, and computing it from
the first plus a position size is this package's arithmetic rather than the venue's ledger.
Rows are the venue's own. Each carries action — Credit or Debit — beside a positive
quantity, so the direction is in the action and not in the sign. Normalising it into a
signed number here would drop the venue's own word for it.
The venue notes instrumentSymbol is attached only to records from 16 April 2024 onwards;
older rows have none, and this package does not fill one in.
@spec list_networks( String.t() | nil, keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
The networks an asset moves over, or the assets a network carries.
Call this before get_deposit_address/3. That endpoint takes a network, and a wrong
one produces an address on a chain this venue does not credit — funds sent there are gone.
Two directions and two endpoints, both authenticated GETs:
list_networks("USDC", credentials: creds) GET /v2/network/USDC
list_networks(nil, network: "…", credentials: creds) GET /v2/networks/{network}/assetsBoth directions were dead until 2026-09-05, in two different ways. The asset
direction used to be documented "Public" and delegate to Rest.networks_for_asset/2,
which sends no credentials — that is Rest's whole design. Measured live: an
unauthenticated GET /v2/network/BTC returns 401 MissingSecurityHeaders, and the
vendor's OpenAPI requires apiKeyAuth, signatureAuth and payloadAuth on it, so this
direction could never succeed for any consumer. The network direction had its own,
independent bug: it POSTed to /v2/networks/{network}/assets, and the vendor documents
that route as GET (operationId: getAssetsForNetwork) — there is no POST form. Both
now go through signed_get/3, the helper this module already used for exactly this
request shape elsewhere.
Both are scoped to the credential. The vendor requires the Fund Manager or Auditor role and states the network direction returns "only the assets where your account has deposit and withdraw access enabled". So an empty answer means this account cannot move anything on that network, not that the network carries nothing — a caller reading it as a description of the network would draw the wrong conclusion from a true response.
@spec list_payment_methods( map(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
The funding sources this account can move fiat through — /v1/payments/methods.
Rows are the venue's own. A method being listed is not the same as being usable: a
bank account added through add_payment_method/2 sits pending verification, and the
status is in the row.
@spec place_order(map(), map(), keyword()) :: {:ok, DpExchange.Core.Types.Order.t()} | {:error, term()} | {:refused, term()}
Places an order.
request is a map carrying at least :symbol, :side, :quantity and :price.
:order_type defaults to :limit; :time_in_force defaults to :gtc. A
:client_order_id is passed through when given and is strongly recommended by the venue.
Refuses rather than substituting:
order_type: :market— the venue serves none, and the documented workaround needs a limit price only the caller can choose- an unknown
:time_in_force— the venue rejects an unsupported option outright - a missing price on a type that requires one
@spec preview_margin_order(map(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
What a spot order would do to this account's margin — POST /v1/margin/order/preview.
Places nothing. It returns the account's margin statistics before and after the
hypothetical order, as preorder and postorder, and a caller reads the difference.
symbol, side and type are required by the venue and are not defaulted here. The
fourth parameter depends on the first three and the venue states which:
amount for a limit order or a market sell, totalSpend for a market buy, and
price for a limit order. Sending the wrong one is refused up front rather than sent —
a preview computed against a quantity the caller did not mean is a number that looks
right.
@spec quote_conversion(String.t(), String.t(), Decimal.t(), keyword()) :: {:ok, DpExchange.Core.Types.Conversion.t()} | {:error, term()} | {:refused, term()}
Quotes a conversion between two assets, holding a rate the caller may then commit.
This is Gemini's Instant pair, /v1/instant/quote then /v1/instant/execute. The
venue states the price, the quantity, the fee and a maxAgeMs, and holds that rate for
the window — typically 60 seconds. Nothing has moved until commit_conversion/2.
Which of the two assets is the pair, and why this refuses more often than you expect
The venue takes a symbol and a side, not a from/to pair, and the two are not
interchangeable: totalSpend is CCY2 on a buy and CCY1 on a sell. So DAI -> BTC is
buy BTCDAI spending DAI, and BTC -> DAI is sell BTCDAI spending BTC.
Deriving that needs to know which of the two is the pair's quote side, and this venue
quotes in crypto as well as fiat — SymbolFormat.quotes/0 lists BTC, ETH, SOL and FIL
alongside USD and the stablecoins. So for USD -> BTC both assets are quote currencies,
both orientations parse, and only the venue's catalogue says which pair exists.
It refuses with {:ambiguous_conversion, from, to} rather than picking one, and that
includes the common USD -> BTC. Choosing wrongly spends the wrong asset, which is a
real loss rather than a wrong-looking number, and this package will not resolve it by
fetching a catalogue behind the caller's back.
So pass opts[:symbol] and opts[:side]. Derivation is the convenience for the case
where exactly one side is a quote currency, not the main path.
The expiry comes from the venue's maxAgeMs measured from the response's own Date
header, not from the local clock. A quote whose window is computed against a clock the
venue does not share is a quote that expires at the wrong time.
@spec refresh_access_token(String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Exchanges a refresh token for a new access token —
POST https://exchange.gemini.com/auth/token.
This is credential use, not consent. The browser redirect that obtains the first
authorization code belongs to the host and is not here; refreshing a token the host
already holds is the same category as Schwab's Auth.refresh/2, and a package that could
not do it would leave a consumer unable to keep a session alive.
A different host from every other endpoint — exchange.gemini.com, not
api.gemini.com — and a form body rather than Gemini's signed payload. It is the same URL
the host's initial code exchange posts to, separated only by grant_type, which is why
the package/host split cannot be read off a path.
The response carries a new refresh token and the old one stops working. A caller that stores the access token and keeps the old refresh token has a session that ends at the next refresh.
client_secret is sent only when given: the venue documents it for confidential clients
and says public clients must not send it.
@spec remove_approved_address(String.t(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Removes address from the allowlist for network —
/v1/approvedAddresses/{network}/remove.
Generally immediate where addition is not: the venue is slow to widen what funds may reach and quick to narrow it.
Renames a subaccount — POST /v1/account/rename.
Two different things can be renamed and they are not the same field. opts[:name] is
the display name; opts[:shortname] is the kebab-cased account string every other
endpoint addresses by. Changing the second changes how the account is addressed, and a
caller with a stored shortname will stop finding it.
Either or both; neither is {:error, :nothing_to_rename} rather than a call that changes
nothing and reports success. The venue returns only the fields that changed.
opts[:account] names which subaccount to rename and is required on a master key.
@spec request_approved_address( String.t(), String.t(), String.t() | nil, map(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Asks the venue to add address to the withdrawal allowlist for network —
/v1/approvedAddresses/{network}/request.
A successful response is not permission to withdraw. The venue holds a new entry under
a time lock and reports it as pending-time until the lock lifts; a withdrawal to it
before then is refused. Read the list back with list_approved_addresses/2 and check
ApprovedAddress.usable?/2, which answers nil while the venue states no activation time.
@spec revoke_access_token( map(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Revokes an access token — POST /v1/oauth/revokeByToken.
Only reachable with an OAuth token, not with an API key: the endpoint revokes the
token that authenticates the call. A credential map without access_token is refused here
rather than sent, because an API-key-signed call would revoke nothing and report success
shape.
Once revoked the token cannot be used again, and neither can any request already in flight that had not reached the venue.
@spec stake(String.t(), Decimal.t(), map(), keyword()) :: {:ok, DpExchange.Core.Types.StakingTransaction.t()} | {:error, term()} | {:refused, term()}
Stakes amount of asset — POST /v1/staking/stake.
This moves funds. The decision belongs to the consumer; this package carries it out and reports what the venue said.
opts[:provider_id] names the provider. It is required, because the same asset can be
staked with several at different rates and picking one here would stake at a rate the
caller never chose. Missing it is {:error, :missing_provider_id} before a request is
made.
Confirms the credentials reach the venue, using its own heartbeat endpoint.
A real round trip rather than a guess: /v1/heartbeat is authenticated, so a success
proves the key, the signature and the nonce mode are all right. It also resets the
session's cancel-on-disconnect timer, which is a side effect worth knowing about — on a
key provisioned with Requires Heartbeat, calling this keeps open orders alive.
@spec transfer_internal(String.t(), Decimal.t(), keyword(), map(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Moves amount of asset between two accounts at this venue —
/v1/account/transfer/{currency}.
Not withdraw/5. Nothing leaves the venue and no chain is involved, so there is no
address, no network and no network fee. opts[:from] and opts[:to] are the venue's own
account names and both are required: a transfer with one end missing is not a transfer,
and defaulting either would move funds between accounts the caller did not name.
@spec unstake(String.t(), Decimal.t(), map(), keyword()) :: {:ok, DpExchange.Core.Types.StakingTransaction.t()} | {:error, term()} | {:refused, term()}
Redeems amount of a staked asset — POST /v1/staking/unstake.
Returns immediately; the redemption does not complete immediately. The returned
transaction carries :amount_remaining, non-zero for as long as the asset is unbonding. A
caller treating the return value as settled will spend an asset it does not have yet.
opts[:provider_id] is required for the same reason it is on stake/4: redeeming from
the wrong provider redeems at the wrong rate, and a default gives the caller no way to
notice.
@spec withdraw(String.t(), String.t(), Decimal.t(), String.t(), map(), keyword()) :: {:ok, DpExchange.Core.Types.Withdrawal.t()} | {:error, term()} | {:refused, term()}
Withdraws amount of asset over network to address — /v2/withdraw/{network}/{ticker}.
This moves funds and cannot be undone. Everything below exists because of that.
A retry without an idempotency key withdraws twice
The venue accepts clientTransferId, "a unique UUID for idempotent withdrawals. If
provided, duplicate requests with the same clientTransferId will not create additional
withdrawals." It is optional at the venue and not optional here: this generates one
when the caller gives none.
A withdrawal request that times out has an unknown outcome — the funds may already be
moving. Without a key, the safe-looking response (retry) is the one that sends the money
again. opts[:client_transfer_id] lets a caller supply its own so a retry across a
process restart is still the same request.
The memo is required on some networks and this package cannot tell you which
The venue: "Required for certain networks that use memos (e.g., Solana, XRP, Cosmos)." It publishes no machine-readable list, so this does not guess one. A withdrawal to an exchange address on a memo network without one is credited to nobody and is generally not recoverable.
opts[:memo] is passed through. opts[:memo_required] is a caller's assertion, not a
lookup: passing true with no memo is refused here rather than sent.
Three preconditions the venue states
- The account has an approved address list
- The destination is already on it —
list_approved_addresses/2, and note that an address can be present and still time-locked - The API key carries the Fund Manager role
None can be checked from here without spending a request, and all three fail at the venue with a message; they are stated so a caller can check them before it gets there.