---
title: "Coupons"
description: "Crée un coupon et ses récompenses sans SQL manuel (remplace le seeding à la main côté BO). Le coupon (coupon) et ses lignes coupon_reward sont écrits dans…"
---

## `POST /admin/coupons`

Crée un coupon et ses récompenses **sans SQL manuel** (remplace le seeding à la main côté BO). Le coupon (`coupon`) et ses lignes `coupon_reward` sont écrits dans **une seule transaction** ⇒ jamais de coupon à moitié configuré.

Le code (`id`) est **sensible à la casse** (la rédemption l'est aussi) et doit matcher `^[A-Za-z0-9._-]{1,32}$`. Un coupon **sans récompense** est autorisé (simple compteur d'inscriptions) mais n'accorde rien à la rédemption.

**Body (JSON)**

| Champ | Type | Requis | Notes |
|---|---|---|---|
| `id` | string | **oui** | code du coupon, 1..32 chars `[A-Za-z0-9._-]`, casse préservée |
| `userLimit` | int ≥ 0 | non (def `0`) | `0` = **illimité** (la colonne est NOT NULL, pas de NULL possible) |
| `start` | ISO-8601 \| null | non | début de validité |
| `end` | ISO-8601 \| null | non | fin de validité, doit être ≥ `start` |
| `rewards` | array | non | liste de `{ rewardId, value, badgeId? }` |
| `rewards[].rewardId` | int | oui (si présent) | doit référencer une ligne du catalogue `reward` (ex. `1`=experience, `2`=point) |
| `rewards[].value` | int > 0 | oui (si présent) | montant accordé |
| `rewards[].badgeId` | string(1..6) \| null | non | badge optionnel attaché à la récompense |

```json
{
  "id": "WELCOME2026",
  "userLimit": 100,
  "start": "2026-01-01T00:00:00+00:00",
  "end": "2026-12-31T23:59:59+00:00",
  "rewards": [
    { "rewardId": 1, "value": 500, "badgeId": null }
  ]
}
```

**Réponse (201)** — voir le shape commun dans `GET /admin/coupons` (un seul objet coupon, `redeemedCount` à `0`).

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Field 'id' must match ^[A-Za-z0-9._-]{1,32}$." }` | code absent/mal formé |
| `400` | `{ "error": "Field 'userLimit' must be an integer >= 0." }` | limite invalide |
| `400` | `{ "error": "Field 'end' must be greater than or equal to 'start'." }` | fenêtre incohérente |
| `400` | `{ "error": "rewards[0].rewardId must reference an existing reward." }` | reward inconnu |
| `409` | `{ "error": "Coupon 'WELCOME2026' already exists." }` | code déjà pris |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"WELCOME2026","userLimit":100,"rewards":[{"rewardId":1,"value":500}]}' \
  http://hydrogen.dev.com/admin/coupons
```

---


## `GET /admin/coupons`

Liste paginée (keyset) des coupons avec leurs **stats d'usage**, en remplacement de la requête SQL que l'opérateur reconstruisait à la main. Pour chaque coupon : configuration, compteur de slots dénormalisé (`userCount`), décompte réel des rédemptions tiré du ledger `coupon_user` (`redeemedCount`) et liste des récompenses. Les récompenses de toute la page sont chargées en **un appel batch**.

> `userCount` (compteur de slots, source de la limite) et `redeemedCount` (lignes réelles de `coupon_user`) peuvent légitimement diverger : les deux sont exposés.

**Query params**

| Param | Valeurs | Défaut | Notes |
|---|---|---|---|
| `limit` | `1..100` | `50` | borné en dur |
| `cursorAt` | ISO-8601 | _aucun_ | `created_at` du dernier item de la page précédente. À fournir avec `cursorId` (les deux ou aucun). |
| `cursorId` | code coupon | _aucun_ | id du dernier item — discriminant pour les `created_at` identiques. |

Tri implicite : `created_at DESC, id DESC`.

**Réponse (200)**

```jsonc
{
  "items": [
    {
      "id":            "WELCOME2026",
      "userLimit":     100,
      "isUnlimited":   false,         // true quand userLimit = 0
      "userCount":     12,            // slots consommés (compteur dénormalisé)
      "redeemedCount": 12,            // lignes réelles du ledger coupon_user
      "remaining":     88,            // null si illimité, sinon max(0, userLimit - userCount)
      "start":         "2026-01-01T00:00:00+00:00",
      "end":           "2026-12-31T23:59:59+00:00",
      "createdAt":     "2026-01-01T09:00:00+00:00",
      "rewards": [
        { "type": "experience", "value": 500, "badgeId": null }
      ]
    }
  ],
  "nextCursor": { "at": "2026-01-01T09:00:00+00:00", "id": "WELCOME2026" }
  // `null` quand la page courante contient < `limit` items (= dernière page)
}
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | une moitié seulement du curseur |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | parsing Carbon KO |
| `403` | `{ "error": "..." }` | auth KO |

---


## `GET /admin/coupons/{id}/redemptions`

Ledger des rédemptions d'**un** coupon (`coupon_user`) : qui l'a consommé et quand, en complément des compteurs agrégés de `GET /admin/coupons`. Sert l'enquête (fraude, double usage).

**Path params**
- `id` : code du coupon (`[A-Za-z0-9._-]{1,32}`, casse préservée).

**Query params**

| Param | Valeurs | Défaut | Notes |
|---|---|---|---|
| `limit` | `1..100` | `50` | borné en dur |
| `cursorAt` | ISO-8601 | _aucun_ | `consumed_at` du dernier item précédent. À fournir avec `cursorUserId` (les deux ou aucun). |
| `cursorUserId` | hex (32 chars) | _aucun_ | id du dernier item — discriminant pour les `consumed_at` identiques. |

Tri implicite : `consumed_at DESC, user_id DESC`.

**Réponse (200)**

```jsonc
{
  "items": [
    { "userId": "d26d1600cde54bd095e09f8b68ace05f", "consumedAt": "2026-01-02T10:00:00+00:00" }
  ],
  "nextCursor": { "at": "2026-01-02T10:00:00+00:00", "userId": "d26d…" }
}
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Coupon not found." }` | code inconnu |
| `400` | `{ "error": "Both cursorAt and cursorUserId must be supplied together." }` | demi-curseur |
| `400` | `{ "error": "cursorUserId is not a valid hex UUID." }` | hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/coupons/WELCOME2026/redemptions
```

---


## `GET /admin/coupons/{id}`

Détail complet d'**un** coupon : même shape que la réponse de création/mise à jour (config, les **deux** compteurs `userCount`/`redeemedCount`, liste des récompenses). Brique de lecture avant édition.

**Path params**
- `id` : code du coupon (`[A-Za-z0-9._-]{1,32}`, casse préservée).

**Réponse (200)** — un seul objet coupon (voir le shape des items dans `GET /admin/coupons`).

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Coupon 'WELCOME2026' not found." }` | code inconnu |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/coupons/WELCOME2026
```

---


## `PUT /admin/coupons/{id}`

Met à jour la **configuration** et **l'ensemble des récompenses** d'un coupon existant, dans **une seule transaction**. Le code (`id`) vient de l'URL et **n'est jamais modifiable** (le renommer orphelinerait le ledger `coupon_user`). Le compteur `user_count` et le `created_at` restent **intacts**.

> Le remplacement des récompenses est **total** : les lignes `coupon_reward` existantes sont supprimées puis réinsérées à partir du body. Omettre `rewards` (ou envoyer `[]`) retire toutes les récompenses.

> Baisser `userLimit` sous `userCount` est **autorisé** : cela gèle simplement les rédemptions à venir (le garde de slot refuse ensuite systématiquement).

**Path params**
- `id` : code du coupon (`[A-Za-z0-9._-]{1,32}`, casse préservée).

**Body (JSON)** — mêmes champs que `POST /admin/coupons`, **sans** `id` :

| Champ | Type | Requis | Notes |
|---|---|---|---|
| `userLimit` | int ≥ 0 | non (def `0`) | `0` = **illimité** |
| `start` | ISO-8601 \| null | non | début de validité |
| `end` | ISO-8601 \| null | non | fin de validité, doit être ≥ `start` |
| `rewards` | array | non | remplacement **total** ; `{ rewardId, value, badgeId? }` |

```json
{
  "userLimit": 200,
  "start": "2026-01-01T00:00:00+00:00",
  "end": "2026-12-31T23:59:59+00:00",
  "rewards": [
    { "rewardId": 2, "value": 100, "badgeId": null }
  ]
}
```

**Réponse (200)** — le coupon rechargé (même shape que `GET /admin/coupons/{id}`).

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Coupon 'WELCOME2026' not found." }` | code inconnu |
| `400` | `{ "error": "Field 'userLimit' must be an integer >= 0." }` | limite invalide |
| `400` | `{ "error": "Field 'end' must be greater than or equal to 'start'." }` | fenêtre incohérente |
| `400` | `{ "error": "rewards[0].rewardId must reference an existing reward." }` | reward inconnu |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userLimit":200,"rewards":[{"rewardId":2,"value":100}]}' \
  http://hydrogen.dev.com/admin/coupons/WELCOME2026
```

---


## `DELETE /admin/coupons/{id}`

Suppression **définitive** d'un coupon, dans **une seule transaction**. Les lignes `coupon_reward` sont retirées explicitement, puis le coupon ; le ledger `coupon_user` disparaît via son FK `ON DELETE CASCADE`.

> **Irréversible** : l'historique des rédemptions passées est perdu avec le coupon.

**Path params**
- `id` : code du coupon (`[A-Za-z0-9._-]{1,32}`, casse préservée).

**Réponse (200)**

```json
{ "id": "WELCOME2026", "status": "deleted" }
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Coupon 'WELCOME2026' not found." }` | code inconnu |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/coupons/WELCOME2026
```

---
