# Game catalogues

## 8.1 Your catalogue → GA Promo

If your games are not on GA, GA Promo learns them from you: promotions are set up on your games and an
event is checked against them. It holds only games that do not run
through GA: GA's games reach GA Promo with GA's own rounds, and a copy of GA's catalogue (§8.2) is
not sent here.

### Sending it

```
POST https://<api host>/promo/v1/catalog/<source_id>
Content-Type: application/json
X-Promo-Timestamp: <unix seconds>
X-Promo-Signature: <signature>
```

The URL takes the `source_id` of your **webhook** transport, and the request is signed exactly like
its events (§6.1): the same secret, the same ±300 s window. A catalogue cannot be sent over Kafka or
RabbitMQ.

```json
{
  "mode": "snapshot",
  "games": [
    {
      "id": "book-of-x",
      "name": "Book of X",
      "provider": "Acme Studio",
      "category": "Slots",
      "image_url": "https://cdn.acme.example/book-of-x.png"
    },
    {
      "id": "crash-9",
      "name": "Crash 9",
      "category": "Crash",
      "is_active": false
    }
  ]
}
```

| `mode` | List | Effect |
| :--- | :--- | :--- |
| `snapshot` | `games` | your catalogue becomes exactly `games`, at once; a game not listed is removed; `[]` clears it |
| `upsert` | `games` | the listed games are added or updated; the others stay |
| `delete` | `game_ids` | the listed games are removed; unknown ids are ignored |

| Game field | Required | Rule |
| :--- | :---: | :--- |
| `id` | yes | up to 255 bytes; **the same string your events carry as `game.id`** |
| `name` | yes | not blank, up to 255 bytes |
| `provider` | no | up to 255 bytes |
| `category` | no | up to 255 bytes; the category of the game |
| `image_url` | no | an absolute `https` URL, up to 2048 bytes |
| `is_active` | no | `true` by default; an inactive game is kept but not shown and not counted |

One request carries up to 10 000 games or ids and 8 MiB. Fields you add beyond these are ignored;
an `id` twice in one request is an error. JSON Schema:
[`schema/promo-catalogue-v1.schema.json`](/docs/gamification/pack/schema/promo-catalogue-v1.schema.json); a snapshot to try is
`examples/catalogue/snapshot.json`, sent with the webhook examples of §6.1 by pointing
`PROMO_INGEST_URL` at the catalogue URL.

| Status | Body | Meaning |
| :--- | :--- | :--- |
| `200` | `{"source":"<source_id>","mode":"snapshot","upserted":2,"deleted":0,"total":2}` | applied; the same request again changes nothing and answers `upserted: 0, deleted: 0` |
| `400` | `{"error":"invalid","details":[{"index":0,"field":"name","reason":"required"}]}` | a game breaks a rule; nothing is written. `reason`: `required`, `too_long`, `not_a_string`, `not_a_boolean`, `not_https_url`, `duplicate`, `not_an_object` |
| `400` | `{"error":"malformed"}` | not a JSON object, an unknown `mode`, or the list missing |
| `401` | `{"error":"unauthorized"}` | missing or stale timestamp, missing signature, or a signature that matches no secret (§6.1) |
| `404` | `{"error":"unknown_source"}` | no active webhook connection with this `source_id` |
| `413` | `{"error":"body_too_large"}` / `{"source":"<source_id>","error":"batch_too_large"}` | over 8 MiB, or more than 10 000 games or ids |
| `503` + `Retry-After: 1` | `{"error":"unavailable"}` | not applied; send the same request again |

### What it changes

- **Your events are held to it.** Once your catalogue holds a game, a `bet` or `settled` whose
  `game.id` is not an active game of it is set aside as `skipped/game_unknown`. `win` and `refund`
  are never held back, so a refund still reverses its round. Send the catalogue **before** a game
  appears in your feed: an event set aside stays set aside when the game arrives later.
- **Promotions are set up on your games.** We pick them from your catalogue for a promotion's game
  lists.
- **The game widgets do not show it.** `game-sections` and `game-section` show GA's catalogue for
  your operator, never the one you send us.

The catalogue is one per client connection: every brand of a group shares it.

## 8.2 GA's catalogue → you

If your games run through GA's aggregation, you can keep a copy of GA's catalogue on your side — a
full snapshot once, then the changes. This is optional: the widgets read the catalogue themselves,
and GA Promo needs no copy of it (§8.1).

```
GET https://<api host>/v1/catalog/snapshot
GET https://<api host>/v1/catalog/diff?cursor=<catalog_version or next_cursor>&limit=200
X-Public-API-Key: <your public API key>
X-Public-Timestamp: <unix seconds>
X-Public-Signature: <signature>
```

```
signature = lowercase_hex( HMAC-SHA256( secret, METHOD + path_with_query + body + X-Public-Timestamp ) )
```

- `path_with_query` is the full request path with its query string, e.g.
  `/v1/catalog/diff?cursor=…&limit=200`; `body` is empty for these `GET`s.
- The timestamp may be up to 5 minutes old and up to 30 seconds ahead of our clock.
- The key needs the `read:catalog` scope; we issue it with your credentials (§10.2).

**Snapshot** — one consistent read: `catalog_version` (the cursor to store once you have saved the
snapshot), `generated_at`, `providers[]`, `categories[]`, `games[]` and `category_games[]`
(`category_id`, `game_id`, `display_order`).

A game's `image_url` is an absolute URL of its cover for your operator,
`https://<api host>/public/v1/assets/games/<game id>/image?operator_id=<your GA operator id>`. Every
game has one: the address answers `200` with the cover, or with a placeholder marked by the response
header `X-Image-Fallback: true` when the game has no cover yet. The same holds for `payload.image_url`
of a game in the diff.

**Diff** — `changes[]` in order, each `entity_type`, `entity_id`, `operation`, `updated_at`,
`payload`; `upsert` carries the whole current object, `disable` and `delete` are tombstones. Continue
with `next_cursor` while `has_more` is `true`. `limit` is 1–1000, 200 by default. A cursor too old to
serve answers `410` `cursor_expired`: take a new snapshot. A cursor works only with the key that
received it.
