Deep Dive — Transport and Signing
The SDK handles request signing, nonce management, retry logic, and dispatch automatically. Understanding these internals helps you debug authentication errors, reason about retry behavior, and configure timeouts correctly.
HMAC signing flow
Every authenticated REST request goes through a signing pipeline that produces three headers. The SDK handles all of this — you never construct these headers yourself.
Step by step
-
Build the payload object — the SDK combines the API path with your request parameters and a nonce:
Code -
Serialize to JSON —
JSON.stringifywith abigint-safe replacer (bigints are serialized viaJSON.rawJSONto avoid precision loss) -
Base64-encode — the JSON string is encoded to base64
-
HMAC-SHA384 sign — the base64 payload is signed with your API secret using
crypto.subtle.sign("HMAC", key, payload) -
Attach headers — three headers are sent:
Header Value X-GEMINI-APIKEYYour API key X-GEMINI-PAYLOADBase64-encoded JSON payload X-GEMINI-SIGNATUREHex-encoded HMAC-SHA384 of the payload
The request body is empty (Content-Length: 0). The logical body is encoded entirely within X-GEMINI-PAYLOAD. This is a distinctive aspect of Gemini's private REST API.
Endpoint integrity
The request field in the payload is always set to the API path. If a caller's parameters contain a request key that would override the path, the SDK throws an EndpointMismatch error — preventing a signed payload from being routed to the wrong endpoint.
Nonce serialization and the sign queue
The exchange rejects requests with a nonce that is not strictly greater than the previous one (for monotonic mode). When multiple requests are in-flight concurrently, their nonces must arrive in order at the server.
The problem
crypto.subtle.sign() is asynchronous. If two requests call nextNonce() in sequence (getting nonces N and N+1), then sign concurrently, the signing for N+1 could complete before N. The server sees N+1 first, then rejects N as a stale nonce — even though both were valid when issued.
The solution: sign queue
HmacAuth chains every credentialHeaders() call through a Promise queue (#signQueue):
Code
Each signing operation waits for the previous one to complete before starting. This guarantees that credential headers resolve in nonce order, so fetch() calls dispatch in nonce order.
The queue is fire-and-forget for error propagation: if signing fails for request A, request B still proceeds (the queue's .then(() => {}, () => {}) swallows A's rejection). Each request independently handles its own signing errors.
Nonce modes
The SDK supports two nonce strategies:
| Mode | Behavior | Use case |
|---|---|---|
"monotonic" (default) | Millisecond timestamp, bumped by 1 if not strictly increasing | General use; safe for high-frequency concurrent requests |
"time-based" | Unix timestamp in seconds (Math.floor(now / 1000)) | Required by some account-level endpoints |
Code
Public vs private dispatch
The SDK uses two distinct dispatch paths based on whether an endpoint requires authentication.
Public endpoints (GET, no auth)
Public endpoints like market data use standard HTTP GET with query parameters:
Code
No X-GEMINI-* headers, no payload signing, no nonce. Parameters are serialized as URL query strings.
Private endpoints (POST, signed payload)
Private endpoints use POST with the signed payload in headers and an empty body:
Code
The Content-Type: text/plain and Content-Length: 0 are deliberate — the Gemini private REST convention encodes the entire request in headers, not the body.
Reserved headers
The SDK prevents both callers and auth strategies from setting headers that would conflict with the transport envelope:
X-GEMINI-PAYLOAD,Content-Length,Content-Type,Cache-Control— always set by the transportX-GEMINI-APIKEY,X-GEMINI-SIGNATURE— always set by the auth strategynoncein request params — reserved for the auth strategy
Attempting to set these throws an SdkError.
OAuth dispatch
OAuth-authenticated requests use a simpler flow: the X-GEMINI-PAYLOAD header is still sent (the exchange always expects it), but there is no HMAC signature and no nonce.
How it works
- The
OAuthAuthstrategy loads tokens from the caller-providedOAuthTokenStore - If the access token is expired (or within the 60-second refresh skew), the token is refreshed automatically
- The access token is sent as a Bearer token:
Code
Serialized refresh
Token refresh is serialized through the token store's runExclusive method. This prevents concurrent requests from triggering multiple refresh calls (which would fail because refresh tokens are single-use):
Code
Without serialization, both A and B would attempt to refresh with the same single-use refresh token. One would succeed; the other would get invalid_grant and clear the token store.
WebSocket auth
WebSocket connections authenticate differently from REST. The WsSession calls websocketAuthHeaders() which:
- Gets a nonce from the auth strategy
- Base64-encodes just the nonce (not a full payload envelope)
- Gets credential headers (HMAC signature of the encoded nonce)
- Sends
X-GEMINI-NONCEandX-GEMINI-PAYLOADas WebSocket upgrade headers
For OAuth-authenticated WebSocket connections, only the Authorization: Bearer ... header is sent (no nonce or payload).
Retry policy
Only operations marked retryable: true in their metadata are retried. In practice, this means all GET-backed reads (public market data, etc.). POST mutations are never retried — replaying a createNewOrder could cause a duplicate fill.
Retried conditions
| Condition | Behavior |
|---|---|
| Network failure (ECONNRESET, ECONNREFUSED, ETIMEDOUT, etc.) | Retry with backoff |
| HTTP 429 (rate limited) | Retry, respecting Retry-After header if present |
| HTTP 502, 503, 504 (server errors) | Retry with backoff |
Backoff parameters
| Parameter | Default | Notes |
|---|---|---|
| Base delay | 500 ms | Configurable via backoff.baseMs |
| Cap | 30 s | Configurable via backoff.capMs |
| Factor | 2 | Configurable via backoff.factor |
| Max retries | 5 | Configurable via maxRetries |
| Jitter | Equal jitter | Half fixed + half random |
The backoff formula for attempt N (0-based): min(capMs, baseMs × factor^N), then split into half fixed + half random. This is the same equal-jitter shape used by the WebSocket transport (though with a different base: 500 ms for HTTP vs 250 ms for WebSocket). Note that WebSocket reconnect attempt 0 is immediate (delay = 0) to recover from transient drops quickly; HTTP retries always apply the computed backoff.
Retry-After header
When the server sends a Retry-After header on a 429 response, the SDK respects it:
- Integer value — interpreted as seconds (e.g.
Retry-After: 5→ wait 5 seconds) - HTTP date — parsed and used as an absolute deadline
- Missing or unparseable — falls back to the calculated backoff delay
What is NOT retried
- POST mutations (order placement, cancellation, withdrawals, transfers)
- Client errors (400, 403, 404, 406)
- Auth errors (invalid nonce, invalid signature, missing role)
- Validation errors (caught before the request is sent)
Request validation pipeline
For operations with client-side validators (e.g. createNewOrder, placeOrder, withdrawCryptoFunds), validation runs before authentication signing. The full pipeline:
Code
This ordering is important: validation catches malformed requests before any signing work. A validation failure never generates a nonce, so the nonce sequence stays clean.
See the Request Validation deep dive for details on what each operation validates.
Timeout and cancellation
Every request gets a deadline controlled by timeoutMs (default: 30 seconds). The SDK uses AbortSignal internally to propagate cancellation through every async step.
How it works
Code
Error types
| Error | Cause |
|---|---|
RequestTimeoutError | The deadline elapsed before a response arrived |
RequestAbortedError | The caller's AbortSignal was aborted |
Both propagate through fetch, WebSocket operations, and auth signing. An aborted request never leaves a dangling nonce.
Timeout scope
The timeout starts inside the transport's send() method — after client-side validation has already run. It covers signing → network round-trip → response parsing, but not the validation step. For retried requests, all attempts share a single deadline — later retries have less remaining time. The timeout is per-operation, not per-attempt.
Related
- Authentication — configuring HMAC and OAuth credentials
- Error Handling — error types and classification
- Request Validation deep dive — client-side validation details
- Data Types deep dive — bigint serialization in payloads
- API Specifications — full request/response schemas