Integrating a Coinbase client¶
slick-sim exposes three local endpoints that speak Coinbase Advanced Trade's own protocols — two for
order entry (submitting orders and receiving what happens to them) and one for market data
(streaming prices and trades):
| Endpoint | Sample port | Direction | Purpose |
|---|---|---|---|
| REST order entry | 4000 | client → sim | Products, and create/cancel/edit orders |
| WebSocket order entry | 4001 | sim → client | user channel — acks, fills, cancels |
| WebSocket market data | 5000 | sim → client | level2, market_trades, ticker, heartbeats |
The two order-entry endpoints are not alternatives — they are the two halves of one round trip. You send on REST and hear back on the WebSocket.
Point your client's REST base URL and WebSocket URLs at these and it should work unchanged, subject to the gaps noted throughout.
Use both order-entry endpoints
The REST route returns only the PENDING_NEW acknowledgement. Fills, cancels and rejects arrive
exclusively over the WebSocket user channel. A REST-only client will never learn that its
order traded.
Authentication¶
Both order-entry endpoints require a JWT, but neither verifies its signature.
REST expects an Authorization: Bearer <jwt> header. The gateway decodes the token, reads the sub
claim, and takes everything after the last / as the user_id:
auto decoded = jwt::decode<jwt::traits::nlohmann_json>(token);
std::string sub = decoded.get_subject();
std::string user_id = sub.substr(sub.rfind('/') + 1);
Signature verification is commented out in the WebSocket gateway and never attempted in REST. Any
syntactically valid JWT is accepted; a malformed one gets 401 Unauthorized (REST) or an error
message and a socket close (WebSocket).
The user_id matters: it is how responses are routed back. The WebSocket gateway keys its client map
by user_id, so the REST and WebSocket connections must present tokens with the same sub or
your fills will be published to nobody.
Market data requires no authentication.
REST order entry¶
Nine routes are implemented. Everything else 404s.
GET /api/v3/brokerage/market/products and GET /api/v3/brokerage/products¶
Both return the product list verbatim — either the initial_products string from your config, or
whatever was fetched from the real Coinbase API at startup. No authentication, no filtering, no query
parameter support.
GET /api/v3/brokerage/products/{product_id} and GET /api/v3/brokerage/market/products/{product_id}¶
Returns the single cached product object, or 400 Bad Request:
{"error":"INVALID_ARGUMENT","error_details":"valid product_id is required","message":"valid product_id is required"}
POST /api/v3/brokerage/orders¶
Creates an order. Requires authentication.
Request
{
"product_id": "BTC-USD",
"side": "BUY",
"client_order_id": "my-order-1",
"order_configuration": {
"limit_limit_gtc": {
"base_size": "0.01",
"limit_price": "60000.00",
"post_only": false
}
}
}
side is compared against the literal "BUY"; anything else is treated as SELL, including a
typo.
Four order_configuration variants are recognised:
| Variant | OrderType |
TimeInForce |
Fields read |
|---|---|---|---|
limit_limit_gtc |
LIMIT |
GOOD_TILL_CANCEL |
base_size, limit_price, post_only |
market_market_ioc |
MARKET |
IMMEDIATE_OR_CANCEL |
base_size |
sor_limit_ioc |
LIMIT |
IMMEDIATE_OR_CANCEL |
base_size, limit_price |
market_market_fok |
LIMIT if limit_price present, else MARKET |
FILL_OR_KILL |
base_size, optional limit_price |
Anything else returns 400 with "Unsupported order_configuration type". Notably absent:
limit_limit_gtd, stop_limit_stop_limit_gtc, trigger_bracket_gtc, and quote-denominated sizing
(quote_size).
post_only is parsed and echoed back but never enforced.
Validation. Before queueing, the gateway checks the order against the cached product spec:
| Check | Error type |
|---|---|
| Product not in the cached list | INVALID_PRODUCT_ID |
Non-market price not a multiple of price_increment |
INVALID_LIMIT_PRICE_POST_ONLY |
Size below base_min_size |
INVALID_ORDER_CONFIG |
Size not a multiple of base_increment |
INVALID_ORDER_CONFIG |
An empty product cache therefore fails every order with INVALID_PRODUCT_ID — see
initial_products.
Success response (HTTP 200)
{
"success": true,
"success_response": {
"order_id": "9f0c1a4e-…",
"product_id": "BTC-USD",
"side": "BUY",
"client_order_id": "my-order-1"
},
"order_configuration": { "limit_limit_gtc": { … } }
}
The order_id is the simulator's UUID; keep it for cancel and edit.
Rejection response (HTTP 400)
{
"success": false,
"error_response": {
"message": "CLIENT_ORDER_ID_ALREADY_EXISTS",
"error_details": "CLIENT_ORDER_ID_ALREADY_EXISTS",
"new_order_failure_reason": "CLIENT_ORDER_ID_ALREADY_EXISTS"
}
}
The strings are slick-sim's OrdRejectReason names, not
Coinbase's failure-reason enum, so a client that switches on Coinbase's documented values will not
recognise them.
Timeout (HTTP 504) after 5 s with no matching response:
POST /api/v3/brokerage/orders/batch_cancel¶
Cancels are submitted one at a time, each followed by a 200 ms wait for a rejection. No rejection within the window is reported as success.
{ "results": [
{ "success": true, "failure_reason": "", "order_id": "9f0c1a4e-…" },
{ "success": false, "failure_reason": "UNKNOWN_ORDER", "order_id": "3b7d2f10-…" }
]}
UNKNOWN_ORDER with submitted = false means the gateway has no record of that order_id at all —
it only tracks orders created through this gateway instance, in an in-memory
order_id_to_symbol_ map that is not shared with the WebSocket gateway and does not survive a restart.
Serialised cancels mean a batch of 20 unknown-to-the-engine orders can take up to 4 seconds.
POST /api/v3/brokerage/orders/edit¶
Both price and size are optional but at least one should be present; the modified values are
validated against the product spec exactly as on create.
{ "success": true, "errors": [] }
{ "success": false, "errors": [ { "edit_failure_reason": "UNKNOWN_ORDER" } ] }
An edit that makes the order marketable will trade immediately — see Matching engine. Changing the price loses queue priority; changing only the size keeps it.
GET /api/v3/brokerage/orders/historical/batch¶
Authenticates, then returns nothing. The handler builds an empty {"orders": []} object and never
writes it to the response — the HTTP request hangs until your client times out. Treat this route as
unimplemented.
GET /api/v3/brokerage/transaction_summary¶
Proxied straight to the real https://api.coinbase.com (hard-coded) using a JWT the simulator
generates itself, and relayed back verbatim. This requires working Coinbase API credentials in the
environment coinbase::generate_coinbase_jwt reads.
Order-entry WebSocket¶
Connect to ws://localhost:4001/. Every message is a subscribe request:
type must be "subscribe"; anything else returns
{"type":"error","message":"type `…` is not supported"}.
A missing jwt field returns an error. There is no unsubscribe.
| Channel | Behaviour |
|---|---|
user |
Registers the socket for execution reports. Immediately sends an order snapshot then a subscriptions confirmation |
heartbeats |
Enables periodic heartbeat messages; confirms via subscriptions |
futures_balance_summary |
Returns a hard-coded static balance snapshot — the same fabricated numbers for every user |
| anything else | {"type":"error","message":"channel `…` is not supported"} |
Execution reports¶
Every OrderResponse other than PENDING_NEW is pushed to the socket registered for that user_id:
{
"channel": "user",
"client_id": "",
"timestamp": "2026-08-11T12:34:56.789012345Z",
"sequence_num": 7,
"events": [{
"type": "update",
"orders": [{
"avg_price": "60000.000000",
"client_order_id": "my-order-1",
"completion_percentage": "100.000000",
"cumulative_quantity": "0.010000",
"filled_value": "600.000000",
"leaves_quantity": "0.000000",
"limit_price": "60000.000000",
"number_of_fills": "1",
"order_id": "9f0c1a4e-…",
"order_side": "BUY",
"order_type": "LIMIT",
"post_only": "false",
"product_id": "BTC-USD",
"reject_reason": "NONE",
"status": "FILLED",
"time_in_force": "GOOD_TILL_CANCEL",
"total_fees": "0.720000",
"total_value_after_fees": "599.280000",
"trigger_status": "INVALID_ORDER_TYPE",
"creation_time": "…",
"end_time": "0001-01-01T00:00:00Z",
"start_time": "0001-01-01T00:00:00Z"
}]
}]
}
Differences from the real Coinbase feed worth planning around:
PENDING_NEWreports are filtered out, so the first thing you see for an order is itsNEWack.- Numeric fields use
std::to_string, which gives fixed six-decimal formatting ("60000.000000"), not Coinbase's variable precision. total_feesis display-only — no fee is ever applied to the order. The rate comes from a single unguardedCoinbaseRestClient::get_taker_fee_rate()call in the gateway constructor, which unconditionally overwrites thefee_rate_ = 0.0012member initialiser — so that value is a declaration default, not a fallback, and whatever the call returns on failure is what you get. (The source comment beside it reads "1.2%"; 0.0012 is 0.12%.)completion_percentagedivides by the raw fixed-pointqty, not the scaled value, so the number is wrong by a factor of 1e8.number_of_fillsis derived ascum_qty / last_qtyrather than the trackednum_fills.
Heartbeats¶
Every 30 s (ping_interval) the gateway sends a WebSocket PING to all clients and, to those
subscribed to heartbeats, a message:
{ "channel": "heartbeats", "client_id": "", "timestamp": "…", "sequence_num": 12,
"events": [{ "current_time": "…", "heartbeat_counter": 4 }] }
A client that does not PONG within pong_timeout (60 s) is disconnected.
Market-data WebSocket¶
Connect to ws://localhost:5000/. No authentication.
| Channel | Status |
|---|---|
level2 |
Works — snapshot then incremental updates on l2_data |
market_trades |
Subscribes and confirms, then sends no trades |
ticker |
Subscribes and confirms; only an empty snapshot is ever sent |
heartbeats |
Works |
| anything else | {"type":"error","message":"channel `…` is not supported"} |
Subscribing to a symbol for the first time creates it inside the simulator and opens an upstream Coinbase feed for it, so the first snapshot arrives only once real data has been received.
l2_data¶
Snapshot:
{ "channel": "l2_data", "client_id": "", "timestamp": "…", "sequence_num": 1,
"events": [{
"type": "snapshot",
"product_id": "BTC-USD",
"updates": [
{ "side": "bid", "price_level": "60000.000000", "new_quantity": "1.500000" },
{ "side": "offer", "price_level": "60001.000000", "new_quantity": "0.800000" }
]
}] }
Incremental update — same shape with "type": "update", and each entry carries an extra event_time:
{ "side": "bid", "price_level": "60000.000000", "new_quantity": "2.000000",
"event_time": "2026-08-11T12:34:56.789012345Z" }
Bids come first in a snapshot, then offers. new_quantity is the absolute new level quantity, and it
includes your own resting orders at that price — the book is shared, so your liquidity is visible
in the market data you receive.
One-second latency
Coinbase level updates are held in a time-ordering buffer for at least one second before being applied and republished. See Market data. Snapshots bypass this.
Subscription confirmation¶
After the first snapshot for each pending symbol:
{ "channel": "subscriptions", "client_id": "", "timestamp": "…", "sequence_num": 2,
"events": [{ "subscriptions": { "level2": ["BTC-USD"], "market_trades": ["BTC-USD"] } }] }
All of the client's active subscriptions are listed, not just the one that just completed.
sequence_num¶
Per-socket and monotonically increasing across every message the publisher sends to that socket, including confirmations and heartbeats. It is not the exchange sequence number and does not correspond to anything upstream.
End-to-end example¶
# 1. start the simulator (the logs/ directory must exist)
mkdir -p logs
slick-sim config/slick_sim.json
# 2. confirm products loaded
curl -s http://localhost:4000/api/v3/brokerage/market/products | head -c 200
# 3. subscribe to market data (creates the symbol and opens the upstream feed)
websocat ws://localhost:5000/ <<'EOF'
{"type":"subscribe","channel":"level2","product_ids":["BTC-USD"]}
EOF
# 4. in another terminal, subscribe to the user channel so you can see fills
websocat ws://localhost:4001/ <<'EOF'
{"type":"subscribe","channel":"user","jwt":"<your-jwt>"}
EOF
# 5. send a marketable order
curl -s -X POST http://localhost:4000/api/v3/brokerage/orders \
-H "Authorization: Bearer <your-jwt>" \
-H "Content-Type: application/json" \
-d '{
"product_id": "BTC-USD",
"side": "BUY",
"client_order_id": "demo-1",
"order_configuration": {
"market_market_ioc": { "base_size": "0.001" }
}
}'
Step 3 is not optional: an order for a product nobody has subscribed to is rejected with
UNKNOWN_CONTRACT, because the Symbol does not exist until a market-data subscription creates it.