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 inclus dans les formules PRO et PARTNER. Vous créez et activez vous-même vos endpoints depuis votre espace développeur : aucune demande à Trail Horizon.
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, sur le port 443 (tout autre port est refusé), sans identifiants dans l'URL. Les redirections ne sont pas suivies. Les adresses privées, loopback et link-local sont refusées.
- Vous gérez vos endpoints vous-même depuis votre espace développeur : création, activation, désactivation, rotation du secret (20 actions par heure et par compte au maximum).
- 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.
- Si l'un de vos domaines autorisés est déclaré Non conforme, les endpoints dont l'URL correspond à ce domaine sont désactivés. Ils ne peuvent être ni créés, ni modifiés, ni réactivés vers cette URL tant que le domaine reste Non conforme.
É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. Une course qui sort de vos filtres pays / région (ou qui est dépubliée en même temps) vous est signalée par race.unpublished, y compris si vous ne la connaissiez que par l'export. 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.
Délai d'envoi#
Les changements sont détectés puis envoyés par un traitement planifié toutes les 5 minutes : comptez jusqu'à environ 5 minutes (davantage lors d'un fort volume de modifications) entre la modification d'une course et la réception du webhook. Ce n'est pas du temps réel.
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://trailhorizon.fr/races/europe/france/auvergne-rhone-alpes/trail-des-cretes-fictives?utm_source=partner-votre-slug&utm_medium=api&utm_campaign=race-data",
"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. Seuls les premiers Ko (4 Ko) de votre réponse sont lus : le corps n'est pas exploité, inutile de renvoyer autre chose qu'un2xxvide. - 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 de passages consécutifs en échec défini par votre formule (un échec compté au plus une fois par endpoint et par passage, toutes les 5 minutes environ), l'endpoint est désactivé automatiquement et le propriétaire du compte est prévenu par email. Réactivez-le depuis votre espace.
- Tant que les conditions d'utilisation de l'API ne sont pas acceptées (jamais acceptées, ou délai de grâce dépassé), les livraisons ne partent pas : elles sont mises en attente et envoyées dès l'acceptation.
- 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 (reprise telle quelle, paramètres de requête compris) s'applique aussi aux données reçues par webhook.

