---
title: "Offres"
description: "Triplet /api/offers (list paginé) / /api/offers/search (full-text + géo) / /api/offers/{id} (détail) adossé à l'index Meilisearch MEILISEARCH_OFFERS_INDEX…"
---

Triplet `/api/offers` (list paginé) / `/api/offers/search` (full-text + géo) / `/api/offers/{id}` (détail) adossé à l'index Meilisearch `MEILISEARCH_OFFERS_INDEX` (**`offers_v2`** en prod comme en dev — le suffixe de version reste dans l'env pour permettre des rolls d'index blue/green sans toucher au code, qui parle toujours d'« offers » au sens logique). Catalogue **~377 000 documents** ⇒ pagination obligatoire sur les listings.

Comme pour les établissements, Hydrogen **n'a pas de domaine `Offer` côté MySQL** : l'index Meilisearch est la **source de vérité**, alimenté par un processus externe. Pas de re-hydratation SQL, pas de cache applicatif — chaque hit Meili devient ressource JSON:API directement.

**Identité côté API** : le `id` JSON:API d'une ressource `offers` est le **hash hex 32 caractères lowercase** stocké dans le champ `id` du document Meili. Même contrainte de canonicité que `/api/establishments/{id}` : la regex de path `^[a-f0-9]{32}$` est **stricte** sur la casse (pas de normalisation silencieuse).

**Shape attributes commune (formatter partagé)** : les 3 endpoints utilisent `OfferHitFormatter`, qui :
1. recopie tous les champs du document dans `attributes`, sauf les clés Meili-internes (`_geo`, `_geoDistance`, `_formatted`, `_matchesPosition`, `_rankingScore`, `_rankingScoreDetails`) et le `id` brut (passe en JSON:API id) ;
2. aplatit `_geo: { lat, lng }` en `attributes.latitude` / `attributes.longitude` ;
3. expose `_geoDistance` (mètres) en `attributes.distanceMeters` quand le tri géo est actif (search en mode géo uniquement).

Champs métier exposés tels quels : `name`, `description`, `url`, `call_price{price, currency}`, `position`, `importance`, `open_location_code`, `brand{brand_id, brand_name, brand_code}`, `establishment{id, name, slug}`.

> `created_at` et **`udated_at`** (sic — la faute de frappe vit dans le pipeline source) sont stockés dans l'index (gardés pour le tri whitelist `sort=created_at` / `sort=udated_at`) mais **sont privés** : ils ne sortent pas dans `attributes` — bookkeeping ETL, hors contrat public.

> **Pas de description Markdown ici** — contrairement aux pays/régions/sous-régions, les offres n'ont pas de blurb éditorial localisé. Le champ `description` provient directement du document Meili tel quel.

## `GET /api/offers`

Listing paginé du catalogue, sans full-text ni géo (pour ça, voir `/api/offers/search`). Pratique pour le tooling BO, les balayages de fraîcheur, ou pour lister toutes les offres d'un établissement donné via `?establishment=`.

- **Auth** : aucune.
- **Action** : [ListOffersAction](../src/Http/Action/Api/Offer/ListOffersAction.php).
- **Query** :
  - `limit` (int, défaut `20`, plafond `100`).
  - `offset` (int ≥ 0, défaut `0`).
  - `sort` (string, optionnel) — whitelist : `created_at` / `-created_at` (= défaut, desc), `created_at_asc`, `importance` / `-importance`, `importance_asc`, `position` / `-position`, `price` / `price_asc` / `-price` / `price_desc` (alias sur `call_price.price`), `udated_at` / `-udated_at`, `udated_at_asc`. Valeur hors whitelist → `422 Invalid sort` (évite d'exposer un attribut absent de `sortableAttributes` ce qui 503erait Meili).
  - `establishment` (optionnel) — `[a-f0-9]{32}` strict ; filtre `establishment.id = "<hex>"` côté index. Casse non lowercase ⇒ `422 Invalid establishment id`.
- **Tri par défaut** : `created_at:desc`.

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "offers",
      "id":   "5c2f1c5b9a4d4f3e8b7a6c1d2e3f4a5b",
      "attributes": {
        "name":               "Menu découverte",
        "description":        "Entrée + plat + dessert + café",
        "url":                "https://exemple.tld/menu",
        "call_price":         { "price": 24.50, "currency": "EUR" },
        "position":           1,
        "importance":         7,
        "open_location_code": "8FW4V75V+8Q",
        "brand":              { "brand_id": "42", "brand_name": "Acme", "brand_code": "ACM" },
        "establishment":      { "id": "00003c7104994cc688663a51b89c9d40", "name": "Café des Arts", "slug": "cafe-des-arts" },
        "latitude":           48.8566,
        "longitude":          2.3522
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/offers?limit=20",
    "first": "https://api.example/api/offers?limit=20",
    "prev":  null,
    "next":  "https://api.example/api/offers?limit=20&offset=20",
    "last":  "https://api.example/api/offers?limit=20&offset=377480"
  },
  "meta": {
    "totalHits": 377485,
    "limit":     20,
    "offset":    0,
    "count":     20,
    "sort":      "created_at:desc"
  }
}
```

- **Réponses d'erreur** :
  - `422 Invalid sort` — valeur de `sort` hors whitelist.
  - `422 Invalid establishment id` — `?establishment` mal formé (pas `[a-f0-9]{32}`).
  - `503 Search backend unavailable`.

## `GET /api/offers/search`

Recherche d'offres par **texte** (`?q=…`), par **proximité GPS** (`?lat=…&lng=…&distance=…`), ou les deux combinés. Sémantique et erreurs strictement identiques à [`GET /api/establishments/search`](#get-apiestablishmentssearch) — seuls le `type` JSON:API et l'index Meili changent.

- **Auth** : aucune (endpoint public).
- **Action** : [SearchOffersAction](../src/Http/Action/Api/Offer/SearchOffersAction.php).
- **Query** : (`q`, `lat`, `lng`, `distance`, `limit`, `offset`).
  - `limit` plafonné à `50` (vs 100 sur `/api/offers`).
  - `distance` borné par `OFFER_NEARBY_MAX_DISTANCE_METERS` (défaut **50 km**).
  - `lat`/`lng`/`distance` mutuellement requis ⇒ partial input = `422`.
- **Pipeline** : pass-through des hits Meili (formatter partagé), avec `attributes.distanceMeters` rempli quand le tri géo est actif.
- **Réponse `200 OK`** : ressources `offers` avec navigation `links.{self,first,prev,next,last}` et `meta.{totalHits,limit,offset,query[,center,distance]}`. Même shape `attributes` que `/api/offers` (formatter partagé).
- **Réponses d'erreur** : identiques à `/api/establishments/search` (`422` pour params invalides ou incomplets, `503` si Meilisearch est indisponible).

> **Pré-requis index Meilisearch** (one-shot, sans ça le mode géo 503e) :
> - `_geo` dans `filterableAttributes` ET `sortableAttributes`
> - les champs texte recherchables dans `searchableAttributes`

> **Index versionné** : `MEILISEARCH_OFFERS_INDEX=offers_v2` en prod comme en dev (contrairement à `media_dev`/`users_dev`). C'est volontaire : les rolls de schéma d'offres se font en créant un `offers_v3` à côté, en le remplissant via le process externe, puis en flippant l'env — aucun déploiement de code requis pour bumper la version.

## `GET /api/offers/{id}`

Détail d'une offre par son id hex.

- **Auth** : aucune.
- **Action** : [GetOfferAction](../src/Http/Action/Api/Offer/GetOfferAction.php).
- **Path** :
  - `{id}` — regex **stricte** `[a-f0-9]{32}`. Pas de normalisation de casse : upper ⇒ `404`. Le pattern de routage Slim porte déjà la regex, donc tout id mal formé n'atteint même pas l'action.
- **Résolution** : filtre Meili exact `id = "<hex>"` sur l'index (l'attribut `id` est filterable) — pas de recherche full-text, le scorer pourrait surfacer un mauvais doc sur des préfixes d'id.

- **Réponse `200 OK`** : ressource unique `offers` avec la même shape `attributes` que les listings (formatter partagé).

- **Réponses d'erreur** :
  - `404 Offer not found` — id mal formé (n'atteint pas l'action, le router 404e via la regex) OU id valide mais absent de l'index.
  - `503 Search backend unavailable`.

---

## `POST /api/offers/{id}/clicks`

Génère un **subid par clic** (`clickRef`) **sans rediriger**. À utiliser quand tu rends **toi-même** le lien que l'affilié t'a fourni (pointant directement vers le réseau) au lieu de passer par notre redirect `/go/...` : tu récupères le `clickRef` ici puis tu le colles dans le lien affilié comme subid.

- **Auth** : aucune (optionnelle : un `Bearer` rattache l'utilisateur au clic ; anonyme OK).
- **Action** : [CreateOfferClickAction](../src/Http/Action/Api/Offer/CreateOfferClickAction.php).
- **Path** :
  - `{id}` — offre, regex stricte `[a-f0-9]{32}`.
- **Body (JSON)** : `{ "mediaId": "<32-hex>" }` — `mediaId` **obligatoire** (le contexte média est toujours requis).
- **Effet** : mint un `clickRef` opaque (UUIDv7 hex), persiste une ligne `tracking_click` (média obligatoire + utilisateur nullable + visiteur), et émet le cookie visiteur si activé. **Seul** le `clickRef` opaque est renvoyé — jamais `userId`/PII.

- **Réponse `201 Created`** — ressource JSON:API `clicks` ; **`data.id` EST le `clickRef`** :

```json
{
  "data": {
    "type": "clicks",
    "id":   "0193a1b2c3d4e5f60718293a4b5c6d7e",
    "attributes": {
      "clickRef":   "0193a1b2c3d4e5f60718293a4b5c6d7e",
      "subidParam": "subid",
      "url":        "https://merchant.example.com/deal/123?subid=0193a1b2c3d4e5f60718293a4b5c6d7e"
    }
  }
}
```

| Attribut | Sens |
|---|---|
| `clickRef` | le subid opaque à insérer dans le lien affilié (= `data.id`) |
| `subidParam` | nom de paramètre subid à utiliser (config `TRACKING_SUBID_PARAM`) ; utile si tu construis le lien côté client |
| `url` | URL marchande de l'offre **avec le subid déjà injecté** (placeholder `{subid}` substitué, sinon `?subid=` ajouté), ou `null` si l'offre n'a pas d'URL exploitable |

- **Réponses d'erreur** :
  - `404 Offer not found` — id mal formé OU offre absente de l'index.
  - `422 Invalid media id` / `Invalid body` — `mediaId` manquant/mal formé ou body non-JSON.
  - `503 Search backend unavailable`.

**Exemple**

```bash
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"mediaId":"a1b2c3d4e5f600112233445566778899"}' \
  "http://hydrogen.dev.com/api/offers/0f1e2d3c4b5a69788796a5b4c3d2e1f0/clicks"
```

> Le `clickRef` remonte ensuite dans le postback de conversion (`POST /admin/tracking/conversions`, champ `clickRef`) et est résoluble via `GET /admin/tracking/clicks/{ref}` (voir `docs/admin.md`).

---

## `GET /go/offer/{id}`

Lien de **suivi de clic + redirection** vers le marchand. Brique du modèle d'affiliation/commission : au lieu de pointer l'offre directement vers l'URL du marchand, le front-end pointe vers cet endpoint. On compte un clic facturable (bufferisé, anti-bot) puis on renvoie un `302` vers l'URL de destination de l'offre.

- **Auth** : aucune (auth optionnelle : si un `Bearer` est présent, le viewer est attaché à la requête — l'attribution reste au niveau de la cible en v1).
- **Action** : [RedirectOfferAction](../src/Http/Action/Go/RedirectOfferAction.php).
- **Hors groupe `/api`** : pas de middleware JSON ; succès = `302` brut, erreurs = JSON:API.
- **Path** :
  - `{id}` — regex **stricte** `[a-f0-9]{32}` (même shape canonique que `GET /api/offers/{id}`).
- **Résolution** : filtre Meili exact `id = "<hex>"`, on ne récupère que `id` + `url`.
- **Comptage** : le clic part dans un tampon (`tracking_event`) vidé en fin de requête (`register_shutdown_function`), drainé par le worker `bin/tracking-flush.php` vers `tracking_stats`/`tracking_daily`. Les bots (crawlers, dépliage de liens, User-Agent vide) sont ignorés quand `TRACKING_IGNORE_BOTS=true`. Aucun dédoublonnage : chaque clic réel compte (le réseau d'affiliation fait sa propre attribution).
- **Conversions** : remontent côté Admin via `POST /admin/tracking/conversions` (voir `docs/admin.md`).

- **Réponse `302 Found`** : header `Location` = URL marchand de l'offre. Aucun corps.

- **Réponses d'erreur** :
  - `404 Offer not found` — id mal formé, offre absente, ou offre sans champ `url` exploitable.
  - `503 Search backend unavailable`.

**Exemple**

```bash
curl -i "http://hydrogen.dev.com/go/offer/0f1e2d3c4b5a69788796a5b4c3d2e1f0"
# HTTP/1.1 302 Found
# Location: https://merchant.example.com/deal/123?aff=hexatrip
```

---

## `GET /go/offer/{id}/media/{mediaId}`

Variante du lien de suivi ci-dessus avec **attribution par clic** (le *subid* d'affiliation). À utiliser quand le lien partenaire est rendu **au contexte d'un média** : on veut savoir non seulement *que* l'offre a été cliquée, mais *qui* a cliqué et *depuis quel média*.

- **Auth** : aucune (optionnelle ; un `Bearer` présent rattache l'utilisateur au clic). Les clics anonymes sont gérés nativement (`userId` nul).
- **Action** : [RedirectOfferMediaAction](../src/Http/Action/Go/RedirectOfferMediaAction.php).
- **Hors groupe `/api`** : succès = `302` brut, erreurs = JSON:API.
- **Path** :
  - `{id}` — offre, regex stricte `[a-f0-9]{32}`.
  - `{mediaId}` — média, regex stricte `[a-f0-9]{32}`. **Toujours obligatoire** (segment de chemin).
- **Subid** : on mint un `clickRef` opaque (UUIDv7 en 32 hex), on persiste **une** ligne `tracking_click` reliant `clickRef → média (obligatoire) + utilisateur (nullable) + visiteur`, puis on **ajoute le `clickRef` à l'URL marchande** comme paramètre `subid` (nom configurable via `TRACKING_SUBID_PARAM`). Le réseau renvoie ce subid dans son postback de conversion (`POST /admin/tracking/conversions`, champ `clickRef`) pour une attribution précise clic → média → utilisateur.
- **Confidentialité** : **seul** le `clickRef` opaque sort du système — jamais `userId` ni PII. L'identité reste résoluble côté Admin via `GET /admin/tracking/clicks/{ref}`.
- **Visiteur anonyme** : si `TRACKING_VISITOR_COOKIE=true`, un cookie longue durée (`hyv`, UUIDv7) corrèle les clics d'un même invité non connecté. **Désactivé par défaut** (consentement RGPD requis). `HttpOnly`, `SameSite=Lax` (survit au 302).
- **Comptage cible** : le clic facturable par cible est compté en plus, exactement comme `/go/offer/{id}` (tampon bufferisé, anti-bot).

- **Réponse `302 Found`** : header `Location` = URL marchand **+ subid**. Émet aussi un `Set-Cookie` visiteur si un nouvel id est minté.

- **Réponses d'erreur** :
  - `404 Offer not found` / `404 Media not found` — id mal formé, offre absente/sans `url`.
  - `503 Search backend unavailable`.
- **Robustesse** : un échec de persistance du clic ne casse pas la redirection (fallback sur l'URL sans subid).

**Exemple**

```bash
curl -i "http://hydrogen.dev.com/go/offer/0f1e2d3c4b5a69788796a5b4c3d2e1f0/media/a1b2c3d4e5f600112233445566778899"
# HTTP/1.1 302 Found
# Location: https://merchant.example.com/deal/123?subid=0193a1b2c3d4e5f60718293a4b5c6d7e
```

---
