# RaceCap API

Prices of nine stores (Steam, PlayStation Store, Xbox, Nintendo eShop, Epic Games Store, GOG, EA app, Ubisoft Store, Battle.net) in every regional storefront they sell in (`/regions`, `/countries`), with history, under one canonical game catalog.

## Quick start
The API needs a plan with API access. Create a key in your account; it is shown once. Then:

```
curl -H "X-API-Key: $KEY" 'https://racecap.soracrm.net/api/v1/best?q=elden+ring&limit=5'
```

## Authentication
Send the key in `X-API-Key` (or `Authorization: Bearer <key>`). The API is for servers and scripts: it sends no CORS headers.

## Limits
Each plan sets requests a minute and a day, the largest `limit` and `offset`, the refresh priority, forced refreshes, watch slots and keys. `GET /usage` shows them and costs nothing. Answers carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` and the `-Day` variants. Over a budget the answer is `429` with `Retry-After`. A refused request (400, 429) costs nothing. `POST /prices` costs one request per item, a refresh stream one per region it checks.

## Parameters
An unknown query parameter, or one given twice, is a `400` that names it. `provider` and `country` take any letter case.

## Paging
Lists come in pages: pass `next_offset` as `offset` until it is null. Sync the whole catalog with `/items` and `after`.

## Freshness
Every price carries `seen_at`. Viewing a product or a game starts a background refresh of regions older than about 30 minutes. `max_age` (seconds) asks for prices no older than that: older ones are read again first. `max_age=0` on a refresh stream forces every region and spends a forced refresh.

## Caching
GET answers carry a weak `ETag` and `Cache-Control: private` (60 s, 300 s for cards). Send it back in `If-None-Match` to get an empty `304`.

## Idempotency
`POST /watch`, `POST /webhooks` and webhook actions take an `Idempotency-Key`: the same key and body within 24 hours replays the first answer with `Idempotent-Replayed: true`.

## Webhooks
Deliveries are POSTed JSON `{id, type, created_at, data}` signed with `X-RaceCap-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">`. Failed deliveries retry with backoff (1 min to 12 h); a webhook failing 20 times in a row is disabled until resumed.

## Errors
JSON `{error, message}`: `400 bad_request`, `401 unauthorized` or `invalid_key`, `403 subscription_required`, `404 not_found`, `409 watch_limit` or `too_many_webhooks`, `410 gone`, `415 unsupported_media_type`, `422 no_prices` or `idempotency_mismatch`, `429 rate_limited`, `500 internal`.

## Data
Prices are read from each store's regional storefront by RaceCap's own scanner; exchange rates come from ExchangeRate-API daily.

## Endpoints

### games

#### `GET /v1/games` — Search and list games

- `q` (query, string): Search text
- `limit` (query, integer): Page size, capped by the plan
- `offset` (query, integer): Items to skip, capped by the plan

Example answer:

```json
{
  "items": [
    {
      "id": "elden-ring",
      "name": "ELDEN RING",
      "stores": [
        "psn",
        "steam",
        "xbox"
      ]
    }
  ],
  "next_offset": 20
}
```

#### `GET /v1/games/info` — Cards of up to 100 games at once

- `ids` (query, csv, required): Comma separated game ids
- `lang` (query, string): Language of texts — one of en, ru

Example answer:

```json
{
  "items": [
    {
      "id": "elden-ring",
      "name": "ELDEN RING",
      "info": {}
    }
  ]
}
```

#### `GET /v1/games/info/changes` — Feed of changed game cards

- `after` (query, string): Cursor: the "next" of the previous page or an ISO time
- `limit` (query, integer): Page size

Example answer:

```json
{
  "items": [
    {
      "id": "elden-ring",
      "updated_at": "2026-10-16T12:00:00Z"
    }
  ],
  "next": "2026-10-16T12:00:00Z|elden-ring"
}
```

#### `GET /v1/games/{id}` — A game with its prices in every store

- `id` (path, string, required): Game id, e.g. elden-ring
- `country` (query, string): ISO 3166 country; the store region that serves it is used
- `currency` (query, string): Also convert prices to this currency (field "converted"); default USD only

Example answer:

```json
{
  "game": {
    "id": "elden-ring",
    "name": "ELDEN RING"
  },
  "stores": [
    {
      "provider": "steam",
      "products": [
        "app:1245620"
      ],
      "cheapest": {
        "provider": "steam",
        "product_id": "app:1245620",
        "region": "TR",
        "country": "TR",
        "currency": "USD",
        "price": "39.99",
        "base": null,
        "discount": null,
        "ends_at": null,
        "free": false,
        "preorder": false,
        "subscription_only": false,
        "plans": [],
        "usd": "39.99",
        "seen_at": "2026-10-16T15:49:49.994Z",
        "url": "https://store.steampowered.com/app/1245620/ELDEN_RING/?cc=tr"
      },
      "offers": [
        {
          "provider": "steam",
          "product_id": "app:1245620",
          "region": "TR",
          "country": "TR",
          "currency": "USD",
          "price": "39.99",
          "base": null,
          "discount": null,
          "ends_at": null,
          "free": false,
          "preorder": false,
          "subscription_only": false,
          "plans": [],
          "usd": "39.99",
          "seen_at": "2026-10-16T15:49:49.994Z",
          "url": "https://store.steampowered.com/app/1245620/ELDEN_RING/?cc=tr"
        }
      ]
    }
  ]
}
```

#### `GET /v1/games/{id}/cheapest` — Where a game costs least

- `id` (path, string, required): Game id
- `currency` (query, string): Also convert prices to this currency (field "converted"); default USD only

Example answer:

```json
{
  "items": [
    {
      "provider": "steam",
      "product_id": "app:1245620",
      "region": "TR",
      "country": "TR",
      "currency": "USD",
      "price": "39.99",
      "base": null,
      "discount": null,
      "ends_at": null,
      "free": false,
      "preorder": false,
      "subscription_only": false,
      "plans": [],
      "usd": "39.99",
      "seen_at": "2026-10-16T15:49:49.994Z",
      "url": "https://store.steampowered.com/app/1245620/ELDEN_RING/?cc=tr"
    }
  ]
}
```

#### `GET /v1/games/{id}/history` — Price history of a game

- `id` (path, string, required): Game id
- `provider` (query, string, required): Store: steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `region` (query, string, required): Store region
- `days` (query, integer): Window in days

Example answer:

```json
{
  "provider": "steam",
  "region": "TR",
  "currency": "USD",
  "points": [
    {
      "at": "2026-10-10T17:58:53Z",
      "price": "39.99",
      "base": null
    }
  ]
}
```

#### `GET /v1/games/{id}/info` — The rich card of a game

- `id` (path, string, required): Game id
- `lang` (query, string): Language of texts — one of en, ru

Example answer:

```json
{
  "id": "elden-ring",
  "name": "ELDEN RING",
  "stores": [
    "psn",
    "steam",
    "xbox"
  ],
  "facts": []
}
```

#### `GET /v1/entities` — Developers, publishers, franchises, genres and features

- `q` (query, string): Search text
- `kind` (query, string): Entity kind — one of publisher, developer, franchise, genre, feature
- `limit` (query, integer): Page size, capped by the plan
- `offset` (query, integer): Items to skip, capped by the plan

Example answer:

```json
{
  "items": [
    {
      "id": "publisher:bandai-namco",
      "kind": "publisher",
      "name": "Bandai Namco Entertainment",
      "games": 120
    }
  ],
  "next_offset": null
}
```

### products

#### `GET /v1/best` — Where each product costs least

Products matching a search, each with its cheapest region in USD

- `q` (query, string, required): Search text
- `provider` (query, string): Store: steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `currency` (query, string): Also convert prices to this currency (field "converted"); default USD only
- `limit` (query, integer): Page size, capped by the plan
- `offset` (query, integer): Items to skip, capped by the plan

Example answer:

```json
{
  "items": [
    {
      "product": {
        "provider": "xbox",
        "product_id": "9P3J32CTXLRZ",
        "name": "ELDEN RING"
      },
      "cheapest": {
        "provider": "xbox",
        "product_id": "app:1245620",
        "region": "TR",
        "country": "TR",
        "currency": "USD",
        "price": "39.99",
        "base": null,
        "discount": null,
        "ends_at": null,
        "free": false,
        "preorder": false,
        "subscription_only": false,
        "plans": [],
        "usd": "39.99",
        "seen_at": "2026-10-16T15:49:49.994Z",
        "url": "https://store.steampowered.com/app/1245620/ELDEN_RING/?cc=tr"
      }
    }
  ],
  "next_offset": null
}
```

#### `GET /v1/products` — Search and filter products of every store

- `q` (query, string): Search text
- `provider` (query, string): Store: steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `kind` (query, string): Product kind — one of game, edition, bundle, dlc, currency, other
- `limit` (query, integer): Page size, capped by the plan
- `offset` (query, integer): Items to skip, capped by the plan

Example answer:

```json
{
  "items": [
    {
      "provider": "steam",
      "product_id": "app:1245620",
      "name": "ELDEN RING",
      "kind": "game",
      "game_id": "elden-ring"
    }
  ],
  "next_offset": 20
}
```

#### `GET /v1/products/{provider}/{product_id}` — A store product with its prices

- `provider` (path, string, required): Store — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `product_id` (path, string, required): Store product id, e.g. app:1245620
- `region` (query, string): A region of the store (see /regions), e.g. TR
- `country` (query, string): ISO 3166 country; the store region that serves it is used
- `currency` (query, string): Also convert prices to this currency (field "converted"); default USD only
- `max_age` (query, integer): Seconds; older prices are read again first

Example answer:

```json
{
  "product": {
    "provider": "steam",
    "product_id": "app:1245620",
    "name": "ELDEN RING",
    "kind": "game",
    "game_id": "elden-ring"
  },
  "offers": [
    {
      "provider": "steam",
      "product_id": "app:1245620",
      "region": "TR",
      "country": "TR",
      "currency": "USD",
      "price": "39.99",
      "base": null,
      "discount": null,
      "ends_at": null,
      "free": false,
      "preorder": false,
      "subscription_only": false,
      "plans": [],
      "usd": "39.99",
      "seen_at": "2026-10-16T15:49:49.994Z",
      "url": "https://store.steampowered.com/app/1245620/ELDEN_RING/?cc=tr"
    }
  ]
}
```

#### `GET /v1/products/{provider}/{product_id}/history` — Price history of a product

- `provider` (path, string, required): Store — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `product_id` (path, string, required): Store product id
- `region` (query, string, required): Store region
- `days` (query, integer): Window in days; default: the whole history the plan allows

Example answer:

```json
{
  "region": "TR",
  "currency": "USD",
  "points": [
    {
      "at": "2026-10-10T17:58:53Z",
      "price": "39.99",
      "base": null
    }
  ]
}
```

#### `GET /v1/products/{provider}/{product_id}/info` — What one store says about a product

- `provider` (path, string, required): Store — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `product_id` (path, string, required): Store product id
- `lang` (query, string): Language of texts — one of en, ru

Example answer:

```json
{
  "product": {
    "provider": "steam",
    "product_id": "app:1245620"
  },
  "facts": {
    "short": "THE CRITICALLY ACCLAIMED FANTASY ACTION RPG"
  }
}
```

#### `GET /v1/products/{provider}/{product_id}/refresh` — Refresh the prices of a product region by region, as a stream

NDJSON lines: queued, running, one per region, then done. Costs one request per region it checks. max_age=0 forces every region and spends a forced refresh instead

- `provider` (path, string, required): Store — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `product_id` (path, string, required): Store product id
- `regions` (query, csv): Comma separated regions; default every region of the store
- `max_age` (query, integer): Seconds; older prices are read again first

Example answer:

```json
{
  "kind": "region",
  "region": "TR",
  "ok": true,
  "offer": {
    "provider": "steam",
    "product_id": "app:1245620",
    "region": "TR",
    "country": "TR",
    "currency": "USD",
    "price": "39.99",
    "base": null,
    "discount": null,
    "ends_at": null,
    "free": false,
    "preorder": false,
    "subscription_only": false,
    "plans": [],
    "usd": "39.99",
    "seen_at": "2026-10-16T15:49:49.994Z",
    "url": "https://store.steampowered.com/app/1245620/ELDEN_RING/?cc=tr"
  }
}
```

#### `GET /v1/products/{provider}/{product_id}/refresh/live` — Watch the refresh of a product that is already running, as a stream

- `provider` (path, string, required): Store — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `product_id` (path, string, required): Store product id

Example answer:

```json
{
  "kind": "queued",
  "position": 1
}
```

#### `POST /v1/prices` — Prices of many products at once

Up to max_batch items; each costs one request. Items come back in request order with their offers or their own error

Body: {"items": [{"provider", "product_id", "region"?, "country"?}], "max_age"?}

```json
{
  "items": [
    {
      "provider": "steam",
      "product_id": "app:1245620",
      "country": "TR"
    },
    {
      "provider": "psn",
      "product_id": "c:10006560",
      "region": "US"
    }
  ]
}
```

Example answer:

```json
{
  "items": [
    {
      "provider": "steam",
      "product_id": "app:1245620",
      "offers": [
        {
          "provider": "steam",
          "product_id": "app:1245620",
          "region": "TR",
          "country": "TR",
          "currency": "USD",
          "price": "39.99",
          "base": null,
          "discount": null,
          "ends_at": null,
          "free": false,
          "preorder": false,
          "subscription_only": false,
          "plans": [],
          "usd": "39.99",
          "seen_at": "2026-10-16T15:49:49.994Z",
          "url": "https://store.steampowered.com/app/1245620/ELDEN_RING/?cc=tr"
        }
      ]
    }
  ]
}
```

#### `GET /v1/lookup` — Find products by a store link or id

- `q` (query, string, required): A store link or product id of any store

Example answer:

```json
{
  "items": [
    {
      "provider": "steam",
      "product_id": "app:1245620",
      "name": "ELDEN RING",
      "game_id": "elden-ring"
    }
  ]
}
```

#### `GET /v1/items` — Every item with the product that stands for it

Catalog sync: walk with "after" set to the last item_id of the previous page

- `after` (query, string): item_id to continue after
- `limit` (query, integer): Page size

Example answer:

```json
{
  "items": [
    {
      "item_id": "elden-ring:game:base",
      "game_id": "elden-ring",
      "kind": "game",
      "name": "ELDEN RING",
      "products": [
        {
          "provider": "steam",
          "product_id": "app:1245620"
        }
      ]
    }
  ],
  "next_after": "elden-ring:game:base"
}
```

#### `GET /v1/items/{item_id}/info` — The card of one item: an edition, a DLC or a bundle

- `item_id` (path, string, required): Item id, e.g. elden-ring:game:base

Example answer:

```json
{
  "item_id": "elden-ring:game:base",
  "game_id": "elden-ring",
  "kind": "game",
  "name": "ELDEN RING",
  "products": []
}
```

#### `GET /v1/suggest` — Suggestions while typing a search

- `q` (query, string, required): Search text

Example answer:

```json
{
  "games": [
    {
      "id": "elden-ring",
      "name": "ELDEN RING"
    }
  ],
  "entities": []
}
```

### lists

#### `GET /v1/deals` — Discounts in a region or country

- `provider` (query, string): Store: steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `region` (query, string): A region of the store (see /regions), e.g. TR
- `country` (query, string): ISO 3166 country; the store region that serves it is used
- `currency` (query, string): Also convert prices to this currency (field "converted"); default USD only
- `kind` (query, string): Products to include — one of game, dlc, all
- `min_discount` (query, integer): Minimum discount percent
- `sort` (query, string): Order — one of discount, price, popular, ending
- `limit` (query, integer): Page size, capped by the plan
- `offset` (query, integer): Items to skip, capped by the plan

Example answer:

```json
{
  "items": [],
  "next_offset": null
}
```

#### `GET /v1/price-drops` — Price drops in a region or country

- `provider` (query, string): Store: steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `region` (query, string): A region of the store (see /regions), e.g. TR
- `country` (query, string): ISO 3166 country; the store region that serves it is used
- `currency` (query, string): Also convert prices to this currency (field "converted"); default USD only
- `kind` (query, string): Products to include — one of game, dlc, all
- `limit` (query, integer): Page size, capped by the plan
- `offset` (query, integer): Items to skip, capped by the plan

Example answer:

```json
{
  "items": [],
  "next_offset": null
}
```

#### `GET /v1/new` — New in a region or country

- `provider` (query, string): Store: steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `region` (query, string): A region of the store (see /regions), e.g. TR
- `country` (query, string): ISO 3166 country; the store region that serves it is used
- `currency` (query, string): Also convert prices to this currency (field "converted"); default USD only
- `kind` (query, string): Products to include — one of game, dlc, all
- `limit` (query, integer): Page size, capped by the plan
- `offset` (query, integer): Items to skip, capped by the plan

Example answer:

```json
{
  "items": [],
  "next_offset": null
}
```

#### `GET /v1/releases` — Releases by date in a region or country

- `provider` (query, string): Store: steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet
- `region` (query, string): A region of the store (see /regions), e.g. TR
- `country` (query, string): ISO 3166 country; the store region that serves it is used
- `currency` (query, string): Also convert prices to this currency (field "converted"); default USD only
- `kind` (query, string): Products to include — one of game, dlc, all
- `sort` (query, string): Order — one of date, popular
- `limit` (query, integer): Page size, capped by the plan
- `offset` (query, integer): Items to skip, capped by the plan

Example answer:

```json
{
  "items": [],
  "next_offset": null
}
```

### reference

#### `GET /v1/providers` — Stores

Example answer:

```json
[
  {
    "id": "bnet",
    "name": "Battle.net",
    "regions": 4,
    "products": 376,
    "games": 242
  }
]
```

#### `GET /v1/regions` — Regions of every store

- `provider` (query, string): Store: steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet — one of steam, psn, nintendo, epic, gog, ubisoft, xbox, ea, bnet

Example answer:

```json
[
  {
    "provider": "steam",
    "region": "TR",
    "country": "TR",
    "currency": "USD",
    "name": "Turkey & MENA, USD",
    "serves": [
      "BH",
      "DZ"
    ],
    "last_full": "2026-10-16T15:49:49Z",
    "offers": 250000
  }
]
```

#### `GET /v1/countries` — Countries and the store regions that serve them

Example answer:

```json
[
  {
    "country": "TR",
    "name": "Türkiye",
    "currency": "TRY",
    "regions": {
      "steam": "TR",
      "psn": "TR"
    }
  }
]
```

#### `GET /v1/rates` — Exchange rates to US dollars

Example answer:

```json
{
  "base": "USD",
  "date": "2026-10-16",
  "rates": {
    "EUR": 0.89165,
    "TRY": 41.56
  }
}
```

#### `GET /v1/status` — Data freshness

Example answer:

```json
{
  "providers": [
    {
      "provider": "steam",
      "name": "Steam",
      "last_full": "2026-10-16T15:49:49Z",
      "state": "ok"
    }
  ],
  "ready": true,
  "snapshot_at": "2026-10-16T17:55:01Z"
}
```

#### `GET /v1/genres` — Shared genres

Example answer:

```json
[
  {
    "id": "genre:action",
    "name": "Action",
    "games": 5000
  }
]
```

#### `GET /v1/features` — Shared features

Example answer:

```json
[
  {
    "id": "feature:multiplayer",
    "name": "Multiplayer",
    "games": 3000
  }
]
```

### watchlist

#### `GET /v1/watch` — Your watched products

- `limit` (query, integer): Page size, capped by the plan
- `offset` (query, integer): Items to skip, capped by the plan

Example answer:

```json
{
  "items": [
    {
      "id": 1,
      "provider": "steam",
      "product_id": "app:1245620",
      "condition": {
        "type": "below",
        "amount": 3000,
        "currency": "USD"
      }
    }
  ],
  "next_offset": null
}
```

#### `POST /v1/watch` — Watch a product

Body: {"provider", "product_id", "regions"?, "condition": {"type": "drop"|"below"|"discount"|"any", "amount"?, "currency"?, "percent"?}}

```json
{
  "provider": "steam",
  "product_id": "app:1245620",
  "regions": [
    "TR",
    "AR"
  ],
  "condition": {
    "type": "below",
    "amount": "25.00",
    "currency": "USD"
  }
}
```

Example answer:

```json
{
  "id": 1,
  "provider": "steam",
  "product_id": "app:1245620"
}
```

#### `GET /v1/watch/events` — Fired conditions, oldest first

- `after` (query, integer): Event id to continue after
- `limit` (query, integer): Page size

Example answer:

```json
{
  "items": [
    {
      "id": 10,
      "type": "below",
      "created_at": "2026-10-16T10:00:00Z",
      "payload": {}
    }
  ],
  "next_after": 10
}
```

#### `GET /v1/watch/{id}` — One watched product

- `id` (path, integer, required): Watch id

Example answer:

```json
{
  "id": 1
}
```

#### `PATCH /v1/watch/{id}` — Change regions, the condition or pause

- `id` (path, integer, required): Watch id

Body: {"regions"?, "condition"?, "paused"?}

```json
{
  "paused": true
}
```

Example answer:

```json
{
  "id": 1,
  "paused": true
}
```

#### `DELETE /v1/watch/{id}` — Stop watching

- `id` (path, integer, required): Watch id

#### `GET /v1/webhooks` — Your webhooks

Example answer:

```json
[
  {
    "id": 1,
    "url": "https://example.com/hook",
    "disabled_at": null
  }
]
```

#### `POST /v1/webhooks` — Receive events by HTTPS

Deliveries are signed: X-RaceCap-Signature: t=<unix>,v1=hex(HMAC-SHA256(secret, "<t>.<body>"))

Body: {"url"}

```json
{
  "url": "https://example.com/hook"
}
```

Example answer:

```json
{
  "id": 1,
  "url": "https://example.com/hook",
  "secret": "whsec_..."
}
```

#### `DELETE /v1/webhooks/{id}` — Delete a webhook

- `id` (path, integer, required): Webhook id

#### `POST /v1/webhooks/{id}/redeliver` — Send events again or resume a disabled webhook

- `id` (path, integer, required): Webhook id

Body: {"event_ids"?: [ids]} — without ids every failed delivery is sent again

```json
{
  "event_ids": [
    10,
    11
  ]
}
```

Example answer:

```json
{
  "queued": 2
}
```

#### `POST /v1/webhooks/{id}/secret` — Replace the signing secret

- `id` (path, integer, required): Webhook id

Example answer:

```json
{
  "id": 1,
  "secret": "whsec_..."
}
```

#### `POST /v1/webhooks/{id}/test` — Send a test call now

- `id` (path, integer, required): Webhook id

Example answer:

```json
{
  "event_id": 12
}
```

### account

#### `GET /v1/usage` — Your plan and what is left of it

Example answer:

```json
{
  "plan": {
    "id": "api_starter",
    "name": "API Starter",
    "ends_at": "2026-11-16T00:00:00Z"
  },
  "requests": {
    "per_minute": 60,
    "per_day": 3000,
    "used_today": 12
  }
}
```
