Crypto Exchange API Development (REST, WebSocket)
An exchange API is the public interface for traders, bots, and third-party integrations. Its quality determines the platform's attractiveness to professional participants. Binance, Kraken, OKX have standardized expectations: REST for order management and account, WebSocket for real-time data.
REST API Design
Endpoint Structure
Standard structure for public and private API:
Public API (no authentication):
GET /api/v1/markets - list of trading pairs
GET /api/v1/markets/{pair}/ticker - current ticker
GET /api/v1/markets/{pair}/orderbook - order book snapshot
GET /api/v1/markets/{pair}/trades - recent trades
GET /api/v1/markets/{pair}/candles - OHLCV data
Private API (requires authentication):
GET /api/v1/account/balances - balances
GET /api/v1/account/orders - list of orders
POST /api/v1/account/orders - place order
DELETE /api/v1/account/orders/{id} - cancel order
POST /api/v1/account/orders/cancel-all - cancel all orders
GET /api/v1/account/trades - trade history
GET /api/v1/account/deposits - deposit history
POST /api/v1/account/withdrawals - create withdrawal
Authentication
Standard for crypto APIs — HMAC-SHA256 request signature:
// Server signature verification
func verifySignature(r *http.Request, secret string) bool {
apiKey := r.Header.Get("X-API-Key")
timestamp := r.Header.Get("X-Timestamp")
signature := r.Header.Get("X-Signature")
// Check timestamp (not older than 5 seconds — replay protection)
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if time.Now().UnixMilli()-ts > 5000 {
return false
}
// Signature string: method + path + timestamp + body
method := r.Method
path := r.URL.RequestURI()
body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(body)) // restore body
message := method + path + timestamp + string(body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(message))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
Client-side signature:
import hmac, hashlib, time, requests
def signed_request(method: str, path: str, body: dict = None):
timestamp = str(int(time.time() * 1000))
body_str = json.dumps(body) if body else ''
message = method + path + timestamp + body_str
signature = hmac.new(SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()
headers = {
'X-API-Key': API_KEY,
'X-Timestamp': timestamp,
'X-Signature': signature,
'Content-Type': 'application/json',
}
return requests.request(method, BASE_URL + path, headers=headers, data=body_str)
Response Format
Consistent format for all endpoints:
// Success
{
"success": true,
"data": { ... },
"timestamp": 1700000000000
}
// Error
{
"success": false,
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Insufficient balance to place order",
"details": { "available": "0.5", "required": "1.0" }
},
"timestamp": 1700000000000
}
Standardized error codes are important for integrators. Better INSUFFICIENT_BALANCE than 400 Bad Request with text.
Rate Limiting
// Rate limiter by API key with bucket algorithm
type RateLimiter struct {
redis *redis.Client
}
func (rl *RateLimiter) Check(apiKey string, weight int) error {
key := "rate_limit:" + apiKey
// Sliding window counter
now := time.Now().UnixMilli()
windowStart := now - 60000 // 60 seconds
pipe := rl.redis.Pipeline()
// Remove stale records
pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(windowStart, 10))
// Add current request
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: fmt.Sprintf("%d-%d", now, rand.Int())})
// Count sum of weights
pipe.ZCard(ctx, key)
pipe.Expire(ctx, key, 2*time.Minute)
results, _ := pipe.Exec(ctx)
count := results[2].(*redis.IntCmd).Val()
// Different limits for different user levels
limit := rl.getUserLimit(apiKey) // 1200 req/min for standard
if int(count) > limit {
return ErrRateLimitExceeded
}
return nil
}
Binance-style weight system: different endpoints have different "weights" (GET orderbook = 5, POST order = 1, GET all orders = 10). Allows flexible load management.
Pagination
For historical data — cursor-based pagination (better than offset for large tables):
type TradesQuery struct {
Pair string `json:"pair"`
Limit int `json:"limit"` // max 1000
StartTime *int64 `json:"start_time,omitempty"`
EndTime *int64 `json:"end_time,omitempty"`
FromID *string `json:"from_id,omitempty"` // cursor
}
func (h *Handler) GetTrades(w http.ResponseWriter, r *http.Request) {
q := parseTradesQuery(r)
trades, err := h.db.GetTrades(q)
var nextCursor *string
if len(trades) == q.Limit {
lastID := trades[len(trades)-1].ID
nextCursor = &lastID
}
writeJSON(w, map[string]interface{}{
"trades": trades,
"next_cursor": nextCursor,
"has_more": nextCursor != nil,
})
}
WebSocket API
WebSocket Server Architecture
type WSHub struct {
clients map[*WSClient]bool
subscriptions map[string]map[*WSClient]bool // channel -> clients
broadcast chan WSMessage
register chan *WSClient
unregister chan *WSClient
mu sync.RWMutex
}
func (h *WSHub) Run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client] = true
h.mu.Unlock()
case client := <-h.unregister:
h.mu.Lock()
delete(h.clients, client)
for _, subs := range h.subscriptions {
delete(subs, client)
}
h.mu.Unlock()
case message := <-h.broadcast:
h.mu.RLock()
for client := range h.subscriptions[message.Channel] {
select {
case client.send <- message.Data:
default:
// Client slow, buffer full — drop
close(client.send)
delete(h.clients, client)
}
}
h.mu.RUnlock()
}
}
}
Subscription Protocol
// Client → Server: subscription
{"op": "subscribe", "channels": ["ticker.BTC-USDT", "orderbook.ETH-USDT.50", "trades.BTC-USDT"]}
// Server → Client: confirmation
{"op": "subscribed", "channels": ["ticker.BTC-USDT", "orderbook.ETH-USDT.50", "trades.BTC-USDT"]}
// Server → Client: ticker data
{
"channel": "ticker.BTC-USDT",
"data": {
"pair": "BTC-USDT",
"last": "42150.50",
"bid": "42148.00",
"ask": "42152.00",
"volume_24h": "1234.56",
"change_24h": "+2.35",
"ts": 1700000000000
}
}
// Order book diff update
{
"channel": "orderbook.ETH-USDT.50",
"type": "diff",
"seq": 12345,
"data": {
"bids": [["2200.00", "5.5"], ["2195.00", "0"]],
"asks": [["2205.00", "3.2"]]
}
}
WebSocket Authentication
func (ws *WSClient) HandleAuth(msg AuthMessage) {
// Verify signature like in REST
if !verifyWSAuth(msg.APIKey, msg.Timestamp, msg.Signature) {
ws.sendError("AUTH_FAILED", "Invalid signature")
return
}
ws.userID = getUserIDByAPIKey(msg.APIKey)
ws.authenticated = true
ws.sendSuccess("authenticated")
}
// After authorization, access to private channels
// {"op": "subscribe", "channels": ["orders", "trades", "balances"]}
Heartbeat
func (ws *WSClient) startPingPong() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ws.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := ws.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
ws.close()
return
}
case <-ws.done:
return
}
}
}
Client must respond to Ping within 10 seconds. If not — connection closes. Clients can also send {"op": "ping"} to keep connection alive.
API Documentation
OpenAPI 3.0 + Swagger UI — standard for REST documentation. Documentation generated from code annotations or written manually:
# openapi.yaml
paths:
/api/v1/account/orders:
post:
summary: Place Order
security:
- ApiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlaceOrderRequest'
example:
pair: "BTC-USDT"
side: "buy"
type: "limit"
quantity: "0.001"
price: "42000"
time_in_force: "GTC"
WebSocket documentation — separate page describing subscription protocol, message formats, and code examples (Python, JavaScript, Go).
SDKs for Major Languages
Public API without SDKs — additional integration barrier. Minimum set:
# Python SDK
class ExchangeClient:
def __init__(self, api_key: str, api_secret: str, testnet: bool = False):
self.base_url = 'https://testnet-api.exchange.com' if testnet else 'https://api.exchange.com'
self.api_key = api_key
self.api_secret = api_secret
self.session = aiohttp.ClientSession()
async def place_order(self, pair: str, side: str, type: str,
quantity: str, price: str = None) -> dict:
return await self._post('/api/v1/account/orders', {
'pair': pair, 'side': side, 'type': type,
'quantity': quantity, 'price': price,
})
# WebSocket
async def subscribe_ticker(self, pairs: list[str], callback: Callable):
async with websockets.connect(self.ws_url) as ws:
await ws.send(json.dumps({
'op': 'subscribe',
'channels': [f'ticker.{p}' for p in pairs]
}))
async for msg in ws:
await callback(json.loads(msg))
Testing
Testnet is mandatory. Separate infrastructure with same endpoints but test currency. Binance, OKX, Kraken — all provide testnet.
Load testing: k6 or wrk for REST (goal — 10,000 req/sec at p99 latency < 50ms), artillery for WebSocket (1000+ concurrent connections, check broadcast latency).
Timeline
| Component | Timeline |
|---|---|
| Public REST API (5–7 endpoints) | 3–4 weeks |
| Private REST API + HMAC auth | 3–4 weeks |
| WebSocket server + subscriptions | 3–4 weeks |
| Rate limiting + API key management | 1–2 weeks |
| OpenAPI documentation | 1 week |
| Python SDK | 1–2 weeks |
| Load testing | 1–2 weeks |
Complete API with documentation, SDK, and testing: 3–4 months.







