Operator backend HTTP API
The operator backend exposes a small REST surface over its view of the ledger. It does exactly two kinds of work, and the split is the thing to understand before reading the endpoint tables:
- Operator-observed reads. The operator’s active-contract-set view and its indexer, projected into JSON the dApp renders — pairs, pools, order books, a party’s holdings, trade and swap history. Reads never move value and, with two scoping exceptions below, need no authorization.
- Orchestration writes. Administrative and settlement commands the operator is authorized to submit, plus the explicitly documented operator-mediated RFQ routes. These are gated by a bearer token.
Order funding, holding allocation, swaps, and LP actions preserve a
self-custodial boundary: a trader wallet authors the allocation and this API
only requests or settles it. The RFQ write endpoints are a custodial exception.
They submit as configured trader parties, and acceptance also submits as the
operator, so the backend ledger user must hold those act-as rights.
testnet-server.ts disables that relay by default; opting in requires
per-caller JWT binding. Do not describe or expose that authority model as
self-custodial.
The server in this repository has no /v1/testnet/* namespace, party faucet,
or public-host provisioning. Those are deployment concerns, not hidden API
routes. The only generic signing relay is the development-only endpoint
documented below.
flowchart LR
UI["dApp / integrator"]
subgraph op["Operator backend — this API"]
R["Reads ACS + indexer → JSON"]
W["Orchestration writes operator commands + mediated RFQ"]
end
A["Trader wallet (CIP-0103)"]
L[("Canton ledger")]
UI -->|GET| R --> L
UI -->|"POST + operator token"| W -->|authorized actAs parties| L
UI -.->|WalletIntent| A -.->|trader authority| L
A DvP flow crosses both lanes: the operator POST …/request returns an
allocation spec, the wallet authors the allocations that lock the trader’s
funds, and the operator POST …/settle invokes the atomic value transfer. The
wallet-authored lock is the step the backend cannot perform for a self-custodial
trader.
Conventions
Section titled “Conventions”- Base URL. Served on the configured port (default
8080); examples usehttp://localhost:8080. - Versioning. Every route is under
/v1. - JSON everywhere. Amounts are Daml
Decimalstrings at scale 10, never JSON numbers — the API never round-trips a value through a float. (Derived ratios that are not amounts — a 24h price change — are the one exception, and are documented as such.) - Request id. Every response carries
X-Request-Id, echoed from the request if supplied, otherwise generated. - Body limit. POST bodies over 1 MiB are rejected with 413.
- CORS. Default-deny: no
Access-Control-Allow-Originis emitted unless the request origin is on theALLOWED_ORIGINSallowlist.
The error envelope:
{ "error": "human-readable message", "code": "machine-readable code", "details": { "...optional context": "..." }, "requestId": "uuid"}A handful of store-gated routes (see
indexer-backed reads) answer with a bare
{ "error": "…" } and 503 when their backing store is absent, rather than
the full envelope.
Authorization
Section titled “Authorization”Three fail-closed gates, applied in this order:
| Gate | Applies to | Requirement |
|---|---|---|
| Admin token | /v1/admin/* writes |
Authorization: Bearer $OPERATOR_ADMIN_TOKEN |
| Operator token | every other state-changing route (pool swap/LP, order, RFQ, matched-trade, wallet relay) | Authorization: Bearer $DEX_OPERATOR_API_TOKEN |
| Per-caller binding (optional) | party-scoped reads and trader-subject writes | X-Caller-Token JWT whose sub is the caller’s own party |
Market reads are open. Account and party-history reads require an explicit
owner or trader; when per-caller binding is enabled, that party must match a
valid X-Caller-Token (401 missing/invalid, 403 mismatch). An admin token
may read any party. The unfiltered forms of /v1/trades, /v1/rfq, and
/v1/rfq/history require the admin token because their rows name both parties.
On the in-memory dev server, DEX_DEV_OPEN=1 opens the operator-write gate
without a token; see
Local Setup → Exercising write paths.
When the operator token is unset and the dev bypass is off, an operator write
returns 401. When per-caller binding is configured
(callerJwtSecret), a party-scoped read or trader-subject write with no valid
X-Caller-Token returns 401; a valid token for a different party returns
403. Binding is off by
default (a single trusted backend); turn it on when the backend fronts
mutually-distrusting callers.
The optional custodial RFQ mode is stricter: testnet-server.ts refuses to
enable DEX_HOSTED_RFQ_RELAY=1 unless DEX_CALLER_JWT_SECRET is present. For
that mode, per-caller binding is mandatory rather than optional.
Read endpoints
Section titled “Read endpoints”Auth is open for market reads unless the row says otherwise. Rows marked caller-bound require the party token only when per-caller binding is enabled.
Reads — context and market
Section titled “Reads — context and market”| Method · Path | Purpose |
|---|---|
GET /v1/context |
Static parties and factory CIDs the dApp needs to build wallet intents |
GET /v1/status |
Network id, ledger slot (offset), sync flag, server time |
GET /v1/pairs |
All DexPair contracts (whether or not they have a pool) |
GET /v1/pools |
All active pools |
GET /v1/instruments |
Instrument metadata, merged from the registry configs; ?ids=BTC,USDC filters |
GET /v1/prices?pairs= |
Advisory pool mid-prices for fiat display |
GET /v1/context returns the DexContext — the operator holds the knowledge of
which admin governs which instrument and which factory to allocate against, so
it surfaces it here rather than making the dApp guess:
{ "operator": "...", "lpRegistrar": "...", "admin": "...", "allocationFactoryCid": "...", "settlementFactoryCid": "...", "allocationFactoryExtraArgs": { "context": { "values": {} }, "meta": { "values": {} } }, "allocationFactoryDisclosure": [ /* DisclosedContract[] for the wallet */ ], "network": "canton:devnet"}GET /v1/status reports slot as the participant’s latest ledger-end offset,
polled every two seconds. synced reflects the most recent probe. A failed
configured-participant probe keeps the last real offset and returns
synced:false; only the no-Canton in-memory dev server uses a local counter:
{ "network": "canton:devnet", "slot": 1234567, "synced": true, "serverTime": "2026-05-17T..." }GET /v1/instruments merges decimals from Registry.V2 InstrumentConfig with
decimals/isin/cusip from the reference registry’s InstrumentConfig, keyed by
instrumentId, then unions in the instruments referenced by active pools so the
list is populated even before any config is registered. Both config templates
are signatory admin, so the endpoint reads as admin and lpRegistrar, not
as the operator. Metadata therefore exists only for instruments issued by a
registry this deployment hosts; a foreign registry’s instrument reports null
fields until registry-client implements the standard’s off-ledger
metadata-v1 API.
[ { "instrumentId": "BTC", "symbol": "BTC", "decimals": 8, "isin": null, "cusip": null, "description": null } ]Reads — order book
Section titled “Reads — order book”| Method · Path | Purpose |
|---|---|
GET /v1/orders?trader= |
Open orders for one trader; caller-bound (400 without ?trader=) |
GET /v1/orders/book?pair=BASE/QUOTE |
Resting bids and asks for one market |
GET /v1/orders/matches?pair=BASE/QUOTE |
Crossable pairs — a read-only preview |
Both pair-scoped reads also accept ?base="e=, and return 400 if
neither form resolves. /v1/orders/matches projects each cross down to its
terms — price, quantity, buyOrderCid, sellOrderCid. The Order
contracts themselves name their traders and allocations and are not served here;
the operator route that acts on a match
(POST /v1/orders/match) sits behind the operator token.
Reads — account
Section titled “Reads — account”| Method · Path | Purpose |
|---|---|
GET /v1/holdings?owner= |
Per-contract (UTXO-style) holding rows; caller-bound (400 without ?owner=) |
GET /v1/balances?owner= |
Caller-bound holding totals per instrument, available vs locked |
/v1/balances saves every client re-deriving a balance from the UTXO-style
rows. locked is the portion committed to open orders, swaps, or allocations;
the split is exact decimal math:
[ { "instrumentId": "BTC", "total": "0.2500000000", "available": "0.2500000000", "locked": "0.0000000000" }, { "instrumentId": "USDC", "total": "5000.0000000000", "available": "5000.0000000000", "locked": "0.0000000000" }]Reads — history, stats, indexer-backed
Section titled “Reads — history, stats, indexer-backed”These read the SQLite indexer and return 503 when the server was started
without a db handle.
| Method · Path | Purpose | Auth |
|---|---|---|
GET /v1/trades?trader=&pair=&limit= |
accepted RFQ MatchedTrades + the SettledTrade each order-book fill writes |
caller-bound / admin unfiltered |
GET /v1/swaps?pair=&kind=&limit= |
Pool history; kind ∈ swap,add_liquidity,remove_liquidity,state_change (default swap) |
open |
GET /v1/rfq/history?trader=&limit= |
RFQ lifecycle rows, including accepted quotes (trader, pair, winning dealer, rank) | caller-bound / admin unfiltered |
GET /v1/price-history?pair=&hours= |
Price points from the swaps feed (hours 1–720, default 24) |
open |
GET /v1/stats/24h?pair= |
24h price change, volume, swap count | open |
GET /v1/dealers |
Dealer registry — public list | open |
/v1/trades matches ?trader= on either side: a party is trader on the
trades it initiated and counterparty on those it was matched into. dealer is
a role, set only where a signed policy receipt names one, so it is null on
order-book fills. On /v1/swaps, inputAmount/outputAmount are derived
textually from the signed reserve deltas the indexer stores — a positive
baseDelta means the pool gained base, i.e. the swapper sent base and received
quote — so the stored scale survives. /v1/stats/24h’s priceChange24h is the
one genuinely float-valued field on the API: it is a ratio, not an amount.
Reads — RFQ
Section titled “Reads — RFQ”| Method · Path | Purpose | Auth |
|---|---|---|
GET /v1/rfq?owner= |
RFQs and quotes scoped to one party | caller-bound / admin unfiltered |
A trader sees the RFQs they raised or were whitelisted for; a dealer sees the quotes they posted or received. The operator observes every RFQ and quote — who is asking, on what, in what size, and the price each dealer answered — so the unscoped sweep is admin-only:
{ "rfqs": [ /* Rfq[] */ ], "quotes": [ /* RfqQuote[] */ ] }The Pool, DexPair, Order, Holding, Balance, and Instrument shapes are
defined in
services/operator-backend/src/types.ts.
| Method · Path | Purpose | Auth |
|---|---|---|
POST /v1/swaps/quote |
Exact off-ledger swap quote | open |
A quote is advisory. The authoritative /v1/pools/swap/request call accepts the
trader’s minimum, binds a pool-state snapshot and slice set on-ledger, and
returns an allocation specification with the exact input and output leg sides.
The dApp verifies that response before the wallet signs it. PoolRules_Swap
then re-derives the output and rejects any snapshot or allocation whose legs no
longer match, so the operator cannot quote one number and settle another.
Because the quote endpoint runs the same function off-ledger, preview and
settlement agree to the last digit (see Pricing).
Supply poolCid; poolId is also accepted and resolves either the ContractId
or the logical id (e.g. "BTC-USDC").
// request{ "poolCid": "#2:0", "inputInstrumentId": "BTC", "inputAmount": "0.5" }// response — the output plus the fields a client would otherwise recompute{ "outputAmount": "9496.5947516312", "inputInstrumentId": "BTC", "outputInstrumentId": "USDC", "feeBps": 30, "feeAmount": "0.0015000000", // fee applied to the input "executionPrice": "18993.18...", // output per unit input "spotPrice": "20000.00...", // pre-trade reserve mid "priceImpact": "0.0503...", // (spot − execution) / spot "poolCid": "#2:0", "poolId": "BTC-USDC"}Write endpoints
Section titled “Write endpoints”All writes below return 400 for malformed JSON or a missing/invalid field
(amounts must be Decimal strings, parties canonical hint::fingerprint, cids
non-empty), 413 over 1 MiB, and require the operator token unless noted.
Field-level specs live in
services/operator-backend/src/http/validate.ts.
The two-call DvP pattern
Section titled “The two-call DvP pattern”Every pool swap and LP move is a delivery-versus-payment settlement, and the operator holds only one side of it. So each runs as two operator calls around one wallet step:
POST …/request— the operator opens the flow (for LP, by creating aLiquidityAllocationRequest) and returns the allocation specs, factories, choice contexts, and disclosures the wallet needs, alongside a quote.- The trader’s wallet authors the allocations via
AllocationFactory_Allocate, locking the trader’s funds under the trader’s own authority. POST …/settle(orPOST /v1/pools/swapfor a swap) — the operator, and thelpRegistraron LP moves, exercises the settle choice: funds enter or leave the pool and LP tokens mint or burn, atomically.
If a wallet returns only an updateId (no created-event tree),
POST /v1/pools/recover-dvp-allocations recovers the created allocation cids
from the transaction tree so the settle can still be assembled.
Pool — swap and liquidity
Section titled “Pool — swap and liquidity”| Method · Path | Purpose |
|---|---|
POST /v1/pools/swap/request |
Open a swap; returns the allocation spec + choice context |
POST /v1/pools/swap |
Settle with the wallet-created allocation (PoolRules_Swap) |
POST /v1/pools/add-liquidity/request |
Open add-LP; create LiquidityAllocationRequest, return quote + specs |
POST /v1/pools/add-liquidity/settle |
PoolLiquidityRules_SettleAddLiquidity (operator + lpRegistrar) |
POST /v1/pools/remove-liquidity/request |
Open remove-LP |
POST /v1/pools/remove-liquidity/settle |
PoolLiquidityRules_SettleRemoveLiquidity (operator + lpRegistrar) |
POST /v1/pools/recover-dvp-allocations |
Recover created allocation cids from an updateId-only receipt |
POST /v1/pools/swap/request requires poolCid, swapper,
inputInstrumentId, inputAmount, and minOutputAmount. Its response includes
the settlement, exact allocationSpec, registry context/disclosure, and a
quoteBinding containing the pool id, state cid, selected slice cids, and
minimum. Pass that binding unchanged to POST /v1/pools/swap with the wallet’s
swapperAllocationCid. If the state or slices have moved, settlement fails and
the terminal, uncommitted swap allocation remains withdrawable by its trader.
An add off the reserve ratio is only partly taken. LP tokens are minted
against whichever leg is short relative to the pool’s ratio
(min((base·S)/rb, (quote·S)/rq)), and only the matching part of the other leg
enters the reserves. The add-liquidity/request response reports both parts, so
the receipt reflects what actually settled rather than what was asked:
{ "requestCid": "...", "lpAmount": "29.6531048680", "matchedBaseAmount": "0.1000000000", "matchedQuoteAmount": "8847.7436669408", "refundedBaseAmount": "0.0000000000", "refundedQuoteAmount": "1152.2563330592", "offRatioBps": "1152.2563330592", "knownTotalLpSupply": "1581.0163443902", "baseAmount": "0.1", "quoteAmount": "10000.0"}matchedBaseAmount/matchedQuoteAmount are the parts the minted LP tokens
represent and that reach the pool; refundedBaseAmount/refundedQuoteAmount are
the remainder, which PoolLiquidityRules_SettleAddLiquidity returns to the
depositor in the same transaction. At the reserve ratio both remainders are
zero, and the first deposit into an unfunded pool sets the ratio.
The optional maxOffRatioBps (0..10000) refuses the request with 400
when offRatioBps exceeds it, before any contract is created — use it when a
partly-filled add is not what you want. Decimal rounding alone can leave a
sub-bps remainder on an otherwise on-ratio deposit, so 0 is stricter than it
looks; 1 is the usual “on ratio” check.
Order lifecycle
Section titled “Order lifecycle”| Method · Path | Purpose |
|---|---|
POST /v1/orders/bind |
Bind a funded order to a settlement ref (full-tree or updateId recovery) |
POST /v1/orders/fund |
Fund a bound order |
POST /v1/orders/:cid/cancel |
Cancel an open order (204) |
POST /v1/orders/match |
Discover crossing orders and settle each atomically |
POST /v1/orders/match catches per match so one bad pair cannot stop the rest,
and reports the outcome in its status: 200 when all settled, 207 when
some failed, 502 when every one did.
{ "matches": [ /* per-match results */ ], "settled": 3, "failed": 0 }Matched-trade (OTC) settlement
Section titled “Matched-trade (OTC) settlement”| Method · Path | Purpose |
|---|---|
POST /v1/matched-trades/request-allocations |
MatchedTrade_RequestAllocations |
POST /v1/matched-trades/settle |
MatchedTrade_Settle — one allocation batch per admin |
POST /v1/matched-trades/cancel |
MatchedTrade_Cancel — release allocations |
settle and cancel carry a batchesByAdmin / allocationsByAdmin object
keyed by admin party; each admin’s batch must cover exactly its own legs, or the
request is rejected with 400 before it reaches the ledger.
| Method · Path | Purpose |
|---|---|
POST /v1/rfq |
Create an RFQ on a trader’s behalf |
POST /v1/rfq/:cid/cancel |
Cancel an open RFQ (204) |
POST /v1/rfq/accept |
Operator + trader co-sign the accept → { tradeCid, receipt } |
These three writes are disabled (404) by default in testnet-server.ts.
DEX_HOSTED_RFQ_RELAY=1 enables the custodial mode only when
DEX_CALLER_JWT_SECRET is also configured; the participant user must have
actAs rights for every configured trader. This flag does not provision
parties or make the server a public service. Reads remain available when the
mode is disabled.
// POST /v1/rfq{ "trader": "...", "rfqId": "...", "pair": "BTC/USDC", "side": "RFQ_Buy", "size": "0.5", "expiresAt": "2026-...", "whitelist": [], "createdAt": "..." }cancel and accept act as the fetched RFQ’s trader, which a body-field
binding cannot reach. With per-caller binding on, both resolve the caller from
the X-Caller-Token and reject a mismatch (403), so an operator-token holder
cannot cancel or accept on another trader’s behalf.
The admin routes require the admin token, except the config read, which is open. The dealer routes additionally require the indexer (503 without it).
| Method · Path | Purpose |
|---|---|
POST /v1/admin/pairs |
Create a DexPair → { pairCid } |
POST /v1/admin/pairs/:cid/fee-model |
Update the pair’s fee model |
POST /v1/admin/pairs/:cid/active |
Activate / deactivate a pair |
POST /v1/admin/pairs/:cid/trading-mode |
Set the pair’s trading mode |
POST /v1/admin/pools |
Create a pool → { poolCid } |
GET /v1/admin/config |
Dump operator config (open read) |
PUT /v1/admin/config |
Set a key { key, value } |
DELETE /v1/admin/config/:key |
Delete a key |
PUT /v1/admin/dealers |
Upsert a dealer |
DELETE /v1/admin/dealers/:party |
Remove a dealer |
The pass-through bodies for the pair/pool routes are the service inputs in
services/operator-backend/src/admin/index.ts.
Wallet relay — dev only
Section titled “Wallet relay — dev only”| Method · Path | Purpose | Auth |
|---|---|---|
POST /v1/wallet/submit |
Forward shaped ledger commands under the operator JWT | operator + flag |
Only the in-memory dev-server.ts can enable this route with
DEX_DEV_WALLET_RELAY=1; testnet-server.ts hard-disables it even if that
variable leaks into a deployment environment. In dev, forwarded actAs
parties must be on DEX_DEV_RELAY_PARTIES (else 403), the commands array
and commandId are shape-checked, and the relay follows the committed
transaction tree to return created allocation cids. It is a walletless local
diagnostic, not a public faucet, hosted-party service, or production authority
path.
Wallet intent shapes
Section titled “Wallet intent shapes”For self-custodial allocation writes, the frontend hands an intent to the active
WalletProvider rather than calling the ledger. Shapes are in
app/web/src/wallet/types.ts:
| Intent | When |
|---|---|
FundOrderIntent |
Trader locks holdings for a pending order |
PlaceOrderIntent |
Trader places a new order |
RequestSwapIntent |
Trader initiates a pool swap |
AddLiquidityIntent |
Trader authors the base / quote / LP-receipt allocations for an add |
RemoveLiquidityIntent |
Trader authors the base / quote-receipt and LP burn-sender allocations for a remove |
The wallet also exposes SplitHoldingIntent / MergeHoldingsIntent for holding |
|
| management; those are a wallet concern and have no operator endpoint. |
Status and error codes
Section titled “Status and error codes”code |
HTTP | Meaning |
|---|---|---|
bad_request |
400 | malformed JSON, missing field, invalid amount / party / cid |
unauthorized |
401 | missing or invalid operator / admin token |
forbidden |
403 | per-caller party mismatch, or wallet-relay actAs not allowlisted |
not_found |
404 | route or resource not found (also the disabled wallet relay) |
payload_too_large |
413 | body > 1 MiB |
not_supported |
501 | a demo-mode limitation surfaced cleanly, not a server fault |
internal_error |
500 | unexpected server error |
Beyond the enveloped codes, POST /v1/orders/match returns 207 / 502 for
partial / total settlement failure, the wallet relay returns 502
(tree_fetch_failed) when a committed transaction’s tree cannot be fetched, and
indexer- or config-gated routes return a bare-{ error } 503 when their
store is absent.
On-ledger assertions
Section titled “On-ledger assertions”The codes above wrap the backend’s own validation. A write can also fail inside the Daml settlement, where the ledger returns the choice’s assertion message. The most common ones and how a client should handle them:
| On-ledger assertion | Triggering condition | Suggested handling |
|---|---|---|
Output below slippage minimum |
the request-time quote is below the trader’s minOutputAmount, so no allocation specification is issued |
refresh the quote or widen the slippage tolerance before signing |
stale swap quote binding |
the bound pool state or reserve slices changed before settlement | withdraw the uncommitted allocation, request a fresh signed specification, and retry |
expectedPoolId mismatch (pool config swapped?) |
the referenced pool contract is no longer the active one for the pair | refresh the client’s pool cache and rebuild the request |
add: base reserve delta must equal created base slice amount |
reserve and slice arithmetic disagree during a liquidity settle (should be unreachable) | operator alert; run PoolRules_ReconcileState |
Allocation_Settle: settlement deadline has passed |
an order or RFQ allocation was settled after its deadline | release the reserved funds with the cancel / withdraw choice |
LP tokens below minimum |
ratio drift on add left the minted LP below the caller’s floor | re-quote the deposit at the current pool ratio |
These are the on-ledger messages; the backend surfaces them under a
bad_request or internal_error envelope depending on the route.
Examples
Section titled “Examples”Reads need no auth; /v1/admin/* writes need Authorization: Bearer $OPERATOR_ADMIN_TOKEN, other writes Authorization: Bearer $DEX_OPERATOR_API_TOKEN (or DEX_DEV_OPEN=1 on the dev server).
# Reads: pairs, pools, a trader's aggregated balancecurl -s http://localhost:8080/v1/pairs | python3 -m json.toolcurl -s http://localhost:8080/v1/pools | python3 -m json.toolcurl -s "http://localhost:8080/v1/balances?owner=$TRADER" | python3 -m json.tool
# Advisory swap quote (re-validated on-ledger by PoolRules_Swap)curl -s -X POST http://localhost:8080/v1/swaps/quote \ -H 'content-type: application/json' \ -d '{"poolCid":"#2:0","inputInstrumentId":"BTC","inputAmount":"0.5"}'# -> {"outputAmount":"9496.59...", ...}
# Create an RFQ on a trader's behalf (operator token)curl -s -X POST http://localhost:8080/v1/rfq \ -H "authorization: Bearer $DEX_OPERATOR_API_TOKEN" -H 'content-type: application/json' \ -d '{"trader":"'"$TRADER"'","rfqId":"rfq-1","pair":"BTC/USDC","side":"RFQ_Buy", "size":"0.5","expiresAt":"2026-12-31T00:00:00Z","whitelist":[],"createdAt":"2026-07-01T00:00:00Z"}'Existing projection rows. Reindex after deploying a version that changes derived amount or party fields; rows already stored in SQLite retain their original values until rebuilt.
services/operator-backend/scripts/reindex-derived.tsrecomputes both in place (idempotent,--dry-runfirst) with no ledger read.
Where to read next: Builder Guide · Choice Context · Allocation surface · Pricing