# Quickstart

> Make your first small call, price the big ones first and poll without paying twice.

## Before you start

You need a key. Access is by request today, and the key arrives by email. Put it in an environment variable called ODDSRELAY_KEY and never in code, a query string or a browser bundle.

## The whole flow in three languages

Each sample makes one small call on the standard board: soccer match odds at one bookmaker in the UK & Ireland. It prices the call first with a free quote, makes it with gzip, reads the token headers, then polls again with the ETag. It backs off on a 429 and stops on a 402. Each is tested against the live API.

### cURL (oddsrelay.sh)

```curl
#!/usr/bin/env bash
# OddsRelay API sample, cURL. Run: ODDSRELAY_KEY=or_live_… bash oddsrelay.sh
#
# One small filtered call on the standard board, the way every client should make it:
#   1. price it first with a free quote=true;
#   2. make it with --compressed (gzip; standard refuses a call without it) and read the token headers;
#   3. poll it again with If-None-Match, where an unchanged board answers 304, free;
#   4. back off on 429 (Retry-After) and stop on 402 (out of tokens).
set -euo pipefail

BASE="https://api.oddsrelay.io"
: "${ODDSRELAY_KEY:?set ODDSRELAY_KEY}"
# Soccer match odds at one bookmaker, UK & Ireland: a small call.
URL="$BASE/v2/odds/standard?region=uk&sports=soccer&markets=h2h&bookmakers=ladbrokes"
MAX_TOKENS=1000

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

# call URL [extra header]: writes headers to $tmp/h, body to $tmp/b, prints the status. Retries 429s.
call() {
  local status extra=()
  [ -n "${2:-}" ] && extra=(-H "$2")
  for _ in 1 2 3; do
    status=$(curl --compressed -sS -o "$tmp/b" -D "$tmp/h" -w '%{http_code}' \
      -H "Authorization: Bearer $ODDSRELAY_KEY" ${extra[@]+"${extra[@]}"} "$1")
    if [ "$status" = 429 ]; then
      # The front door's own 429 has an empty body; both carry Retry-After.
      wait=$(awk -F': ' 'tolower($1)=="retry-after"{print $2+0}' "$tmp/h")
      echo "429: waiting ${wait:-1} s" >&2
      sleep "${wait:-1}"
      continue
    fi
    if [ "$status" = 402 ]; then
      echo "402: out of tokens: $(cat "$tmp/b")" >&2
      exit 1
    fi
    echo "$status"
    return
  done
  echo "still rate-limited after 3 tries" >&2
  exit 1
}

header() { awk -F': ' -v k="$(echo "$1" | tr 'A-Z' 'a-z')" 'tolower($1)==k{sub(/\r$/,"",$2); print $2}' "$tmp/h"; }
tokens() { echo "cost=$(header X-Tokens-Cost) remaining=$(header X-Tokens-Remaining) reset=$(header X-Tokens-Reset)"; }

# 1. Quote: free.
[ "$(call "$URL&quote=true")" = 200 ] || { echo "quote failed: $(cat "$tmp/b")" >&2; exit 1; }
cost=$(sed -E 's/.*"cost": *([0-9]+).*/\1/' "$tmp/b")
echo "quote: $cost tokens"
[ "$cost" -le "$MAX_TOKENS" ] || { echo "quote $cost is above $MAX_TOKENS: narrow the filters" >&2; exit 1; }

# 2. The call.
[ "$(call "$URL")" = 200 ] || { echo "call failed: $(head -c 200 "$tmp/b")" >&2; exit 1; }
etag=$(header ETag)
echo "200: $(tokens)"

# 3. Poll with the ETag: 304 if nothing changed (free), else a fresh 200.
status=$(call "$URL" "If-None-Match: $etag")
case "$status" in 200|304) ;; *) echo "poll answered $status" >&2; exit 1 ;; esac
echo "$status on poll: $(tokens)"
echo "ok"
```

### Python (oddsrelay.py)

```python
# OddsRelay API sample, Python 3 standard library only. Run: ODDSRELAY_KEY=or_live_… python3 oddsrelay.py
#
# One small filtered call on the standard board, the way every client should make it:
#   1. price it first with a free quote=true;
#   2. make it with gzip and read the token headers (urllib sends Accept-Encoding: identity unless
#      told otherwise, and standard refuses that, so this asks for gzip and decompresses);
#   3. poll it again with If-None-Match, where an unchanged board answers 304, free;
#   4. back off on 429 (Retry-After) and stop on 402 (out of tokens).
import gzip
import json
import os
import time
import urllib.error
import urllib.request

BASE = "https://api.oddsrelay.io"
KEY = os.environ["ODDSRELAY_KEY"]

# Soccer match odds at one bookmaker, UK & Ireland: a small call.
PATH = "/v2/odds/standard?region=uk&sports=soccer&markets=h2h&bookmakers=ladbrokes"
MAX_TOKENS = 1000  # don't make the call if the quote is above this


def call(path, headers=None):
    """(status, response headers, decoded body bytes). Retries 429s; raises on 402."""
    for _ in range(3):
        req = urllib.request.Request(
            BASE + path,
            headers={"Authorization": f"Bearer {KEY}", "Accept-Encoding": "gzip", **(headers or {})},
        )
        try:
            with urllib.request.urlopen(req) as res:
                status, hdrs, raw = res.status, res.headers, res.read()
        except urllib.error.HTTPError as err:  # 304 and every 4xx/5xx arrive here
            status, hdrs, raw = err.code, err.headers, err.read()
        if hdrs.get("Content-Encoding") == "gzip" and raw:
            raw = gzip.decompress(raw)
        if status == 429:
            # The front door's own 429 has an empty body; both carry Retry-After.
            wait = int(hdrs.get("Retry-After", "1"))
            print(f"429: waiting {wait} s")
            time.sleep(wait)
            continue
        if status == 402:
            err = json.loads(raw)["error"]
            raise SystemExit(f"402 {err['code']}: {err['cost']} tokens needed, {err['remaining']} left until {err['resets_at']}")
        return status, hdrs, raw
    raise SystemExit("still rate-limited after 3 tries")


def tokens(hdrs):
    return f"cost={hdrs.get('X-Tokens-Cost')} remaining={hdrs.get('X-Tokens-Remaining')} reset={hdrs.get('X-Tokens-Reset')}"


# 1. Quote: free.
status, _, raw = call(PATH + "&quote=true")
if status != 200:
    raise SystemExit(f"quote answered {status}")
quote = json.loads(raw)
print(f"quote: {quote['cost']} tokens, {quote['remaining']} left")
if quote["cost"] > MAX_TOKENS:
    raise SystemExit(f"quote {quote['cost']} is above {MAX_TOKENS}: narrow the filters")

# 2. The call.
status, hdrs, raw = call(PATH)
if status != 200:
    raise SystemExit(f"call answered {status}: {raw[:200]!r}")
etag = hdrs.get("ETag")
meta = json.loads(raw)["meta"]
print(f"200: {meta['count']} events, board time {meta['processed_at']}, {tokens(hdrs)}")

# 3. Poll with the ETag: 304 if nothing changed (free), else a fresh 200.
status, hdrs, _ = call(PATH, {"If-None-Match": etag})
if status not in (200, 304):
    raise SystemExit(f"poll answered {status}")
print(f"{status} on poll: {tokens(hdrs)}")
print("ok")
```

### JavaScript (oddsrelay.mjs)

```js
// OddsRelay API sample, JavaScript (Node 18+). Run: ODDSRELAY_KEY=or_live_… node oddsrelay.mjs
//
// One small filtered call on the standard board, the way every client should make it:
//   1. price it first with a free quote=true;
//   2. make it (fetch sends Accept-Encoding: gzip and decompresses for you) and read the token headers;
//   3. poll it again with If-None-Match, where an unchanged board answers 304, free;
//   4. back off on 429 (Retry-After) and stop on 402 (out of tokens).

const BASE = "https://api.oddsrelay.io";
const KEY = process.env.ODDSRELAY_KEY;
if (!KEY) throw new Error("set ODDSRELAY_KEY");

// Soccer match odds at one bookmaker, UK & Ireland: a small call.
const PATH = "/v2/odds/standard?region=uk&sports=soccer&markets=h2h&bookmakers=ladbrokes";
const MAX_TOKENS = 1000; // don't make the call if the quote is above this

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function call(path, headers = {}) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    const res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${KEY}`, ...headers } });
    if (res.status === 429) {
      // The front door's own 429 has an empty body; both carry Retry-After.
      const wait = Number(res.headers.get("retry-after") ?? "1");
      console.log(`429: waiting ${wait} s`);
      await sleep(wait * 1000);
      continue;
    }
    if (res.status === 402) {
      const { error } = await res.json();
      throw new Error(`402 ${error.code}: ${error.cost} tokens needed, ${error.remaining} left until ${error.resets_at}`);
    }
    return res;
  }
  throw new Error("still rate-limited after 3 tries");
}

const tokens = (res) =>
  `cost=${res.headers.get("x-tokens-cost")} remaining=${res.headers.get("x-tokens-remaining")} reset=${res.headers.get("x-tokens-reset")}`;

// 1. Quote: free.
const quoteRes = await call(`${PATH}&quote=true`);
if (quoteRes.status !== 200) throw new Error(`quote answered ${quoteRes.status}`);
const quote = await quoteRes.json();
console.log(`quote: ${quote.cost} tokens, ${quote.remaining} left`);
if (quote.cost > MAX_TOKENS) throw new Error(`quote ${quote.cost} is above ${MAX_TOKENS}: narrow the filters`);

// 2. The call.
const res = await call(PATH);
if (res.status !== 200) throw new Error(`call answered ${res.status}: ${await res.text()}`);
const etag = res.headers.get("etag");
const { meta } = await res.json();
console.log(`200: ${meta.count} events, board time ${meta.processed_at}, ${tokens(res)}`);

// 3. Poll with the ETag: 304 if nothing changed (free), else a fresh 200.
const again = await call(PATH, { "If-None-Match": etag });
if (again.status !== 304 && again.status !== 200) throw new Error(`poll answered ${again.status}`);
console.log(`${again.status} on poll: ${tokens(again)}`);
console.log("ok");
```

## What to change first

Swap the board in the path, and the filters in the query, for the data you need. Keep region on every call, keep gzip on, and keep the quote step for any call you haven't priced before.

Source: https://oddsrelay.io/docs/guides/quickstart · the API contract: https://api.oddsrelay.io/v2/openapi.json
