Un compte école sur Alpine Pal vous donne l'effectif de vos moniteurs, leurs calendriers et leurs demandes de réservation dans un seul tableau de bord. L'API est ce même tableau de bord pour une machine : un channel manager, un CRM ou un script à vous peut lire et écrire les disponibilités de vos moniteurs, être informé de chaque réservation payée à l'instant où elle a lieu, et l'accepter, la refuser ou proposer un autre horaire.
Elle est volontairement étroite. Les clients paient sur Alpine Pal, donc l'API ne crée pas de réservations : elle enregistre les cours vendus ailleurs comme des blocs qui maintiennent le calendrier conforme à la réalité. Ce qui fait bouger de l'argent d'après ce qui s'est passé sur la piste (marquer une absence, clore un litige) reste un acte humain dans le tableau de bord.
Obtenir une clé
Les clés appartiennent à l'école, pas à une personne, et seul le titulaire de l'école peut les créer ou les révoquer, depuis l'onglet API du tableau de bord de l'école. Une clé n'est affichée qu'une fois, à sa création ; nous en conservons un hash et ne pouvons pas la réafficher. Une école peut détenir jusqu'à cinq clés réelles, de sorte qu'une nouvelle intégration reçoit la sienne et qu'une ancienne se révoque sans toucher aux autres. Les clés réelles commencent par ap_live_ ; celles de l'environnement de test commencent par ap_test_ et se comptent à part.
Une clé cesse de fonctionner dès qu'elle est révoquée, et toutes cessent si l'école est désactivée ou fermée. Les clés ne se gèrent pas depuis l'API elle-même. Pour en faire tourner une : créez la nouvelle, basculez votre système dessus, puis révoquez l'ancienne ; entre-temps, les deux sont valables. Le tableau de bord indique quand chaque clé a été utilisée pour la dernière fois, à cinq minutes près. Les clés sont faites pour des serveurs : n'en mettez jamais une dans un navigateur ni dans une application mobile.
Authentification, en-têtes et corps
Chaque requête porte la clé comme bearer token. L'URL de base est https://alpinepal.com/api/v1 ; les réponses sont en JSON, jamais mises en cache, et chacune porte un X-Request-Id que vous pouvez nous citer. Les heures de la journée sont à l'heure de la station au format HH:mm, les dates au format YYYY-MM-DD et les instants en ISO 8601 en UTC avec millisecondes.
Les corps de requête doivent être du JSON avec un en-tête Content-Type: application/json (415 sinon), de moins de 256 Ko (413), et sont vérifiés strictement : un champ inconnu est un 400 validation_error qui liste les chemins fautifs. Un corps vide se lit comme un objet vide. Une méthode que le chemin n'accepte pas est un 405 avec un en-tête Allow ; une barre oblique finale est un 404.
| En-tête | Où |
|---|---|
Authorization: Bearer <clé> | Chaque requête |
Content-Type: application/json | Chaque requête avec un corps |
X-Request-Id | Chaque réponse, erreurs comprises ; citez-le quand vous nous écrivez |
Cache-Control: no-store, Vary: Authorization | Chaque réponse |
WWW-Authenticate: Bearer realm="alpinepal-api" | Réponses 401 |
Allow | Réponses 405 |
Retry-After (secondes) | Réponses 429 ; le corps le répète comme details.retryAfterSeconds |
curl https://alpinepal.com/api/v1/me \
-H "Authorization: Bearer ap_live_…"Il n'y a pas de CORS : l'API est de serveur à serveur, et une requête depuis un navigateur échouera. C'est voulu : une clé dans un navigateur est une clé que n'importe qui peut lire.
Limites d'utilisation
Par clé, en fenêtres fixes d'une minute : 600 lectures (GET) et 120 écritures (tout le reste, DELETE compris) par minute. Au-delà, la réponse est 429 rate_limited avec un en-tête Retry-After, en secondes. Une clé de test a son propre quota. Les échecs d'authentification sont limités par adresse IP, vingt en dix minutes ; passé ce seuil, toute nouvelle tentative reçoit 429 avant même que la clé soit recherchée. Les tests et les renvois de webhooks partagent un quota à part de dix en dix minutes par clé, puisque chacun est une requête vers le serveur de quelqu'un d'autre.
Erreurs
Chaque erreur est la même enveloppe : un statut HTTP, un code stable sur lequel aiguiller votre logique, un message pour un humain et l'id de la requête. Quatre codes portent des details : validation_error liste les champs fautifs comme [{path, message}] (dix au plus) ; booking_state dit dans quel état se trouve maintenant la réservation ({bookingStatus}) ; booking_conflict nomme les heures qui gênent et, si cette réservation est payée, son id et sa référence ; rate_limited répète l'attente comme {retryAfterSeconds}.
| Statut | Codes | Quand |
|---|---|---|
400 | validation_error, invalid_cursor, invalid_json | La requête elle-même est incorrecte |
401 | unauthenticated, invalid_api_key | Pas de clé, ou une clé malformée, inconnue, révoquée ou de l'autre environnement |
403 | forbidden, school_not_active, not_managed_by_school, not_sandbox | La clé est valable mais cette action ne vous revient pas ; not_sandbox est une clé réelle sur un endpoint de l'environnement de test |
404 | not_found | N'existe pas, ou n'est pas l'un de vos moniteurs. Nous ne confirmons jamais que quelque chose existe hors de votre école |
405 | method_not_allowed | Mauvaise méthode pour le chemin ; Allow liste les bonnes |
409 | booking_state, slot_conflict, booking_conflict, external_ref_exists, concurrent_update, not_unassigned, not_on_sale, cancel_window_closed, lesson_started, lesson_ended | Un vrai conflit d'état ; relisez l'état actuel et décidez à nouveau |
413, 415 | payload_too_large, unsupported_media_type | Les corps doivent être du JSON de moins de 256 Ko |
422 | outside_availability, in_the_past, too_far_ahead, outside_ski_day, invalid_availability, api_blocks_read_only, limit_reached, invalid_webhook_url, unknown_event, range_too_long, unknown_resort, price_below_minimum, invalid_price, no_resorts, invalid_max_students, currency_mismatch, invalid_duration, too_many_students, instructor_not_verified, wrong_discipline | Bien formée, mais enfreint une règle du calendrier, du cours ou de l'école |
429 | rate_limited | Voir Retry-After |
500 | internal | De notre côté ; l'id de la requête nous aide à le retrouver |
{
"error": {
"code": "booking_state",
"message": "Invalid booking state",
"requestId": "req_929e0418a4b7",
"details": { "bookingStatus": "confirmed" }
}
}Lire les données
Ids. Les moniteurs portent l'id de leur compte (user_…) ; le poste des cours sans moniteur attribué d'une école est pool_<schoolId> ; les moniteurs de l'environnement de test sont sandbox_ia_… et sandbox_ib_…. Les réservations sont b_…, les événements evt_…, les livraisons whd_…, les endpoints wh_…, les requêtes req_…. Les références sont AP-<année>-<5 chiffres> pour les réservations réelles et SB<6 hex> dans l'environnement de test.
Heures. date, startTime et endTime sont l'heure locale de resortTimeZone, jamais l'UTC ; les instants (createdAt, updatedAt, paidAt, instructorResponseDeadline…) sont en UTC. Un cours la nuit du changement d'heure se résout à la première occurrence d'une heure ambiguë, et une heure qui n'existe pas cette nuit-là est décalée vers l'avant. endTime peut valoir 24:00. Savoir si une heure est passée se juge à l'heure de la station.
Argent. subtotal = (hourlyPrice + extraStudentPrice × (numberOfStudents − 1)) × durationHours, arrondi à la devise ; c'est ce que paie le client. platformFee est notre commission de 10 %. netAmount est la part de 90 % créditée sur le registre de votre école quand le cours se termine, quel que soit celui qui l'a donné et qui le gère, et annulée si la réservation est remboursée. Chaque moniteur de votre effectif facture dans la devise de votre école ; les prix horaires ont un plancher par devise (10 EUR, 11 USD) et extraStudentPrice vaut 0 ou plus.
| Champ | Valeurs |
|---|---|
bookingStatus | Voir le tableau sous Réservations |
paymentStatus | pending, processing, succeeded, failed, authentication_required, refund_pending, refunded ; seuls succeeded et les deux états de remboursement atteignent l'API |
acceptedVia | instructor, school, auto, customer, ou null tant qu'elle n'est pas acceptée |
studentLevels | Une entrée par élève : { level: beginner | intermediate | advanced, age: adult | child } ; vide dans l'environnement de test |
discipline | ski ou snowboard, celle du cours ; les disciplines d'un moniteur peuvent aussi dire both |
language / customer.language | La langue du cours en code à deux lettres, et la langue dans laquelle le client utilise le site (en, es, it) |
meetingPoint | status not_agreed, proposed ou confirmed, plus le title dès qu'il existe |
verificationStatus (moniteur) | draft, pending_review, verified, rejected |
Votre école, vos stations et vos moniteurs
| Endpoint | Renvoie |
|---|---|
GET / | Le descripteur : nom, version, liens vers cette page et vers la spec (sans clé) |
GET /me | Votre école : id, slug, nom, devise, pays et sandbox (true derrière une clé de test) |
GET /resorts | Chaque station où enseigne l'un de vos moniteurs ou votre poste de cours sans moniteur attribué, avec son fuseau horaire et sa devise |
GET /instructors | Votre effectif, actifs et inactifs ; les moniteurs qui ont quitté l'école en disparaissent, et le poste n'y figure jamais |
GET /instructors/{id} | Un moniteur : tarifs, durées de cours, marge, préavis, disciplines, langues, verificationStatus, managedBy, autoAcceptBookings |
PATCH /instructors/{id} | hourlyPrice et extraStudentPrice pour n'importe qui dans l'effectif (le prix horaire a un plancher par devise) ; autoAcceptBookings seulement pour un moniteur géré par l'école. Les deux sont vérifiés d'abord et écrits ensemble : un corps refusé pour un champ ne change rien |
managedBy est la règle qui décide qui répond : quand il dit instructor, le calendrier et les demandes sont au moniteur et l'API peut lire mais pas écrire ; quand il dit school, l'école, et donc l'API, écrit le calendrier et répond aux demandes, et le moniteur regarde. Il se règle depuis l'effectif dans le tableau de bord, par une personne, et ne se change pas via l'API. Rendre son calendrier au moniteur supprime les blocs externes que votre système y avait écrits.
Quand un moniteur quitte l'école, ses réservations disparaissent de GET /bookings et ses événements s'arrêtent, tandis que ce qu'il a déjà gagné reste sur votre registre. Ni cela ni un changement de managedBy n'envoie d'événement : comparez GET /instructors à chaque synchronisation.
Disponibilités
Les disponibilités d'un moniteur sont une liste de blocs. Un bloc est available, blocked ou vacation, appartient à une station où le moniteur enseigne, va de startTime à endTime (endTime peut valoir 24:00), et est ancré soit à un jour de la semaine (dayOfWeek 0–6, dimanche en premier, avec éventuellement until comme dernière date où il s'applique), soit à une date précise : exactement l'un des deux. Les clients ne peuvent réserver qu'à l'intérieur de blocs available que rien d'autre ne recouvre.
PUT /instructors/{id}/availability remplace tout l'horaire, chaque station et chaque bloc, jusqu'à 400, par ce que vous envoyez, exactement comme enregistrer le calendrier dans le tableau de bord. Les ids sont facultatifs : le serveur en attribue un à un bloc qui n'en a pas, conserve ceux que vous envoyez, et refuse les doublons ou le préfixe xb_ des blocs externes. Les blocs externes (ci-dessous) ne sont jamais touchés par le PUT et ne doivent pas y figurer. Un bloc malformé, une station inconnue ou les deux ancrages à la fois sont un 422 invalid_availability.
{
"blocks": [
{ "id": "ab_1", "resortId": "r_baqueira", "type": "available",
"dayOfWeek": 6, "startTime": "09:00", "endTime": "13:00", "until": "2027-04-15" },
{ "id": "ab_2", "resortId": "r_baqueira", "type": "vacation",
"date": "2026-12-25", "startTime": "06:00", "endTime": "23:00" },
{ "id": "xb_7c1d", "resortId": "r_baqueira", "type": "blocked",
"date": "2027-01-10", "startTime": "10:00", "endTime": "12:00",
"source": "api", "externalRef": "lueira:LS-4471" }
]
}GET …/availability/expanded fait le calcul pour vous : pour une station et jusqu'à 62 jours, il renvoie, par jour, les plages libres qu'un client pourrait réserver et les plages occupées, chacune de celles-ci étiquetée booking (un cours confirmé), pending (une demande payée en attente de réponse) ou external (un bloc venu de l'API). Il applique la marge entre les cours et le délai de préavis fixés par le moniteur ; il n'écarte pas les trous plus courts que sa durée minimale de cours, si bien qu'un trou de trente minutes apparaît comme libre.
La vue étendue
{
"instructorId": "user_2Zk9qX1aR7",
"resortId": "r_baqueira",
"timeZone": "Europe/Madrid",
"days": [
{ "date": "2027-01-10",
"free": [{ "start": "09:00", "end": "10:00" }, { "start": "12:00", "end": "13:00" }],
"busy": [{ "start": "10:00", "end": "12:00", "kind": "external" }] },
{ "date": "2027-01-11",
"free": [{ "start": "09:00", "end": "13:00" }],
"busy": [] }
]
}Blocs externes
Un cours que vous avez vendu en dehors d'Alpine Pal occupe le moniteur. Enregistrez-le comme bloc externe et le créneau disparaît de la recherche, dans toutes les stations où le moniteur enseigne : une personne ne peut pas être dans deux vallées à la fois. Les blocs exigent que l'école gère le moniteur (managedBy = school).
Chaque bloc porte votre propre externalRef. Renvoyer la même référence avec la même date et les mêmes heures est idempotent et renvoie 200 avec le bloc enregistré, quel que soit le resortId passé ; la même référence avec d'autres heures est un 409 external_ref_exists : supprimez et recréez. Un bloc qui chevauche une réservation active est un 409 booking_conflict avec les heures qui gênent et, si cette réservation est payée, son id et sa référence : ce cours est déjà vendu de notre côté.
| Endpoint | Notes |
|---|---|
GET /instructors/{id}/blocks | Filtres facultatifs from, to, externalRef |
POST /instructors/{id}/blocks | date, startTime, endTime, externalRef (1–120 caractères), resortId facultatif (une station où le moniteur enseigne ; par défaut la première) ; 06:00–23:00, pas dans le passé à l'heure de cette station, au plus 30 jours au-delà de l'horizon de réservation de 182 jours |
DELETE /instructors/{id}/blocks/{blockId} | 204 ; un bloc de l'horaire (pas venu de l'API) est ici un 404 |
curl -X POST https://alpinepal.com/api/v1/instructors/user_2Zk9qX1aR7/blocks \
-H "Authorization: Bearer ap_live_…" \
-H "Content-Type: application/json" \
-d '{ "date": "2027-01-10", "startTime": "10:00", "endTime": "12:00",
"externalRef": "lueira:LS-4471" }'Accepter une demande de réservation ne revérifie pas les blocs : si une demande est arrivée avant votre bloc, l'école doit la refuser ou proposer un autre horaire. Dès que votre bloc reçoit un 201, l'heure est à vous : un paiement déjà en cours sur ce créneau est remboursé quand il tente d'aboutir, et vous parvient comme booking.expired.
Cours sans moniteur attribué
Une école peut vendre des heures sans dire qui les donnera : le client réserve et paie, et l'école attribue un moniteur quand elle le souhaite, via l'API, depuis le tableau de bord ou au point de rendez-vous avec qui est libre. Dans l'API, c'est le poste propre de l'école : il a un calendrier, un prix et un interrupteur de confirmation immédiate à lui, il ne figure pas dans /instructors, et ses réservations arrivent avec unassigned: true et instructorId égal au poste (pool_…). Un cours par créneau horaire.
GET /unassigned affiche les réglages ; PATCH /unassigned fixe n'importe lequel de active, hourlyPrice, extraStudentPrice, autoAcceptBookings et maximumStudents. Le premier PATCH crée le poste, et jusque-là ses endpoints de calendrier et de blocs renvoient 404. L'activer exige un prix au-dessus du plancher, dans le même appel ou un appel antérieur, et les stations de l'école choisies dans le tableau de bord ; le désactiver est toujours permis. Le calendrier du poste fonctionne exactement comme celui d'un moniteur, dans les stations de l'école.
| Endpoint | Identique à |
|---|---|
GET, PUT /unassigned/availability | GET, PUT /instructors/{id}/availability |
GET /unassigned/availability/expanded | La vue étendue, pour l'une des stations de l'école |
GET, POST /unassigned/blocks · DELETE /unassigned/blocks/{blockId} | Blocs externes sur le poste : le créneau n'est plus vendu comme cours sans moniteur attribué |
GET /bookings?instructorId=pool_<schoolId> | Seulement les cours sans moniteur attribué |
POST /bookings/{id}/assign | instructorId dans le corps : déplace un cours sans moniteur attribué vers quelqu'un de votre effectif ; voir Répondre à une demande |
{
"instructorId": "pool_sch_2a71",
"configured": true,
"active": true,
"hourlyPrice": 55,
"extraStudentPrice": 15,
"autoAcceptBookings": false,
"maximumStudents": 6,
"currency": "EUR",
"resorts": ["r_baqueira", "r_grandvalira"]
}Tant que le cours n'est pas attribué, l'école en est le côté moniteur : elle répond à la demande, discute avec le client par la messagerie et convient du point de rendez-vous. Une fois attribué, la réservation est celle du moniteur comme n'importe quelle autre, et ce que l'école peut encore en faire suit managedBy.
Réservations
Seules les réservations payées atteignent l'API : une réservation existe pour vous dès l'instant où son paiement aboutit (paidAt), et une réservation remboursée reste listée avec son paymentStatus. Un client choisit un moniteur et un créneau, paie, et la demande arrive chez le moniteur ou chez l'école. À partir de là, soit elle reçoit une réponse, soit elle expire sans réponse et le client est remboursé.
L'horloge : le délai de réponse est de 48 heures à compter du paiement, jamais au-delà du début du cours ; une proposition ou son refus le remet à zéro (jamais au-delà du plus tôt des deux débuts, l'original et le proposé) ; un balayage horaire fait expirer ce qui est en retard, si bien que l'expiration peut arriver jusqu'à une heure plus tard. Un cours confirmé passe à upcoming 24 heures avant son début et à in_progress pendant qu'il a lieu. Il est marqué terminé quand le client le confirme, ou de lui-même 48 heures après sa fin ; la clôture libère votre part. Le client peut annuler sans frais jusqu'à 48 heures avant le début.
| bookingStatus | Signification |
|---|---|
pending_instructor_approval | Payée, en attente d'une réponse avant instructorResponseDeadline |
change_proposed | Un autre horaire a été proposé ; le client l'accepte, le refuse ou le laisse expirer. Le créneau d'origine reste occupé ; le créneau proposé ne l'est pas, et son acceptation le revérifie |
confirmed | Acceptée (acceptedVia dit par qui : instructor, school, auto, customer) |
upcoming, in_progress | Confirmée et proche du cours, ou pendant celui-ci |
completed | Terminée ; votre part de l'argent est libérée |
rejected_by_instructor, expired | Remboursée en totalité |
cancelled_by_customer, cancelled_by_instructor | Annulée ; remboursement selon la politique d'annulation |
no_show_customer, no_show_instructor, disputed, refunded | Fixés depuis le tableau de bord ou par nous |
{
"id": "b_3e8c1f",
"reference": "AP-2027-51234",
"instructorId": "user_2Zk9qX1aR7",
"resortId": "r_baqueira",
"resortTimeZone": "Europe/Madrid",
"date": "2027-01-12",
"startTime": "10:00",
"endTime": "12:00",
"durationHours": 2,
"discipline": "ski",
"language": "en",
"numberOfStudents": 2,
"studentLevels": [{ "level": "beginner", "age": "adult" }, { "level": "beginner", "age": "adult" }],
"customerMessage": "Two adults, first time on skis.",
"customer": { "name": "Santi P.", "language": "es" },
"meetingPoint": { "status": "pending" },
"subtotal": 130,
"platformFee": 13,
"netAmount": 117,
"currency": "EUR",
"bookingStatus": "pending_instructor_approval",
"paymentStatus": "succeeded",
"acceptedVia": null,
"instructorResponseDeadline": "2027-01-06T10:00:00.000Z",
"proposed": null,
"createdAt": "2027-01-04T09:12:41.000Z",
"updatedAt": "2027-01-04T09:13:02.000Z",
"paidAt": "2027-01-04T09:13:02.000Z",
"completedAt": null,
"refundedAt": null,
"url": "https://alpinepal.com/en/booking/b_3e8c1f"
}Du client, vous recevez un nom court (prénom et initiale), sa langue préférée et son message : de quoi donner le cours, et ce que notre politique de confidentialité lui promet. L'e-mail, le téléphone et tout identifiant de compte ne sont jamais envoyés ; la conversation avec le client reste dans la messagerie d'Alpine Pal, là où se trouve le moniteur.
Lister et rester synchronisé
GET /bookings liste les réservations payées de vos moniteurs, de la modification la plus ancienne à la plus récente, avec des filtres sur status (liste séparée par des virgules), instructorId, from et to (dates du cours) et updatedSince (n'importe quel instant ISO 8601 ; inclusif). Les pages sont paginées par curseur sur (updatedAt, id) : suivez nextCursor jusqu'à ce qu'il soit null. Le curseur est opaque et n'expire jamais, mais il appartient aux filtres avec lesquels il a été créé : changez-les et repartez du début ; un curseur inventé est un 400.
Le schéma d'une synchronisation nocturne ou horaire est toujours le même : retenez l'updatedAt le plus récent que vous avez vu et demandez tout depuis là, exactement tel que vous l'avez reçu. Les webhooks vous préviennent à l'instant où quelque chose change ; cet endpoint est la façon de vous rattraper après une interruption. updatedAt bouge aussi pour des changements qui n'envoient aucun événement (le cours qui passe à upcoming ou in_progress, un rappel envoyé, un point de rendez-vous proposé), si bien qu'une ligne peut apparaître dans une synchronisation sans rien de visiblement différent.
curl "https://alpinepal.com/api/v1/bookings?updatedSince=2027-01-04T09:00:00Z&limit=100" \
-H "Authorization: Bearer ap_live_…"
# → { "data": [ … ], "nextCursor": "eyJ1IjoiMjAyNy0…" }
# then, while nextCursor is not null:
curl "https://alpinepal.com/api/v1/bookings?updatedSince=2027-01-04T09:00:00Z&limit=100&cursor=eyJ1IjoiMjAyNy0…" \
-H "Authorization: Bearer ap_live_…"Répondre à une demande
Les actions sont ouvertes à l'école sur les moniteurs qu'elle gère et sur son propre poste de cours sans moniteur attribué ; sur un moniteur qui se gère lui-même, la réponse est 403 not_managed_by_school. Chaque action est une mise à jour conditionnelle : si la réservation n'est plus dans l'état que l'action attend, la réponse est 409 booking_state avec l'état actuel dans details, et rien ne change. Répéter une action est donc sans risque, et la façon de confirmer ce qui s'est passé est GET /bookings/{id}.
| Endpoint | Règle |
|---|---|
POST /bookings/{id}/accept | Depuis l'état en attente ; le cours ne doit pas avoir commencé ; toute réservation active qui chevauche le créneau (marge comprise, demandes payées aussi) est un 409 slot_conflict |
POST /bookings/{id}/reject | Depuis l'état en attente ou change_proposed ; rembourse le client en totalité |
POST /bookings/{id}/propose-time | date, startTime, message facultatif ; seulement depuis l'état en attente, une proposition à la fois. Le client l'accepte, la refuse ou la laisse expirer ; le créneau d'origine reste occupé et le créneau proposé ne l'est pas |
POST /bookings/{id}/cancel | Un cours confirmé, à venir ou en cours qui n'est pas terminé ; rembourse le client en totalité |
POST /bookings/{id}/assign | instructorId dans le corps ; seulement un cours avec unassigned: true, depuis l'état en attente, confirmé ou à venir. Le moniteur doit être dans votre effectif, actif et vérifié, enseigner cette discipline dans cette station, facturer dans cette devise, accepter ce nombre d'élèves et cette durée, et être libre à ce moment-là ; ses horaires publiés ne sont pas vérifiés, c'est à vous d'en juger. Le délai ne bouge pas ; à partir de là, c'est celui qui gère le moniteur qui répond. Le client et le moniteur sont prévenus, et un booking.updated avec reason instructor_assigned suit |
curl -X POST https://alpinepal.com/api/v1/bookings/b_3e8c1f/propose-time \
-H "Authorization: Bearer ap_live_…" \
-H "Content-Type: application/json" \
-d '{ "date": "2027-01-12", "startTime": "14:00",
"message": "The morning is taken; would 14:00 work?" }'Deux choses manquent ici à dessein : marquer une absence et déclarer un cours terminé. Toutes deux font bouger de l'argent d'après ce qui s'est passé sur la piste, et toutes deux restent un acte humain dans le tableau de bord.
Webhooks
Enregistrez une URL https et nous y faisons un POST de chaque événement de réservation : le même JSON que renvoient les endpoints de réservations, enveloppé dans un événement. Jusqu'à dix endpoints par école, chacun abonné à certains événements ou à tous avec "*". Enregistrez-les depuis le tableau de bord ou via l'API ; le tableau de bord montre le journal des livraisons dans les deux cas. L'URL doit être en https, avec un nom d'hôte comportant un point, sans identifiants de connexion ni fragment, et ni une adresse privée ni la nôtre.
| Endpoint | Effet |
|---|---|
GET /webhooks | Vos endpoints, secrets compris |
POST /webhooks | url, events (1–20, ou ["*"]), description facultative ; 201 avec le secret |
PATCH /webhooks/{id} | N'importe lequel de url, events, description, active ; le réactiver remet le compteur d'échecs à zéro |
DELETE /webhooks/{id} | Le supprime avec son journal des livraisons |
POST /webhooks/{id}/rotate-secret | Un nouveau secret ; l'ancien meurt aussitôt |
POST /webhooks/{id}/test | Un ping, livré maintenant ; indique comment cela s'est passé |
GET /webhooks/{id}/deliveries | Les cent dernières livraisons, sans les corps |
POST /webhooks/{id}/deliveries/{deliveryId}/replay | Le même événement à nouveau, comme une nouvelle livraison envoyée maintenant |
curl -X POST https://alpinepal.com/api/v1/webhooks \
-H "Authorization: Bearer ap_live_…" \
-H "Content-Type: application/json" \
-d '{ "url": "https://crm.example.com/alpinepal", "events": ["*"] }'
# → 201 { "id": "wh_…", "secret": "whsec_…", … } — keep the secret{
"id": "evt_4b2c9e17a0d3",
"type": "booking.confirmed",
"apiVersion": "v1",
"createdAt": "2027-01-04T10:02:17.000Z",
"schoolId": "sch_2a71",
"sandbox": false,
"data": {
"booking": { "id": "b_3e8c1f", "bookingStatus": "confirmed", "acceptedVia": "school", "…": "…" },
"previousStatus": "pending_instructor_approval"
}
}POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: AlpinePal-Webhooks/1.0
X-AlpinePal-Event: booking.confirmed
X-AlpinePal-Event-Id: evt_4b2c9e17a0d3
X-AlpinePal-Delivery: whd_00dd9d2f0cdd
X-AlpinePal-Signature: t=1799999999,v1=5f1c…e2a9Événements. booking.requested : une demande payée est arrivée. booking.confirmed : acceptée par le moniteur ou par l'école, confirmée automatiquement, ou le client a accepté un horaire proposé ; acceptedVia dit lequel, et previousStatus vaut change_proposed dans le dernier cas. booking.time_proposed : un autre horaire a été proposé au client. booking.rejected. booking.cancelled : cancelledBy vaut customer, instructor, school ou platform (le compte derrière la réservation a été supprimé). booking.expired : personne n'a répondu à temps. booking.completed : le cours est terminé, absences comprises. booking.updated : tout le reste qui mérite d'être signalé, avec data.reason : proposal_declined, meeting_point_proposed, meeting_point_confirmed, dispute_opened, dispute_resolved, refund_settled (l'argent est revenu au client), refund_issued (par nous) ou instructor_assigned (previousInstructorId nomme le poste sur lequel était le cours). ping est l'événement de test, avec un data vide ; ce n'est pas un type auquel on s'abonne. Aucun événement n'est émis pour upcoming, in_progress, ni pour les changements d'effectif ou de managedBy.
À quoi s'attendre. La livraison est au moins une fois : une réponse qui arrive après notre délai de dix secondes compte comme un échec et l'événement revient, donc traitez l'id de l'événement comme clé et ignorez les répétitions. L'ordre n'est pas garanti, les nouvelles tentatives et le balayage réordonnent les événements, donc triez par data.booking.updatedAt. Le payload est figé à la création de l'événement ; une nouvelle tentative porte ce qui était vrai alors, et un événement ultérieur, l'état plus récent. Un événement peut se perdre si notre tentative de l'enregistrer échoue ; GET /bookings?updatedSince est le filet de sécurité. Chaque événement porte sandbox: true ou false. Le journal conserve les livraisons trente jours ; un renvoi envoie à nouveau une livraison passée.
Vérifier les livraisons
Chaque livraison est signée avec le secret de l'endpoint : l'en-tête X-AlpinePal-Signature porte un horodatage et un HMAC-SHA256 de l'horodatage, d'un point et du corps brut. Vérifiez-la avant de faire confiance à quoi que ce soit, comparez en temps constant, et rejetez les horodatages à plus de cinq minutes de votre horloge. Le secret est renvoyé à la création de l'endpoint et par GET /webhooks, et peut être renouvelé depuis le tableau de bord ou avec POST /webhooks/{id}/rotate-secret ; l'ancien meurt aussitôt.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the exact bytes received, before any JSON parsing.
export function verifyAlpinePal(secret, rawBody, signatureHeader, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, "hex"));
}Répondez avec n'importe quel 2xx en moins de dix secondes. Tout le reste, un 3xx compris puisque nous ne suivons pas les redirections, est un échec : nous réessayons après 5 minutes, 15 minutes, 1 heure, 4 heures, 12 heures et 24 heures, environ 42 heures en tout, chaque fois avec le même id d'événement pour que vous puissiez dédupliquer. Un endpoint qui échoue dix tentatives d'affilée est désactivé et les gestionnaires de l'école sont prévenus ; ses livraisons en attente passent alors en échec et ne sont pas envoyées quand vous le réactivez : renvoyez celles dont vous avez besoin. POST /webhooks/{id}/test envoie un ping tout de suite et indique comment cela s'est passé, et GET /webhooks/{id}/deliveries montre les cent dernières livraisons (une par événement et par endpoint, avec leur nombre de tentatives) sans leurs corps.
Environnement de test
Chaque école dispose d'une jumelle de test dès que son titulaire crée une clé de test depuis le tableau de bord (Connectez votre système de réservation → Environnement de test) : une copie de l'école avec deux moniteurs fictifs, sandbox_ia_…, qui répond aux demandes à la main, et sandbox_ib_…, avec la confirmation immédiate activée, et le poste des cours sans moniteur attribué, chacun avec un calendrier 09:00–17:00 tous les jours dans les stations de l'école, de 1 à 6 heures par cours, jusqu'à 6 élèves, sans marge ni préavis. Les clés de test commencent par ap_test_ et n'atteignent que la jumelle ; les clés réelles jamais. Tout le reste est la même API : lister les moniteurs de la jumelle, écrire leurs calendriers, enregistrer des webhooks, répondre aux demandes, attribuer des cours.
Ce qu'un client fait sur le site, POST /sandbox/bookings le fait pour vous : il crée une réservation payée pour le moniteur que vous nommez, avec les mêmes vérifications qu'une vraie, disponibilités publiées et aucun chevauchement, et sans argent en jeu. À partir de là, la vraie machinerie tourne, et les actions ci-dessous tiennent lieu de client et d'horloge, si bien que chaque événement peut être observé en quelques minutes. Aucun e-mail ne sort de l'environnement de test, et rien de ce qu'il contient n'est jamais public ; chaque événement qu'il envoie porte sandbox: true, tout comme GET /me. POST /sandbox/reset le vide pour qu'une suite de tests parte de zéro.
| Endpoint | Notes |
|---|---|
POST /sandbox/bookings | instructorId (un moniteur de la jumelle, ou son poste), date, startTime, durationHours (dans les limites du moniteur) ; facultatifs : numberOfStudents, resortId, discipline, language, customerMessage. 201 avec la réservation ; 403 not_sandbox avec une clé réelle |
POST /sandbox/bookings/{id}/accept-proposal · decline-proposal | La réponse du client à un horaire proposé |
POST /sandbox/bookings/{id}/cancel | Le client annule, selon les mêmes règles que sur le site |
POST /sandbox/bookings/{id}/complete | Ce que fait la confirmation du client, ou le balayage 48 heures après le cours : completed et votre part libérée |
POST /sandbox/bookings/{id}/expire | Ce que fait le balayage une fois le délai passé : expired et remboursée |
POST /sandbox/reset | Supprime les réservations, les conversations et les écritures comptables de la jumelle ; les clés, les webhooks, les calendriers et le journal des livraisons restent |
curl -X POST https://alpinepal.com/api/v1/sandbox/bookings \
-H "Authorization: Bearer ap_test_…" \
-H "Content-Type: application/json" \
-d '{ "instructorId": "sandbox_ia_3f9a", "date": "2027-01-10",
"startTime": "10:00", "durationHours": 2, "numberOfStudents": 2 }'Restent hors de portée dans l'environnement de test : les litiges, les absences, un remboursement émis par nous, une annulation avec cancelledBy platform, et le point de rendez-vous (ses événements viennent du site). Les réservations de l'environnement de test portent un studentLevels vide et un client nommé Sandbox C. qui lit le site en anglais.
OpenAPI
La description lisible par une machine vit à https://alpinepal.com/api/v1/openapi.json : chaque chemin, méthode, paramètre et schéma ci-dessus, en OpenAPI 3.1. Pointez-y un générateur pour obtenir un client typé, ou importez-la dans votre outil HTTP pour la parcourir. Elle est servie sans authentification et mise en cache pendant une heure.
Versions et changements
Ceci est la v1. À l'intérieur, nous ne faisons qu'ajouter : de nouveaux champs, de nouveaux paramètres facultatifs, de nouveaux types d'événements, de nouveaux endpoints. Votre intégration doit ignorer les champs et les événements qu'elle ne connaît pas. Tout ce qui casserait un client existant ira dans une v2 à une nouvelle URL de base, la v1 restant en service pendant que vous migrez.
2026-09 — v1 : école, stations, moniteurs, disponibilités et vue étendue, blocs externes, réservations avec pagination par curseur, accept / reject / propose-time / cancel, webhooks signés avec nouvelles tentatives.
2026-09-18 — hourlyPrice et extraStudentPrice sur PATCH /instructors/{id} ; le poste des cours sans moniteur attribué sous /unassigned et POST /bookings/{id}/assign ; reason et previousInstructorId sur booking.updated ; l'environnement de test, les clés ap_test_ et sandbox dans chaque événement.
2026-09-18 — révision : booking_state porte l'état actuel ; updatedSince accepte n'importe quel instant ISO ; le remboursement d'une réservation sans paiement émet lui aussi refund_settled ; un seul booking.completed par cours ; assign vérifie les limites du moniteur ; meeting_point_proposed ; POST /webhooks/{id}/deliveries/{deliveryId}/replay ; les actions client et horloge de l'environnement de test ; cette page réécrite autour de ce que fait le code.
2026-09-20 — PATCH /instructors/{id} écrit ses champs ensemble, ou rien ; un bloc qui reçoit un 201 l'emporte désormais sur un paiement en cours sur la même heure (le client est remboursé, vous recevez booking.expired).