---
title: "Tracking"
description: "Postback de conversion d'affiliation (notification serveur-à-serveur du réseau d'affiliation, ou rejeu Talend). Brique du modèle de commission : le clic es…"
---

## `POST /admin/tracking/conversions`

Postback de conversion d'affiliation (notification serveur-à-serveur du réseau d'affiliation, ou rejeu Talend). Brique du modèle de commission : le **clic** est compté côté public via `/go/offer/{id}` (voir `docs/api.md`), la **conversion** (vente confirmée) remonte ici.

**Idempotent** sur `(targetType, externalRef)` : un rejeu du même postback renvoie `outcome: "duplicate"` et ne modifie aucun agrégat (table `tracking_conversion` à clé unique).

**Body (JSON)**

```json
{
  "targetType":  "offer",
  "targetId":    "0f1e2d3c4b5a69788796a5b4c3d2e1f0",
  "externalRef": "ORDER-2026-00042",
  "amount":      12345,
  "commission":  617,
  "currency":    "EUR",
  "occurredAt":  "2026-06-16T10:00:00Z",
  "clickRef":    "0193a1b2c3d4e5f60718293a4b5c6d7e"
}
```

| Champ | Type | Obligatoire | Sens |
|---|---|---|---|
| `targetType` | string | oui | type de cible, valeur de l'enum `TrackingTargetType` (v1 : `offer`) |
| `targetId` | string | oui | id de la cible (offre : 32 hex) |
| `externalRef` | string | oui | id de commande/transaction côté réseau — clé d'idempotence |
| `amount` | int | oui | valeur de la commande en **unités mineures** (centimes) |
| `commission` | int | oui | notre commission en **unités mineures** |
| `currency` | string | oui | code ISO-4217 (3 lettres, normalisé en majuscules) |
| `occurredAt` | string | non | date de l'événement (ISO-8601 ou `Y-m-d H:i:s`) ; défaut = maintenant (UTC). Détermine le bucket `tracking_daily`. |
| `clickRef` | string | non | **subid** renvoyé par le réseau (32 hex). Relie la conversion au clic précis (média + utilisateur) via `tracking_click`. Stocké tel quel ; un `clickRef` inconnu n'est pas une erreur (l'attribution reste best-effort). Résoluble ensuite via `GET /admin/tracking/clicks/{ref}`. |

> **Argent** : tous les montants sont des **entiers en unités mineures** (centimes) — jamais de float. v1 suppose **une seule devise par cible** : `tracking_stats.currency` garde la dernière vue. Une cible facturée dans plusieurs devises mélangerait ses sommes — à scinder en v2 si le besoin apparaît.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "outcome": "recorded" }` | nouvelle conversion enregistrée + agrégats bumpés |
| `200` | `{ "status": "ok", "outcome": "duplicate" }` | `externalRef` déjà connu, no-op idempotent |
| `400` | `{ "error": "<raison>" }` | body mal formé / champ manquant / devise invalide |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"targetType":"offer","targetId":"0f1e2d3c4b5a69788796a5b4c3d2e1f0","externalRef":"ORDER-42","amount":12345,"commission":617,"currency":"EUR"}' \
  http://hydrogen.dev.com/admin/tracking/conversions
```

---


## `GET /admin/tracking/{targetType}/{targetId}`

Agrégat **cumulé** (lifetime) d'une cible : clics, conversions, chiffre d'affaires et commission. Une cible sans activité renvoie `200` avec des compteurs à zéro (pas de `404`) pour un état vide propre côté dashboard.

**Path params**
- `targetType` : valeur de `TrackingTargetType` (v1 : `offer`).
- `targetId` : id de la cible.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "targetType": "offer", "targetId": "<id>", "clicks": 123, "conversions": 4, "revenueAmount": 49380, "commissionAmount": 2469, "currency": "EUR" }` | agrégat (montants en unités mineures) |
| `200` | `{ ..., "clicks": 0, "conversions": 0, "revenueAmount": 0, "commissionAmount": 0, "currency": null }` | cible sans activité |
| `400` | `{ "error": "Unknown targetType." }` | type inconnu |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/tracking/offer/0f1e2d3c4b5a69788796a5b4c3d2e1f0
```

**Notes**
- Les clics ne sont pas comptés ici en direct : ils transitent par un tampon (`tracking_event`) drainé par le worker `bin/tracking-flush.php`. Un clic met donc au plus un tick de worker à apparaître dans cet agrégat.
- Les conversions sont écrites de façon **synchrone** (transaction) par le postback ci-dessus : elles sont immédiatement visibles.

---


## `GET /admin/tracking/clicks/{ref}`

Résout un **subid** d'affiliation (`clickRef`) en son identité : qui a cliqué, depuis quel média, vers quelle cible. Contrepartie admin du subid que le réseau renvoie dans son postback de conversion.

Le clic est minté côté public par `/go/offer/{id}/media/{mediaId}` (voir `docs/api.md`) : un `clickRef` opaque (UUIDv7 en 32 hex) est persisté dans `tracking_click` puis ajouté à l'URL marchande comme paramètre `subid`. **Seul** ce `clickRef` opaque sort du système — aucun `userId`/PII n'est exposé au partenaire.

**Path params**
- `ref` : le `clickRef` opaque (32 hex minuscule).

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | voir ci-dessous | clic résolu |
| `404` | `{ "error": "Click not found." }` | ref mal formé ou inconnu |
| `403` | `{ "error": "..." }` | auth KO |

```json
{
  "clickRef":   "0193a1b2c3d4e5f60718293a4b5c6d7e",
  "targetType": "offer",
  "targetId":   "0f1e2d3c4b5a69788796a5b4c3d2e1f0",
  "mediaId":    "a1b2c3d4e5f600112233445566778899",
  "userId":     "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
  "visitorId":  "0193a1b2c3d4e5f6071829aabbccddee",
  "createdAt":  "2026-06-19T12:00:00+00:00"
}
```

| Champ | Sens |
|---|---|
| `mediaId` | média visité (32 hex) — **toujours présent** (obligatoire à la génération du lien) |
| `userId` | utilisateur connecté au clic (32 hex), `null` si **anonyme** |
| `visitorId` | cookie visiteur longue durée (32 hex) corrélant les clics d'un même invité ; `null` si le cookie est désactivé/non consenti |
| `createdAt` | horodatage du clic (ISO-8601) |

**Notes**
- Le clic est écrit **synchrone** avant le 302 (il doit exister avant qu'une conversion ne puisse le référencer), contrairement au **compteur** de clics par cible qui, lui, est bufferisé.
- Le cookie visiteur anonyme est **désactivé par défaut** (`TRACKING_VISITOR_COOKIE=false`) : à n'activer que derrière le consentement RGPD. Sans lui, les clics anonymes ne sont pas reliés entre eux mais le reste de l'attribution fonctionne.

---
