# Deep Dive — Transport and Signing

The TypeScript SDK automates request signing, nonce management, retries, and request dispatch. This guide explains transport internals to help you debug authentication and tune client timeouts.

## HMAC signing flow

Authenticated REST requests pass through a signing pipeline that constructs three required headers:

### Step by step

1. **Build the payload object**: The SDK combines the API endpoint path with your request parameters and a fresh nonce:
   ```json
   {
     "request": "/v1/order/new",
     "nonce": 1691234567890,
     "symbol": "BTCUSD",
     "amount": "0.01",
     "price": "50000",
     "side": "buy",
     "type": "exchange limit"
   }
   ```

2. **Serialize to JSON**: A `bigint`-safe serializer formats exact numeric values.

3. **Base64-encode**: The JSON string is encoded to base64.

4. **HMAC-SHA384 sign**: The base64 payload is signed with your API secret using HMAC-SHA384.

5. **Attach headers**: Three authentication headers are attached to the HTTP request:

   | Header | Value |
   |---|---|
   | `X-GEMINI-APIKEY` | Your API key |
   | `X-GEMINI-PAYLOAD` | Base64-encoded JSON payload |
   | `X-GEMINI-SIGNATURE` | Hex-encoded HMAC-SHA384 signature |

The HTTP request body is empty (`Content-Length: 0`). The logical payload is encoded entirely within the `X-GEMINI-PAYLOAD` header.

### 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`):

```
Request A: nextNonce() → 100 → sign(100) → resolve headers
Request B: nextNonce() → 101 → waits for A's sign → sign(101) → resolve headers
Request C: nextNonce() → 102 → waits for B's sign → sign(102) → resolve headers
```

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 |

```ts
import { HmacAuth } from "@gemini-markets/sdk/server";

const auth = new HmacAuth({
  apiKey: "your-api-key",
  apiSecret: "your-api-secret",
  nonceMode: "monotonic", // default
});
```

## 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:

```
GET /v1/pubticker/BTCUSD HTTP/1.1
Host: api.gemini.com
```

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:

```
POST /v1/order/new HTTP/1.1
Host: api.gemini.com
Content-Length: 0
Content-Type: text/plain
Cache-Control: no-cache
X-GEMINI-APIKEY: your-api-key
X-GEMINI-PAYLOAD: eyJyZXF1ZXN0Ijoi...
X-GEMINI-SIGNATURE: a1b2c3d4...
```

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 transport
- `X-GEMINI-APIKEY`, `X-GEMINI-SIGNATURE` — always set by the auth strategy
- `nonce` in 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

1. The `OAuthAuth` strategy loads tokens from the caller-provided `OAuthTokenStore`
2. If the access token is expired (or within the 60-second refresh skew), the token is refreshed automatically
3. The access token is sent as a Bearer token:

```
Authorization: Bearer eyJhbGciOi...
```

### Serialized refresh

Token refresh is serialized through the token store's required `runExclusive` method. This prevents concurrent requests from triggering multiple refresh calls (which would fail because refresh tokens are single-use):

```
Request A: token expired → runExclusive → refresh → save → continue
Request B: token expired → runExclusive → load (sees A's fresh token) → continue
```

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. The lock must be shared by every client instance and process using the store.

### WebSocket auth

WebSocket connections authenticate differently from REST. The internal
WebSocket session calls `websocketAuthHeaders()` which:

1. Gets a nonce from the auth strategy
2. Base64-encodes just the nonce (not a full payload envelope)
3. Gets credential headers (HMAC signature of the encoded nonce)
4. Sends `X-GEMINI-NONCE` and `X-GEMINI-PAYLOAD` as WebSocket upgrade headers

For server-side OAuth-authenticated WebSocket connections, only the
`Authorization: Bearer ...` header is sent (no nonce or payload). This does not
apply to `BrowserOAuthAuth`: browser OAuth is REST-only, because native browser
WebSockets cannot send that upgrade header.

## 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:

```
validate body → add nonce + request fields → JSON.stringify → base64 → HMAC sign → fetch
```

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](/tools/typescript-sdk/deep-dives/request-validation) 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

```ts
// Default 30-second timeout
const ticker = await client.marketData.getTicker({ symbol: "BTCUSD" });

// Custom timeout
const ticker = await client.marketData.getTicker({
  symbol: "BTCUSD",
}, { timeoutMs: 5_000 });

// External cancellation
const controller = new AbortController();
const ticker = await client.marketData.getTicker({
  symbol: "BTCUSD",
}, { signal: controller.signal });

// Cancel from outside
controller.abort();
```

### 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](/tools/typescript-sdk/authentication) — configuring HMAC and OAuth credentials
- [Error Handling](/tools/typescript-sdk/errors) — error types and classification
- [Request Validation deep dive](/tools/typescript-sdk/deep-dives/request-validation) — client-side validation details
- [Data Types deep dive](/tools/typescript-sdk/deep-dives/data-types) — bigint serialization in payloads
- [API Specifications](/api-specifications) — full request/response schemas
