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#
{
"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.
{
"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.
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
2xxdans 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.idcomme clé d'upsert etrace.updatedAtpour 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.

