---
title: "Get a single OHLCV candle series"
method: GET
path: "/v1/candles"
tags: ["Candles"]
---

# Get a single OHLCV candle series

`GET /v1/candles`

## Request

- Base URL: `https://md.kairos.trade`
- URL: `https://md.kairos.trade/v1/candles`
- Auth: one of:
  - none
  - API key in header `X-Client-Id` and API key in header `X-Api-Key` and API key in header `X-Api-Secret`

Returns OHLCV candles for one `(provider, contract_id, timeframe_seconds, outcome)`
series over `[start, end)`.

**Alignment.** `start` is floored and `end` is ceiled to the nearest
`timeframe_seconds` bucket boundary; `end` is additionally clamped to
`now + 1 bucket`. Degenerate windows (`end <= start` after alignment)
are extended by exactly one bucket. The requested window is silently
clamped (not rejected) to the per-timeframe retention ceiling: 1s=1
day, 1m=30 days, 5m=90 days, 15m=180 days, 1h=365 days, 4h=365 days,
1d=730 days. For `timeframe_seconds=1`, if the aligned window falls
entirely before `now - 24h` (the retention window for 1-second
candles) the endpoint short-circuits to `{"candles":[]}` without
querying upstream.

**Timeframes.** Only 1s and 1m candles are stored directly; every
other timeframe (5m, 15m, 1h, 4h, 1d) is rolled up server-side from
the 1m base. When the first read comes back empty, `60`, `3600`,
`14400` and `86400` retry as a rollup of the 1s base (brand-new
contracts whose only history is 1-second candles); `300` and `900`
have no such fallback and return an empty series instead. Prices are
on a 0-100 scale. For providers that are not natively per-token
(i.e. not `kalshi`, `polymarket`, `dome`, `opinion`, `predictfun`,
`hyperliquid`), requesting `outcome > 0` inverts prices
(`price = 100 - price`, high/low swapped) rather than resolving a
distinct token. The check is on the provider string exactly as
submitted, so the `kalshi_offchain` alias takes the inverting path
even though `kalshi` does not — send `kalshi` for Kalshi candles.

**Rate limiting / cost.** This route is in the `light` rate-limit
bucket. Larger requests — wider windows, finer timeframes — consume
more of your quota; a typical chart paint (a few hundred bars) costs
1 unit.

**Caching.** `Cache-Control` depends on how the aligned window
relates to `now`: a window still in progress (`end >= now`) is
`no-store`; a window entirely in the recent past is cached briefly;
anything older is cached for longer. Cacheable responses carry a
strong ETag and honor `If-None-Match`, returning `304` on a match.
Responses ≥1KB are gzip-encoded when the client sends
`Accept-Encoding: gzip`.

**Binary format.** Pass `?fmt=binary` or send
`Accept: application/x-kairos-candles` to receive a compact
columnar binary frame instead of JSON. Layout: `u8 magic=0xCA,
u8 version=1, u16 num_results`, then per result `u32 index,
u32 count`, followed by the columnar arrays `u32[count] t` (epoch
seconds), `u16[count] o,h,l,c` (price ×100), `i64[count] vol`
(×100). A single-series `GET` response always has `num_results=1`.

## Query parameters

- `provider` 'kalshi' | 'polymarket' | 'opinion' | 'predictfun' | 'hyperliquid' | 'dome' | 'kalshi_offchain', required
- `contract_id` string, required
- `timeframe_seconds` 1 | 60 | 300 | 900 | 3600 | 14400 | 86400, required
- `start` string, required
- `end` string, required
- `outcome` integer
- `fmt` 'binary'

## Response `200`

Candle series for the requested window. `{"candles":[]}` (empty array, `no-store`) is a valid 200 response, not an error — it means the window is authoritatively empty.

- CandleListResponse: Response body of `GET /v1/candles` in JSON mode.
  - `candles` CandleObject[], required: Candles in ascending `bucket_start` order. Empty when the aligned window is authoritatively empty.
    - `contract_id` string, required: Echo of the request's `contract_id` (the caller-supplied id, not the resolved canonical market id).
    - `timeframe_seconds` 1 | 60 | 300 | 900 | 3600 | 14400 | 86400, required: Bucket width in seconds, echoing the request.
    - `bucket_start` string, required: Bucket start timestamp, UTC, always rendered with an explicit `+00:00` offset (not `Z`).
    - `open` number, double, required: Opening price (0-100 scale).
    - `high` number, double, required: High price in the bucket (0-100 scale).
    - `low` number, double, required: Low price in the bucket (0-100 scale).
    - `close` number, double, required: Closing price (0-100 scale).
    - `volume` integer, required: Traded volume in the bucket, provider-native units.
    - `token_id` string: Resolved per-outcome CLOB token id. Omitted entirely from the JSON object when empty.

## Other responses

- `304`: Not Modified — `If-None-Match` matched the current ETag. Empty body.
- `400`: Malformed or invalid query parameters.
- `401`: No valid credential presented. Every path returns `code: unauthorized`; the triggers differ: - An `Authorization` header on a deployment where bearer auth is not enabled (`bearer authentication is not enabled`). - A bearer token that fails verification (`invalid or expired session`), or whose token/session has been revoked (`session has been revoked`). - An incomplete or wrong API-key header set. Sending *any* of `X-Client-Id`, `X-Api-Key`, `X-Api-Secret` commits the request to the API-key path, so a partial set is rejected rather than silently degrading to anonymous limits (`valid X-Client-Id, X-Api-Key, and X-Api-Secret headers are required`). - No credential material at all while the anonymous free tier is unavailable — switched off for the deployment, forced off through the runtime kill switch, or not yet resolvable on a replica that has never read the flag (it fails closed).
- `403`: API key presented from a source IP not in that credential's whitelist.
- `429`: Two different gates return this status, both with `code: rate_limited`: - **Per-credential (or per-IP, when anonymous) weighted budget exhausted.** The message is `rate limit exceeded`, or, for anonymous callers, `anonymous rate limit exceeded — request an API key for higher limits (…)`. `Retry-After` is the whole number of seconds until the current one-minute window rolls over, floored at 1. All four `X-RateLimit-*` headers are present. - **Per-IP admission gate.** An in-process, pre-authentication guard that keeps a flood off the shared credential cache and database. The message is `too many requests from this address` and `Retry-After` is a flat `60`. Because this fires *before* authentication, the response carries **no** `X-RateLimit-*` headers at all.
- `500`: Upstream candle fetch failed.
- `503`: The rate limiter is unreachable. Fails CLOSED — the request is rejected rather than let through unmetered.

---

[API](https://skmtc.dev/kairos/apis/kairos-market-data-api.md) - [All operations](https://skmtc.dev/kairos/apis/kairos-market-data-api/llms.txt) - [OpenAPI document](https://skmtc.dev/kairos/apis/kairos-market-data-api/revisions/01e987f59a04?raw)
