Skip to content
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ To refresh: `curl -sL -o openapi.yaml https://docs.kalshi.com/openapi.yaml` (sam

Where the spec and production disagree, production wins, and the difference gets documented in the README. Known cases:

- Error bodies. The spec's `ErrorResponse` is flat `{"code","message","details"}`. Production returns `{"error":{"code":...,"message":...}}` for most errors and `{"msg":"..."}` for parameter-binding 400s. `newAPIError` parses all three; keep it that way if the spec changes. `APIError.RawBody` always has the body.
- Hosts. Spec primary REST host is `external-api.kalshi.com`; `api.elections.kalshi.com` is listed as also supported and is the client default. AsyncAPI names `external-api-ws.kalshi.com`; the client defaults to `api.elections.kalshi.com` for WS too. Both answer.
- Error bodies. The spec's `ErrorResponse` is flat `{"code","message","details"}`. Production returns `{"error":{"code":...,"message":...}}` for most errors and `{"msg":"..."}` for parameter-binding 400s. `newAPIError` parses all three (from up to 64 KiB of the body); keep it that way if the spec changes. `APIError.RawBody` always has the first 512 bytes.
- Hosts. The client defaults to the spec's primary hosts: `external-api.kalshi.com` (REST) and `external-api-ws.kalshi.com` (WS). The shared `api.elections.kalshi.com` serves both protocols, is listed as also supported, and was the default before v0.6.2; `BaseURL` / `WSHost` select it.
- `client_order_id` deduplication is documented in Kalshi's quick-start guide, not in the OpenAPI field description. A replay the server already applied returns 409.

## Conventions
Expand All @@ -47,7 +47,7 @@ Where the spec and production disagree, production wins, and the difference gets
- `CreateV2` / `BatchCreateV2` pick at runtime based on whether every order has a `client_order_id`.
- Endpoints whose spec response is empty return `error` only.
- Prefer fixed-point `_fp` and `_dollars` string fields. Legacy integer price/count fields are being removed by Kalshi; do not add new ones.
- WebSocket: command replies are matched by `id`; `get_snapshot` is the exception (answered by `orderbook_snapshot` frames keyed by `sid`). Multi-channel `Subscribe` expects one `subscribed` reply per channel. All socket writes go through `writeMu`.
- WebSocket: command replies are matched by `id`; `get_snapshot` is the exception (answered by `orderbook_snapshot` frames keyed by `sid`). Multi-channel `Subscribe` expects one `subscribed` reply per channel. Data-frame writes go through `writeSem` (a 1-slot channel so waiters can honor their context) and are bounded by `WSWriteTimeout`; control frames use gorilla's `WriteControl`, which serializes itself.
- Match the surrounding code. No drive-by refactors, reformatting, or comment rewrites in files you are not otherwise changing.

## Tests are mandatory
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to this project are documented here. The client tracks [Kals

**Versioning.** The module follows semver. While it is at major version 0, a **minor** release may contain breaking changes; when it does, they are listed first under a `### Breaking` heading with migration notes, and CI refuses a release that has API-incompatible changes (per `gorelease`) without that section, or that has one on a patch bump. Patch releases never break. From v1.0.0 on, breaking changes require a major bump.

## [0.6.2] — 2026-09-12

Default hosts move to Kalshi's recommended `external-api` endpoints; WebSocket writes and `Close` are bounded; `DoConcurrent` no longer spawns a goroutine per index; long error bodies decode; the repository has a license. No API-incompatible changes.

### Fixed

- **A WebSocket write could block past the caller's deadline and hang `Close`** ([#15](https://github.com/UTXOnly/oddrip/issues/15)). `Subscribe` / `Unsubscribe` / `ListSubscriptions` / `UpdateSubscription` wrote to the socket with no deadline while holding the write lock, so on a wedged or backpressured socket one command blocked indefinitely, every later command queued behind it, context deadlines had no effect, and `Close` never reached its bounded wait. Each frame is now written with the sooner of the context deadline and `WSWriteTimeout` (default 10s), and waiting for the write slot honors the context. A write that times out fails the connection with an error wrapping the new `ErrWSWriteTimeout` (check with `errors.Is`): `Messages()` closes and `Err()` reports it, the same path as `ErrWSSlowConsumer`; the caller whose own deadline cut the write gets `context.DeadlineExceeded`. A caller that gives up waiting for the slot gets `ctx.Err()` and the connection stays healthy. `Close` now sends the close frame as a bounded control write, skips it when a command write is in flight, and returns within about `WSWriteTimeout` plus five seconds even when the peer is not reading.
- **`DoConcurrent` started one goroutine per index even with a bounded `maxInFlight`** ([#14](https://github.com/UTXOnly/oddrip/issues/14)). The limit gated the active `fn` calls behind a semaphore, but every goroutine was created up front and parked on it, so a large `n` with a small limit still cost `n` goroutine stacks. A positive `maxInFlight` now starts at most `min(n, maxInFlight)` workers that take the next index as their current call returns, and the result buffer is sized to the workers rather than `n`. Results are still index-ordered, and a cancelled context still returns the results collected so far plus `ctx.Err()`, after which workers take no further indices and never block on the result channel. `maxInFlight <= 0` is unchanged (all `n` calls at once), except that a context already cancelled on entry no longer invokes `fn` — matching what bounded mode already did.
- **`APIError` lost `Code` / `Message` / `Details` when a valid error body exceeded 512 bytes** ([#16](https://github.com/UTXOnly/oddrip/issues/16)). Only the 512-byte `RawBody` snippet was read, and the structured fields were decoded from that truncated buffer, so long validation details or gateway metadata reduced the error to `api error STATUS`. The structured fields are now decoded from up to 64 KiB of the body; `RawBody` is still the first 512 bytes, and bodies over 64 KiB are read no further and are not decoded.

### Changed

- **Default hosts are now `external-api.kalshi.com` (REST) and `external-api-ws.kalshi.com` (WebSocket)** ([#18](https://github.com/UTXOnly/oddrip/issues/18)), the production endpoints Kalshi recommends and the primary hosts in both specs. The shared `api.elections.kalshi.com` host that was the default through 0.6.1 remains supported for both protocols; pass `oddrip.BaseURL("https://api.elections.kalshi.com/trade-api/v2")` and `oddrip.WSHost("api.elections.kalshi.com")` to keep using it. Request signing is unaffected — the signed message is `timestamp + METHOD + path` and excludes the host. If you allowlist egress hosts, add the new ones. The WebSocket example maps `external-api.kalshi.com` in `BASE_URL` to the `-ws` host.

### Added

- `WSWriteTimeout(d)` option (default 10s; `<= 0` uses the default) and `ErrWSWriteTimeout` ([#15](https://github.com/UTXOnly/oddrip/issues/15)).
- `LICENSE` — MIT ([#17](https://github.com/UTXOnly/oddrip/issues/17)). The vendored `openapi.yaml` / `asyncapi.yaml` are Kalshi's published specifications and are not covered by it; the README says so.
- **Tests:** `DoConcurrent` goroutine bound at large `n`, cancellation while workers are blocked, `n == 0`; WebSocket write-slot cancellation, cancelled and live callers contending, write timeout as a terminal error, a caller deadline cutting a write, a blocked writer not holding other callers, bounded `Close` with a stuck writer and with a full socket; default REST request destination and WebSocket dial URL; the signed path is identical across the external-api, shared, and demo base URLs; long, malformed, and oversized error bodies through `newAPIError`.

## [0.6.1] — 2026-09-12

Error bodies from production now decode; specs synced to OpenAPI 3.30.0; four missing query filters; malformed WebSocket frames fail the connection. No API-incompatible changes.
Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Brian Hartford

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Go client for the [Kalshi Trade API](https://docs.kalshi.com/): REST plus WebSocket market data. Tracks vendored OpenAPI **3.30.0** / AsyncAPI **2.0.0**.

```bash
go get github.com/UTXOnly/oddrip/oddrip@v0.6.1
go get github.com/UTXOnly/oddrip/oddrip@v0.6.2
```

Go 1.24+. Import the client as `github.com/UTXOnly/oddrip/oddrip` and types as `github.com/UTXOnly/oddrip/oddrip/types`. `oddrip.Version` matches the module tag.
Expand All @@ -26,7 +26,7 @@ client := oddrip.New(
)
```

Default base URL is `https://api.elections.kalshi.com/trade-api/v2`. The spec lists `https://external-api.kalshi.com/trade-api/v2` as the primary production host and both as supported; pass `oddrip.BaseURL(...)` to switch, or for demo (`https://demo-api.kalshi.co/trade-api/v2`). `oddrip.HTTPClient(...)` swaps the transport. Auth is RSA-PSS (PKCS#8 or PKCS#1 PEM) over `timestamp + METHOD + path` (query string excluded); the same signer is used for REST and the WebSocket handshake.
Default base URL is `https://external-api.kalshi.com/trade-api/v2`, the production host Kalshi recommends. The older shared host `https://api.elections.kalshi.com/trade-api/v2` (the default before v0.6.2) is still supported; pass `oddrip.BaseURL(...)` to use it, or for demo (`https://demo-api.kalshi.co/trade-api/v2`). Switching hosts does not affect signing — the signed message covers the path only. `oddrip.HTTPClient(...)` swaps the transport. Auth is RSA-PSS (PKCS#8 or PKCS#1 PEM) over `timestamp + METHOD + path` (query string excluded); the same signer is used for REST and the WebSocket handshake.

## REST

Expand Down Expand Up @@ -68,7 +68,7 @@ for {
}
```

Non-2xx responses are `*oddrip.APIError` with `StatusCode`, `Code`, `Message`, and `RawBody` (first 512 bytes). Kalshi's production error bodies nest `code` / `message` under `"error"` (the spec shows them flat) and parameter-binding 400s use `{"msg": ...}`; all three shapes are parsed. `RequestID` is read from a `Request-Id` header Kalshi does not currently send. An empty ticker or ID path parameter returns `oddrip.ErrEmptyPathParam` without sending — an empty order ID would otherwise hit CancelAll.
Non-2xx responses are `*oddrip.APIError` with `StatusCode`, `Code`, `Message`, and `RawBody` (first 512 bytes). `Code`, `Message`, and `Details` are decoded from up to 64 KiB of the body; a larger body is not decoded and leaves them empty. Kalshi's production error bodies nest `code` / `message` under `"error"` (the spec shows them flat) and parameter-binding 400s use `{"msg": ...}`; all three shapes are parsed. `RequestID` is read from a `Request-Id` header Kalshi does not currently send. An empty ticker or ID path parameter returns `oddrip.ErrEmptyPathParam` without sending — an empty order ID would otherwise hit CancelAll.

## Retries

Expand All @@ -87,7 +87,7 @@ client := oddrip.New(oddrip.RetryConfigOption(oddrip.RetryConfig{MaxAttempts: 1}

## Concurrent requests

The client is safe for concurrent use. `DoConcurrent(ctx, n, maxInFlight, fn)` runs at most `maxInFlight` calls at once (`0` is unbounded) and returns results in index order. Cancel returns the results collected so far plus `ctx.Err()`.
The client is safe for concurrent use. `DoConcurrent(ctx, n, maxInFlight, fn)` runs at most `maxInFlight` calls at once (`0` is unbounded) and returns results in index order. A positive `maxInFlight` also caps the worker goroutines at `min(n, maxInFlight)`, so `n` can be large without creating `n` goroutines. Cancel returns the results collected so far plus `ctx.Err()`.

```go
results, err := oddrip.DoConcurrent(ctx, len(tickers), 8, func(i int) (*types.GetMarketResponse, error) {
Expand Down Expand Up @@ -141,7 +141,7 @@ if err := conn.Err(); !errors.Is(err, oddrip.ErrWSClosed) {
}
```

Commands: `Subscribe`, `Unsubscribe`, `ListSubscriptions`, `UpdateSubscription`. Channel names and `WSType*` constants are in `types`. CF Benchmarks channels take `IndexIDs` (`[]string{"all"}` for every index). Command rejections are returned as `*oddrip.WSError`. Default endpoint is `wss://api.elections.kalshi.com/trade-api/ws/v2`; the AsyncAPI names `external-api-ws.kalshi.com` as the production host — both accept connections. Point elsewhere with `WSHost` / `WSPath` / `WSScheme`.
Commands: `Subscribe`, `Unsubscribe`, `ListSubscriptions`, `UpdateSubscription`. Channel names and `WSType*` constants are in `types`. CF Benchmarks channels take `IndexIDs` (`[]string{"all"}` for every index). Command rejections are returned as `*oddrip.WSError`. Default endpoint is `wss://external-api-ws.kalshi.com/trade-api/ws/v2`, the production host Kalshi recommends; the older shared `api.elections.kalshi.com` (the default before v0.6.2) still accepts connections. Point elsewhere with `WSHost` / `WSPath` / `WSScheme` — note the recommended REST and WebSocket hosts differ (`external-api` vs `external-api-ws`), so a `BaseURL` override does not imply a `WSHost` one.

- If `Messages()` falls behind, the connection fails with `ErrWSSlowConsumer` (buffer default 4096) rather than dropping deltas. Reconnect and re-snapshot any local book.
- Errors scoped to a subscription arrive on `Messages()` as `Type: "error"` with a `SID`, not as a returned `*WSError`. Codes 10 (channel error) and 25 (subscription buffer overflow) are terminal for that subscription — resubscribe. Decode into `types.ErrorMsg`.
Expand All @@ -150,7 +150,8 @@ Commands: `Subscribe`, `Unsubscribe`, `ListSubscriptions`, `UpdateSubscription`.
- `get_snapshot` needs `SID` or a one-element `Sids`. It returns when the first `orderbook_snapshot` for that subscription arrives (`Type` is `"orderbook_snapshot"`); the frames also go to `Messages()`.
- `indexlist` / `underlying_list` replies are not `Type: "ok"`.
- A multi-channel `Subscribe` that fails partway returns the accepted SIDs alongside the `*WSError`.
- `Close` is idempotent. Commands are safe to call concurrently.
- Every socket write is bounded by `WSWriteTimeout` (default 10s) or the command's context deadline, whichever is sooner. A write that times out fails the connection: `Err()` wraps `ErrWSWriteTimeout` and `Messages()` closes, same as a slow consumer; the command whose own deadline cut the write returns `context.DeadlineExceeded`. A command that gives up while waiting its turn to write returns `ctx.Err()` and leaves the connection healthy.
- `Close` is idempotent and returns within about `WSWriteTimeout` plus five seconds even if the peer has stopped reading. Commands are safe to call concurrently.

## Examples

Expand All @@ -165,3 +166,7 @@ CI runs `gofmt`, `go mod tidy`, `go vet`, `staticcheck`, `govulncheck`, and `go
3. Update the `@vX.Y.Z` pin in this README.

Semver. While at v0, a minor release may break; those changes go first under `### Breaking`. CI runs `gorelease` against the previous tag and refuses a release that has API-incompatible changes without that heading, or that declares one on a patch bump.

## License

MIT — see [LICENSE](LICENSE). `openapi.yaml` and `asyncapi.yaml` are Kalshi's published API specifications ([OpenAPI](https://docs.kalshi.com/openapi.yaml), [AsyncAPI](https://docs.kalshi.com/asyncapi.yaml)), vendored unmodified as the contract this client is built against. They are Kalshi's documents and are not covered by this repository's license; Kalshi's own terms apply to them and to use of the API.
2 changes: 1 addition & 1 deletion cmd/example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ KALSHI_ACCESS_KEY=$(cat key_id) KALSHI_PRIVATE_KEY_PATH=./private_key.pem go run
|----------|----------|-------------|
| `KALSHI_ACCESS_KEY` | for auth | API key ID |
| `KALSHI_PRIVATE_KEY_PATH` | for auth | Path to the PEM file |
| `BASE_URL` | no | Default: demo. Production: `https://api.elections.kalshi.com/trade-api/v2` |
| `BASE_URL` | no | Default: demo. Production: `https://external-api.kalshi.com/trade-api/v2` (or the shared `https://api.elections.kalshi.com/trade-api/v2`) |
| `LIVE` | no | `1` places two 1¢ bids on the open 15m BTC market (`KXBTC15M`) and cancels the second. Runs against `BASE_URL` (demo unless you set production). Leaves the first order resting. Both orders use fixed `client_order_id`s (`example-resting`, `example-cancel`), so a second run may be rejected with 409. |

Without auth, only public endpoints run.
2 changes: 1 addition & 1 deletion cmd/example/websocket_example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Subscribe / list / update / unsubscribe / receive. No orders. Writes `ws_calls.log` in the current directory.

Uses the same credentials as the REST example (`KALSHI_ACCESS_KEY`, `KALSHI_PRIVATE_KEY_PATH`). Default is demo (`wss://demo-api.kalshi.co/trade-api/ws/v2`). Demo often returns `websocket: bad handshake`; set `BASE_URL=https://api.elections.kalshi.com/trade-api/v2` (WS URL is derived from `BASE_URL`).
Uses the same credentials as the REST example (`KALSHI_ACCESS_KEY`, `KALSHI_PRIVATE_KEY_PATH`). Default is demo (`wss://demo-api.kalshi.co/trade-api/ws/v2`). Demo often returns `websocket: bad handshake`; set `BASE_URL=https://external-api.kalshi.com/trade-api/v2` for production. The WS URL is derived from `BASE_URL`: `external-api.kalshi.com` maps to `external-api-ws.kalshi.com`; any other host (the shared `api.elections.kalshi.com`, demo) is used as-is.

Exercises ticker, orderbook_delta, trade, market_lifecycle_v2, multi-market ticker with `send_initial_snapshot`, `ListSubscriptions`, `add_markets` / `delete_markets`. Does not call `get_snapshot`, Pyth, or CF Benchmarks.

Expand Down
8 changes: 7 additions & 1 deletion cmd/example/websocket_example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ func wsURLFromBase(baseURL string) (host, path string) {
if path == u.Path {
path = "/trade-api/ws/v2"
}
return u.Host, path
host = u.Host
// The recommended production hosts differ per protocol; the shared and
// demo hosts serve both.
if host == "external-api.kalshi.com" {
host = "external-api-ws.kalshi.com"
}
return host, path
}

func logSection(log *os.File, title string, body string) {
Expand Down
2 changes: 1 addition & 1 deletion oddrip/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
"github.com/UTXOnly/oddrip/oddrip/internal/retry"
)

const defaultBaseURL = "https://api.elections.kalshi.com/trade-api/v2"
const defaultBaseURL = "https://external-api.kalshi.com/trade-api/v2"

type RetryConfig struct {
MaxAttempts int
Expand Down
Loading