Aller au contenu
Pour les écoles de ski

API pour les écoles de ski

Connectez le logiciel avec lequel votre école travaille déjà : les disponibilités en entrée, les réservations en sortie par webhook signé, et vos réponses en retour sans ouvrir le tableau de bord.

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
Authorization: Bearer <clé>Chaque requête
Content-Type: application/jsonChaque requête avec un corps
X-Request-IdChaque réponse, erreurs comprises ; citez-le quand vous nous écrivez
Cache-Control: no-store, Vary: AuthorizationChaque réponse
WWW-Authenticate: Bearer realm="alpinepal-api"Réponses 401
AllowRéponses 405
Retry-After (secondes)Réponses 429 ; le corps le répète comme details.retryAfterSeconds
curl
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}.

StatutCodesQuand
400validation_error, invalid_cursor, invalid_jsonLa requête elle-même est incorrecte
401unauthenticated, invalid_api_keyPas de clé, ou une clé malformée, inconnue, révoquée ou de l'autre environnement
403forbidden, school_not_active, not_managed_by_school, not_sandboxLa 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
404not_foundN'existe pas, ou n'est pas l'un de vos moniteurs. Nous ne confirmons jamais que quelque chose existe hors de votre école
405method_not_allowedMauvaise méthode pour le chemin ; Allow liste les bonnes
409booking_state, slot_conflict, booking_conflict, external_ref_exists, concurrent_update, not_unassigned, not_on_sale, cancel_window_closed, lesson_started, lesson_endedUn vrai conflit d'état ; relisez l'état actuel et décidez à nouveau
413, 415payload_too_large, unsupported_media_typeLes corps doivent être du JSON de moins de 256 Ko
422outside_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_disciplineBien formée, mais enfreint une règle du calendrier, du cours ou de l'école
429rate_limitedVoir Retry-After
500internalDe notre côté ; l'id de la requête nous aide à le retrouver
409 Conflict
{
  "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.

ChampValeurs
bookingStatusVoir le tableau sous Réservations
paymentStatuspending, processing, succeeded, failed, authentication_required, refund_pending, refunded ; seuls succeeded et les deux états de remboursement atteignent l'API
acceptedViainstructor, school, auto, customer, ou null tant qu'elle n'est pas acceptée
studentLevelsUne entrée par élève : { level: beginner | intermediate | advanced, age: adult | child } ; vide dans l'environnement de test
disciplineski ou snowboard, celle du cours ; les disciplines d'un moniteur peuvent aussi dire both
language / customer.languageLa langue du cours en code à deux lettres, et la langue dans laquelle le client utilise le site (en, es, it)
meetingPointstatus 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

EndpointRenvoie
GET /Le descripteur : nom, version, liens vers cette page et vers la spec (sans clé)
GET /meVotre école : id, slug, nom, devise, pays et sandbox (true derrière une clé de test)
GET /resortsChaque station où enseigne l'un de vos moniteurs ou votre poste de cours sans moniteur attribué, avec son fuseau horaire et sa devise
GET /instructorsVotre 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.

GET /instructors/{id}/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

GET /instructors/{id}/availability/expanded?resortId=r_baqueira&from=2027-01-10&to=2027-01-11
{
  "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é.

EndpointNotes
GET /instructors/{id}/blocksFiltres facultatifs from, to, externalRef
POST /instructors/{id}/blocksdate, 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
POST /instructors/{id}/blocks
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.

EndpointIdentique à
GET, PUT /unassigned/availabilityGET, PUT /instructors/{id}/availability
GET /unassigned/availability/expandedLa 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}/assigninstructorId dans le corps : déplace un cours sans moniteur attribué vers quelqu'un de votre effectif ; voir Répondre à une demande
GET /unassigned
{
  "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.

bookingStatusSignification
pending_instructor_approvalPayée, en attente d'une réponse avant instructorResponseDeadline
change_proposedUn 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
confirmedAcceptée (acceptedVia dit par qui : instructor, school, auto, customer)
upcoming, in_progressConfirmée et proche du cours, ou pendant celui-ci
completedTerminée ; votre part de l'argent est libérée
rejected_by_instructor, expiredRemboursée en totalité
cancelled_by_customer, cancelled_by_instructorAnnulée ; remboursement selon la politique d'annulation
no_show_customer, no_show_instructor, disputed, refundedFixés depuis le tableau de bord ou par nous
A booking
{
  "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.

Everything that changed since your last sync
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}.

EndpointRègle
POST /bookings/{id}/acceptDepuis 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}/rejectDepuis l'état en attente ou change_proposed ; rembourse le client en totalité
POST /bookings/{id}/propose-timedate, 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}/cancelUn cours confirmé, à venir ou en cours qui n'est pas terminé ; rembourse le client en totalité
POST /bookings/{id}/assigninstructorId 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
POST /bookings/{id}/propose-time
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.

EndpointEffet
GET /webhooksVos endpoints, secrets compris
POST /webhooksurl, 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-secretUn nouveau secret ; l'ancien meurt aussitôt
POST /webhooks/{id}/testUn ping, livré maintenant ; indique comment cela s'est passé
GET /webhooks/{id}/deliveriesLes cent dernières livraisons, sans les corps
POST /webhooks/{id}/deliveries/{deliveryId}/replayLe même événement à nouveau, comme une nouvelle livraison envoyée maintenant
POST /webhooks
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
What a delivery carries
{
  "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"
  }
}
Request headers on every delivery
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.

Verifying the signature (Node)
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.

EndpointNotes
POST /sandbox/bookingsinstructorId (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-proposalLa réponse du client à un horaire proposé
POST /sandbox/bookings/{id}/cancelLe client annule, selon les mêmes règles que sur le site
POST /sandbox/bookings/{id}/completeCe 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}/expireCe que fait le balayage une fois le délai passé : expired et remboursée
POST /sandbox/resetSupprime 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
POST /sandbox/bookings
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).