# Webhooks

> Version générique : tous les champs sont listés avec la formule minimale qui y donne accès (`FREE`, `PRO`, `PARTNER`). Envoyez votre clé API (`Authorization: Bearer th_live_…`) ou connectez-vous à votre espace développeur pour obtenir la version personnalisée.

> Les webhooks sont réservés à la formule `PARTNER` et activés par l'équipe Trail Horizon pour chaque compte.

Plutôt que de relire le catalogue, recevez un appel HTTP à chaque création, modification ou dépublication d'une course. Le payload est identique à celui de l'API de lecture, restreint à **vos champs**.

## Configuration

- Endpoint en **HTTPS uniquement**, sans identifiants dans l'URL. Les redirections ne sont pas suivies. Les adresses privées, loopback et link-local sont refusées.
- Choix des événements et filtres optionnels par pays / région depuis votre espace développeur.
- Un **secret** est généré par endpoint ; il n'est affiché qu'une fois.

## Événements

| Événement | Déclencheur |
| --- | --- |
| `race.created` | Une course vient d'être publiée (ou rejoint votre périmètre). |
| `race.updated` | Un champ exposé de la course a changé. |
| `race.unpublished` | La course n'est plus visible (dépubliée, masquée pour votre compte, ou sortie de vos filtres pays / région). |
| `race.deleted` | La course a été supprimée. |

Les brouillons (courses jamais publiées) ne sont jamais transmis. Plusieurs changements rapprochés d'une même course sont regroupés en un seul appel portant l'état courant. Aucun appel n'est fait si le payload projeté sur vos champs est identique au dernier envoi réussi.

## Requête reçue

`POST` sur votre URL, corps JSON.

| En-tête | Valeur |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `TrailHorizon-Webhooks/1.0` |
| `X-TrailHorizon-Event` | Type d'événement (`race.updated`…). |
| `X-TrailHorizon-Timestamp` | Timestamp Unix (secondes) de l'envoi. Régénéré à chaque tentative. |
| `X-TrailHorizon-Signature` | `sha256=<hex>` ; pendant une rotation de secret : `sha256=<nouveau>,sha256=<ancien>`. |

### `race.created` / `race.updated`

```json
{
  "event": "race.updated",
  "sentAt": "2026-10-27T12:00:00.000Z",
  "race": {
    "id": "clx1a2b3c0000abcd1234efgh",
    "slug": "trail-des-cretes-fictives",
    "name": "Trail des Crêtes Fictives",
    "url": "https://dev.trailhorizon.fr/races/europe/france/auvergne-rhone-alpes/trail-des-cretes-fictives",
    "nextEditionDate": "2027-06-12T00:00:00.000Z",
    "lastEditionDate": "2026-06-13T00:00:00.000Z",
    "firstEditionDate": "2019-06-15T00:00:00.000Z",
    "updatedAt": "2026-10-20T08:30:00.000Z",
    "status": "ACTIVE",
    "verifiedAt": "2026-10-15T10:00:00.000Z",
    "averageRating": 4.5,
    "location": {
      "city": "Exempleville",
      "lat": 45.1234,
      "lon": 6.5678,
      "region": "Auvergne-Rhône-Alpes",
      "country": "France",
      "continent": "Europe"
    },
    "links": {
      "website": "https://exemple.org/trail",
      "registration": "https://exemple.org/trail/inscriptions",
      "facebook": "https://facebook.com/exemple-trail",
      "instagram": "https://instagram.com/exemple_trail",
      "youtube": null
    },
    "images": {
      "logo": "https://files.example.com/trail-des-cretes/logo.png",
      "card": "https://files.example.com/trail-des-cretes/card.jpg",
      "coverDesktop": "https://files.example.com/trail-des-cretes/cover-desktop.jpg",
      "coverPhone": "https://files.example.com/trail-des-cretes/cover-phone.jpg"
    },
    "formats": [
      {
        "id": "clx9z8y7x0001abcd5678ijkl",
        "name": "Le Grand Tour",
        "type": "STANDARD",
        "distance": 52.4,
        "elevation": 3100,
        "category": "50K",
        "price": 85,
        "currency": "EUR",
        "startAt": "2027-06-12T05:00:00.000Z",
        "hasStartTime": true,
        "timezone": "Europe/Paris",
        "duration": null,
        "traceUrl": "https://tracedetrail.fr/trace/0000",
        "registrationStatus": "NOT_YET_OPEN",
        "registrationOpensAt": "2027-01-15T08:00:00.000Z",
        "maxParticipants": 800,
        "practicalInfo": "Matériel obligatoire : Veste imperméable, EMERGENCY_BLANKET\nDépart : Place du village\nArrivée : Place du village\nTemps limite : 14 h 0 min\nRavitaillements : Col des Exemples (km 18)",
        "practicalInfoStructured": {
          "requiredGear": [
            {
              "code": "WATERPROOF_JACKET",
              "label": null,
              "quantity": null
            },
            {
              "code": "EMERGENCY_BLANKET",
              "label": null,
              "quantity": null
            }
          ],
          "recommendedGear": [],
          "start": {
            "location": "Place du village",
            "details": null
          },
          "finish": {
            "location": "Place du village",
            "details": null
          },
          "access": null,
          "timeLimit": {
            "totalMinutes": 840,
            "cutoffs": []
          },
          "aidStations": [
            {
              "name": "Col des Exemples",
              "km": 18,
              "kind": "FULL"
            }
          ],
          "kidsAgeRange": null,
          "relay": null,
          "stageRace": null,
          "notes": null
        },
        "rankingTypes": [
          "ITRA"
        ],
        "tags": [
          "panoramique",
          "technique"
        ]
      },
      {
        "id": "clx9z8y7x0002abcd5678mnop",
        "name": "La Petite Boucle",
        "type": "STANDARD",
        "distance": 21.2,
        "elevation": 1100,
        "category": "20K",
        "price": 38,
        "currency": "EUR",
        "startAt": "2027-06-12T08:30:00.000Z",
        "hasStartTime": true,
        "timezone": "Europe/Paris",
        "duration": null,
        "traceUrl": null,
        "registrationStatus": "NOT_YET_OPEN",
        "registrationOpensAt": "2027-01-15T08:00:00.000Z",
        "maxParticipants": 400,
        "practicalInfo": null,
        "practicalInfoStructured": null,
        "rankingTypes": [],
        "tags": []
      }
    ]
  }
}
```

### `race.unpublished` / `race.deleted`

Seuls `race.id` et `race.updatedAt` sont envoyés.

```json
{
  "event": "race.unpublished",
  "sentAt": "2026-10-27T12:00:00.000Z",
  "race": {
    "id": "clx1a2b3c0000abcd1234efgh",
    "updatedAt": "2026-10-20T08:30:00.000Z"
  }
}
```

## Vérifier la signature

La signature est `HMAC_SHA256(secret, timestamp + "." + corpsBrut)` en hexadécimal. Calculez-la sur le **corps brut** reçu (avant tout parsing JSON), comparez en temps constant, et rejetez les timestamps de plus de **5 minutes**.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, headers, rawBody) {
    const timestamp = headers['x-trailhorizon-timestamp'];
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
    const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
    return headers['x-trailhorizon-signature']
        .split(',')
        .map((part) => part.trim())
        .filter((part) => part.startsWith('sha256='))
        .some((part) => {
            const candidate = Buffer.from(part.slice(7), 'hex');
            return candidate.length === expected.length && timingSafeEqual(candidate, expected);
        });
}
```

## Réponse, relances et désactivation

- Répondez par un statut `2xx` dans les **10 secondes**. Tout autre résultat (statut, timeout, erreur réseau) est un échec.
- Après un échec, nouvel essai avec un délai croissant : 1 min, 5 min, 30 min, 2 h, 6 h, puis toutes les 12 h, pendant la fenêtre de relance de votre formule. Au-delà, la livraison est abandonnée.
- Au-delà d'un nombre d'échecs consécutifs défini par votre formule, l'endpoint est désactivé automatiquement et le propriétaire du compte est prévenu par email. Réactivez-le depuis votre espace.
- Les livraisons peuvent arriver **en double ou dans le désordre** : utilisez `race.id` comme clé d'upsert et `race.updatedAt` pour ignorer un état plus ancien.

## Rotation du secret

Générez un nouveau secret depuis votre espace : il est actif immédiatement et l'ancien reste valide **24 h**. Pendant ce délai, l'en-tête de signature porte les deux signatures ; acceptez-en une seule valide.

## Citation

L'attribution « Données : Trail Horizon » avec lien vers `race.url` s'applique aussi aux données reçues par webhook.
