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 Connexions 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'à 5 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 reçoivent un 403 school_not_active dès que l'école est fermée ou n'est pas approuvée. Une école seulement suspendue (masquée sur le site, sans sa page) garde ses clés : ses réservations continuent, et l'API avec elles. 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, à 5 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êteOù
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, 20 en 10 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 10 en 10 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. Cinq codes portent des details : validation_error liste les champs fautifs comme [{path, message}] (10 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} ; inside_notice_window donne le délai de préavis du moniteur en heures ({hours}).

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, managed_by_school, 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, pack_pending, slot_conflict, booking_conflict, external_ref_exists, concurrent_update, not_unassigned, not_on_sale, cancel_window_closed, lesson_started, lesson_ended, pack_day_fixed, format_fixed_time, format_price_differs, group_lesson_fixed_time, group_lesson_not_assignable, group_lesson_full, group_lessons_disabledUn 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, resort_required, price_below_minimum, invalid_price, no_resorts, invalid_max_students, currency_mismatch, invalid_duration, too_many_students, instructor_not_verified, wrong_discipline, invalid_duration_prices, invalid_season_prices, invalid_included_students, invalid_lesson_length, invalid_notice, invalid_disciplines, invalid_lesson_formats, formats_not_sold, format_required, unknown_format, format_not_on_date, format_wrong_time, invalid_group_lesson, group_lesson_wrong_date, inside_notice_windowBien 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_…), ou unclaimed_… tant que personne n'a réclamé un profil que votre école a ajouté sans compte ; 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 × max(0, numberOfStudents − includedStudents)) × durationHours, arrondi à la devise ; c'est ce que paie le client. hourlyPrice est celui de la date du cours : celui d'une saison (seasonPrices) si la date y tombe, sinon celui du moniteur ou du poste ; includedStudents est le nombre d'élèves qu'il couvre, 1 sauf si l'école en fixe davantage. platformFee est notre commission : 10 %, ou la commission plus élevée que votre école a convenue par écrit avec nous ; chaque réservation garde le taux auquel elle a été vendue. netAmount est le reste, subtotal − platformFee, crédité 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é 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, 10 CHF) et extraStudentPrice vaut 0 ou plus. Le matériel que le client loue à votre école avec le cours s'ajoute aussi à subtotal (equipment.amount) et compte comme le cours.

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, fr, de, pl, nl, ru)
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, freeCancellationHours (sa politique d'annulation, en heures) 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}Les tarifs — hourlyPrice, extraStudentPrice, extraStudentPer, durationPrices, seasonPrices, includedStudents, groupClassesEnabled et groupPricePerStudent — pour n'importe qui dans l'effectif (les prix horaire et par place ont un plancher par devise, un extraStudentPrice négatif est un 422 invalid_price, et activer les cours collectifs exige un prix par place) ; un nouveau groupPricePerStudent réécrit aussi les cours collectifs actifs. autoAcceptBookings seulement pour un moniteur géré par l'école. Tout est vérifié d'abord et écrit 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.

unclaimed signale un moniteur que l'école a ajouté dans son tableau de bord sans compte : personne ne se connecte à sa place, il est donc toujours managedBy school et l'école répond de lui en tout, messagerie comprise. Quand la personne réclame le profil avec son propre compte, unclaimed passe à false et l'id du moniteur change pour un id définitif : tous les chemins et le filtre instructorId continuent d'accepter l'ancien id, le moniteur le porte dans previousId et les événements suivants citent le nouveau. Aucun événement ne l'annonce : comparez GET /instructors à chaque synchronisation et rapprochez previousId.

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 since et until comme première et dernière dates où il s'applique), soit à une date précise : exactement l'un des deux. Les nouvelles heures available doivent tomber entre 07:00 et 21:00, heure de la station (sinon 422 outside_ski_day) ; les blocs blocked et vacation peuvent couvrir n'importe quelle heure. 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, les deux ancrages à la fois ou un since postérieur à l'until du bloc sont un 422 invalid_availability. Les blocs qui ne peuvent plus s'appliquer (une date passée, un bloc hebdomadaire au-delà de son until) sont écartés : le PUT ne les enregistre pas et le GET ne les renvoie pas. Une date est passée quand elle l'est partout, à 12:00 UTC le lendemain.

GET /instructors/{id}/availability
{
  "blocks": [
    { "id": "ab_1", "resortId": "r_baqueira", "type": "available",
      "dayOfWeek": 6, "startTime": "09:00", "endTime": "13:00",
      "since": "2026-12-19", "until": "2027-04-15" },
    { "id": "ab_2", "resortId": "r_baqueira", "type": "vacation",
      "date": "2026-12-25", "startTime": "07:00", "endTime": "21: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é.

Un moniteur peut avoir au plus 400 blocs externes à partir d'aujourd'hui ; un de plus est un 422 limit_reached. Un bloc dont la date est passée (à 12:00 UTC le lendemain) s'en va de lui-même : GET /blocks ne le liste plus, un DELETE sur lui est un 404 et il cesse de compter dans les 400.

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) ; 07:00–21: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 revérifie le créneau : un bloc externe sur ses heures, comme une réservation active ou un cours collectif, donne un 409 slot_conflict, et l'école doit refuser la demande ou proposer un autre horaire. Sur le poste sans moniteur, accepter ne revérifie rien : ses places ont été comptées quand le client a payé. 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_…). Il vend autant de cours à la fois que son calendrier a de places : un bloc available peut porter capacity (jusqu'à 50 ; 1 s'il n'en porte pas), comptées dans la station du bloc minute par minute : les blocs propres à un jour remplacent les blocs hebdomadaires sur les minutes qu'ils couvrent, et à l'intérieur de chacun, les blocs qui se chevauchent s'additionnent. Un cours a besoin d'une place sur toute sa durée. Les cours collectifs du poste n'occupent pas de places : ils ont lieu en même temps que les cours particuliers et que d'autres cours.

GET /unassigned affiche les réglages ; PATCH /unassigned fixe n'importe lequel de hourlyPrice, extraStudentPrice (0 ou plus ; négatif, il donne un 422 invalid_price), extraStudentPer, durationPrices, seasonPrices, includedStudents, autoAcceptBookings et maximumStudents. Le premier PATCH crée le poste, et jusque-là ses endpoints de calendrier et de blocs renvoient 404. Il n'y a pas d'interrupteur : active indique si le poste est en vente, ce qu'il est dès que son prix horaire dépasse le plancher (ou, s'il vend des formules, dès que ses formules et ses saisons sont complètes) ; il vend sur les heures qu'ouvre son calendrier, donc une école qui ne veut pas de ces cours n'en ouvre aucune. Un active dans un PATCH est ignoré. Pour le reste, le calendrier du poste fonctionne comme celui d'un moniteur, dans les stations de l'école.

Formules fixes. Au lieu de l'heure, une école peut vendre les cours particuliers de son poste en formules fixes, réglées dans le tableau de bord : jusqu'à 6 (matin, après-midi, journée complète ou autre, chacune avec son début et sa fin) et des saisons, des périodes avec un prix par formule, par cours pour tout le groupe. saleMode dans GET /unassigned indique laquelle des deux, et lessonFormats liste les formules et les saisons (en lecture seule dans l'API). Tant que le poste vend des formules, chacun de ses cours particuliers en est une : une formule sans prix dans la saison d'une date n'est pas vendue ce jour-là, et une date hors de toute saison ne vend rien. Une formule a besoin d'une place libre pendant tout son horaire, comme tout cours du poste, et n'a pas la limite de 6 heures des cours à l'heure. Un tel poste est en vente dès que ses formules et ses saisons sont complètes, quel que soit son prix horaire. Ses réservations portent format (id et type), subtotal est le prix de cette formule ce jour-là, et propose-time ne la déplace qu'au même horaire, un jour qui vend sa formule au prix payé (sinon 409 format_fixed_time ou format_price_differs). Dans l'environnement de test, POST /sandbox/bookings sur le poste accepte formatId et utilise les formules de votre école.

EndpointIdentique à
GET, PUT /unassigned/availabilityGET, PUT /instructors/{id}/availability
GET /unassigned/availability/expandedLa vue étendue, pour l'une des stations de l'école ; chaque plage libre indique aussi combien de places il reste (places)
GET, POST /unassigned/blocks · DELETE /unassigned/blocks/{blockId}Blocs externes sur le poste : chacun occupe une place dans sa station (avec plusieurs stations, resortId est obligatoire : 422 resort_required), et n'est refusé (409 booking_conflict) que là où il laisserait plus de cours que de places avec l'un des nôtres parmi eux
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"],
  "saleMode": "formats",
  "lessonFormats": {
    "formats": [
      { "id": "fmt_3b9c1e7a20", "kind": "morning", "startTime": "09:00", "endTime": "13:00", "durationHours": 4 },
      { "id": "fmt_8f02d6c4e1", "kind": "full_day", "startTime": "09:00", "endTime": "17:00", "durationHours": 8 }
    ],
    "seasons": [
      {
        "id": "sea_8d41f0c2b9",
        "name": "Low season",
        "ranges": [{ "from": "2026-11-29", "to": "2026-12-19" }, { "from": "2027-01-03", "to": "2027-02-06" }],
        "prices": [{ "formatId": "fmt_3b9c1e7a20", "price": 450 }, { "formatId": "fmt_8f02d6c4e1", "price": 700 }]
      }
    ]
  }
}

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.

Cours collectifs

Un cours collectif est un événement avec des places que des clients indépendants achètent séparément : ponctuel (date) ou série hebdomadaire (dayOfWeek, avec since et until en option), avec une capacité, un prix par place pour tout le cours, un niveau, à qui il s'adresse (audience : tous, enfants ou adultes ; un cours enfants peut indiquer minAge et maxAge, et chaque élève qui réserve une place doit y correspondre), un point de rendez-vous fixe et un minimum de participants pour avoir lieu. Les cours d'un moniteur prennent leur prix par place dans les réglages du moniteur lui-même (groupClassesEnabled et groupPricePerStudent, modifiables via PATCH /instructors/{id}) ; seul le poste sans moniteur le fixe à la création, et un changement de prix réécrit les cours actifs. Les cours se publient, se confient à un moniteur et s'annulent depuis le panneau — sur l'API ils sont en lecture seule en v1 — et vivent sur un moniteur de l'effectif ou sur le poste sans moniteur attribué.

GET /instructors/{id}/classes (et GET /unassigned/classes) listent les cours actifs. GET …/classes/expanded?from&to les résout en dates concrètes avec leurs places prises, paiements en cours dans leurs 10 minutes de réservation compris — au plus 62 jours par appel. Sur le calendrier d'un moniteur, les heures d'un cours apparaissent aussi comme occupées dans la vue étendue des disponibilités, et un bloc externe sur elles est refusé avec un 409 booking_conflict : ces heures ne sont pas à vous pour être vendues ailleurs. Le poste sans moniteur attribué est une équipe : ses cours ne prennent rien à ses places particulières, ont lieu en même temps qu'elles et que d'autres cours, et un bloc externe n'entre jamais en conflit avec eux.

GET /instructors/{id}/classes
{
  "data": [
    {
      "id": "gc_8c4d21f0a9b3",
      "instructorId": "user_2NcT4jQ0uW",
      "unassigned": false,
      "resortId": "r_grandvalira",
      "discipline": "ski",
      "language": "en",
      "dayOfWeek": 3,
      "since": "2026-12-23",
      "until": "2027-03-31",
      "startTime": "10:00",
      "endTime": "12:00",
      "capacity": 8,
      "minStudents": 3,
      "pricePerStudent": 35,
      "currency": "EUR",
      "level": "beginner",
      "meetingPoint": { "title": "Top of the gondola" },
      "cancelledDates": ["2027-01-20"],
      "createdAt": "2026-09-22T09:00:00.000Z",
      "updatedAt": "2026-09-22T09:00:00.000Z"
    }
  ]
}
GET /instructors/{id}/classes/expanded?from=2027-01-11&to=2027-01-17
{
  "data": [
    {
      "classId": "gc_8c4d21f0a9b3",
      "date": "2027-01-13",
      "startTime": "10:00",
      "endTime": "12:00",
      "capacity": 8,
      "seatsTaken": 5
    }
  ]
}

Chaque place vendue arrive par /bookings comme une réservation ordinaire avec son groupClassId, confirmée dès qu'elle est payée (acceptedVia auto). propose-time et assign la refusent — l'horaire appartient au cours et à tous ses participants, et un cours passe à un moniteur en entier, depuis le panneau. Annuler un cours (ou un jour de la série) rembourse chaque place intégralement et envoie un booking.cancelled par place ; un cours encore sous son minStudents 24 heures avant le début est annulé de la même façon, avec cancelledBy platform.

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'à freeCancellationHours heures avant le début : le délai propre à la réservation, celui qu'avait la politique d'annulation de votre école au moment où elle a été faite (48 par défaut), et qu'elle garde quoi que devienne la politique ensuite.

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",
  "unassigned": false,
  "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" }],
  "groupClassId": null,
  "format": null,
  "pack": null,
  "equipment": null,
  "customerMessage": "Two adults, first time on skis.",
  "customer": { "name": "Santi P.", "language": "es" },
  "meetingPoint": { "status": "not_agreed" },
  "subtotal": 130,
  "platformFee": 13,
  "netAmount": 117,
  "currency": "EUR",
  "bookingStatus": "pending_instructor_approval",
  "paymentStatus": "succeeded",
  "acceptedVia": null,
  "instructorResponseDeadline": "2027-01-06T10:00:00.000Z",
  "freeCancellationHours": 48,
  "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é), 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é. Le créneau est revérifié : une réservation active ou un cours collectif qui le chevauche (marge du moniteur comprise, demandes payées aussi), ou un bloc externe sur ses heures, est un 409 slot_conflict. Sur le poste sans moniteur, rien n'est revérifié : ses places ont été comptées au paiement
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'à 10 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, mis en file comme une livraison ordinaire et tenté maintenant ; indique comment s'est passée cette tentative, et s'il échoue il est réessayé comme les autres
GET /webhooks/{id}/deliveriesLes plus récentes d'abord, sans les corps : 50 par défaut, jusqu'à 100 avec ?limit=
POST /webhooks/{id}/deliveries/{deliveryId}/replayLe même événement (même id, même contenu) à nouveau, comme une nouvelle livraison à part entière, tentée maintenant et réessayée comme les autres
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é, ou un cours collectif a été annulé faute d'atteindre son minimum). 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 10 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. Une réservation ne commence pas toujours par booking.requested : celle d'un moniteur dont la confirmation immédiate est activée (autoAcceptBookings) arrive directement comme booking.confirmed, avec previousStatus pending_instructor_approval. Créez donc la réservation dans votre système dès le premier événement reçu pour son id, quel que soit son type. 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 30 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 5 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 10 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 n'est désactivé, et les gestionnaires de l'école prévenus, que lorsqu'une livraison a épuisé toutes ses tentatives sans qu'aucune autre livraison n'aboutisse entre-temps ; une courte panne ne le désactive pas, même avec de nombreux événements en cours. Tant qu'il est désactivé, les nouveaux événements ne sont pas mis en file pour lui (GET /bookings?updatedSince vous permet de rattraper), et une livraison en attente dont la tentative arrive à échéance pendant ce temps passe en échec sans être envoyée ; les nouvelles tentatives qui arrivent à échéance après sa réactivation partent normalement. Renvoyez celles qui ont échoué. POST /webhooks/{id}/test met un ping en file comme une livraison ordinaire, le tente tout de suite et indique comment s'est passée cette tentative : un ping en échec est réessayé de la même façon, compte comme n'importe quelle livraison pour la désactivation de l'endpoint et, sur un endpoint désactivé, passe en échec sans être envoyé. GET /webhooks/{id}/deliveries montre les livraisons les plus récentes d'abord, 50 par défaut et jusqu'à 100 avec ?limit=, avec leur nombre de tentatives et sans leurs corps : chaque événement a une livraison par endpoint, plus une par renvoi, avec le même id d'événement.

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 (Connexions → 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/classesPublie un cours collectif sur un moniteur de la jumelle ou sur son poste, selon les règles de production ; ses places s'achètent avec POST /sandbox/bookings et groupClassId
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, les écritures comptables et les cours collectifs 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).

  • 2026-09-22 — cours collectifs : GroupClass et ses occurrences sous /instructors/{id}/classes et /unassigned/classes (lecture seule), groupClassId sur chaque réservation, cancelledBy platform quand un cours n'atteint pas son minimum, POST /sandbox/classes et groupClassId sur POST /sandbox/bookings.

  • 2026-09-23 — prix de groupe sur le moniteur : groupClassesEnabled et groupPricePerStudent sur Instructor et sur PATCH /instructors/{id} ; un nouveau prix par place réécrit les cours actifs ; le plancher par place est de 10 EUR / 10 USD.

  • 2026-09-24 — documenté : la réservation d'un moniteur à confirmation immédiate arrive seulement comme booking.confirmed, jamais comme booking.requested ; créez les réservations au premier événement de leur id. Aucun changement de comportement.

  • 2026-09-27 — documenté : proposer un point de rendez-vous envoie bien booking.updated, avec reason meeting_point_proposed ; la section sur la synchronisation disait qu'il n'envoyait aucun événement. Aucun changement de comportement.

  • 2026-09-27 — les blocs dont la date est passée sont écartés : PUT /instructors/{id}/availability ne les enregistre ni ne les renvoie plus, et un bloc externe quitte GET /blocks une fois sa date passée et cesse de compter dans la limite de blocs externes, qui se compte désormais à partir d'aujourd'hui. Jusqu'ici, les uns comme les autres s'accumulaient jusqu'à ce que le moniteur atteigne la limite.

  • 2026-10-05 — moniteurs ajoutés par l'école sans compte : unclaimed et previousId sur Instructor. Quand la personne réclame le profil, son id change ; tous les chemins et le filtre instructorId acceptent encore l'ancien.

  • 2026-10-06 — places sur le poste sans moniteur attribué : capacity sur ses blocs available (plusieurs cours à la fois, comptés par station), places sur chaque plage libre de sa vue étendue, et ses blocs externes occupent une place chacun au lieu de fermer les heures. Ses cours collectifs n'entrent plus en conflit que dans leur propre station. Les calendriers des moniteurs ne changent pas : capacity sur l'un d'eux est un 422 invalid_availability.

  • 2026-10-06 — le poste sans moniteur attribué donne ses cours collectifs en même temps que ses places particulières et que d'autres cours : un cours ne prend plus les heures (elles restent dans free, avec leurs places) et un bloc externe n'entre plus en conflit avec lui. GroupClass gagne audience (all, children, adults) avec minAge et maxAge optionnels ; une place d'un cours enfants ne se vend qu'à des élèves réservés comme enfants, d'un cours adultes qu'à des adultes. POST /sandbox/classes accepte les mêmes champs.

  • 2026-10-06 — les blocs hebdomadaires et les séries de cours collectifs peuvent commencer plus tard : since, leur première date (incluse), permet à une école de charger la saison avant son ouverture sans vendre déjà ses heures. PUT /instructors/{id}/availability l'accepte sur les blocs avec dayOfWeek (un since postérieur à l'until du bloc est un 422 invalid_availability), le GET le renvoie, GroupClass le porte et POST /sandbox/classes l'accepte. Sans since, une série s'applique dès maintenant, comme avant.

  • 2026-10-08 — un endpoint n'est désactivé que lorsqu'une livraison a épuisé toutes ses tentatives sans qu'aucune autre n'aboutisse entre-temps. Il l'était auparavant après dix tentatives échouées d'affilée, toutes livraisons confondues : une courte panne avec de nombreux événements en cours pouvait le désactiver et faire perdre leurs nouvelles tentatives.

  • 2026-10-16 — la journée de cours va de 07:00 à 21:00, heure de la station (avant, de 06:00 à 23:00) : les blocs externes, les horaires proposés et les réservations et cours collectifs du sandbox en dehors renvoient un 422 outside_ski_day, de même que les nouvelles heures available en dehors sur PUT /instructors/{id}/availability et PUT /unassigned/availability. Les heures déjà enregistrées sont conservées, les blocs blocked et vacation peuvent couvrir n'importe quelle heure et un cours collectif publié avant garde son horaire.

  • 2026-10-16 — formules fixes sur le poste sans moniteur attribué (API 1.8.0) : GET /unassigned gagne saleMode et lessonFormats (formules et saisons avec un prix par formule, en lecture seule), Booking gagne format (null pour les cours vendus à l'heure), POST /sandbox/bookings accepte formatId, et propose-time sur un cours en formule garde son horaire et son prix (409 format_fixed_time, format_price_differs). Rien ne change pour un poste qui vend à l'heure.

  • 2026-10-16 — plus d'interrupteur pour les cours non attribués (API 1.8.1) : active dans GET /unassigned indique maintenant si le poste est en vente, ce qu'il est dès que son prix horaire dépasse le plancher (ou que ses formules et saisons sont complètes), et PATCH l'ignore ; une école qui ne veut pas de ces cours n'ouvre pas d'heures pour eux.

  • 2026-10-20 — prix pour plusieurs heures d'affilée (API 1.10.0) : Instructor et UnassignedLessons ajoutent `durationPrices`, des prix fixes facultatifs pour un cours entier de 2, 3 ou 4 heures pour un élève. Celui de 4 heures vaut aussi pour les cours plus longs, à son tarif horaire, une durée sans prix propre prend le tarif de la plus longue des durées plus courtes qui en a un, et les élèves supplémentaires restent facturés à l'heure. Sans eux, tout reste à l'heure. PATCH /instructors/{id} et PATCH /unassigned les acceptent (null les efface). Des prix plus chers à l'heure qu'un cours plus court, ou moins chers au total qu'un cours plus court, répondent 422 `invalid_duration_prices`.

  • 2026-10-19 — forfaits de plusieurs jours (API 1.9.0) : un client peut acheter en un seul paiement plusieurs jours du stage d'un cours collectif (GroupClass.packs, au prix par personne selon sa tranche d'âge, et courseId) ou plusieurs jours de suite d'une formule fixe (UnassignedLessons : packDays et, par saison, minDays et packPrices). Chaque jour est une réservation à part entière avec sa part du prix et un `pack` qui indique de quel forfait et de quel jour il s'agit ; studentLevels peut porter la `band` de chaque élève.

  • 2026-11-02 — conditions par station (API 1.22.0) : UnassignedLessons ajoute `resortTerms` : par id de station, chaque station qui vend différemment, avec tous les prix et conditions tels qu'ils s'y appliquent (les mêmes champs que le niveau supérieur : `hourlyPrice`, `durationPrices`, `seasonPrices`, `saleMode`, `lessonFormats` et les autres). C'est `{}` quand il n'y a qu'un jeu de conditions, et une station absente vend avec les valeurs du niveau supérieur. PATCH /unassigned accepte `resortId` pour écrire les conditions de cette station sans toucher aux autres ; une station où l'école ne travaille pas répond 422 `unknown_resort`. Sans `resortId`, il écrit le niveau supérieur, qu'une station de `resortTerms` ne lit pas. `active` vaut true tant que la vente est ouverte dans au moins une station. C'est un simple ajout : sans conditions par station, tout se lit et se vend comme avant.

  • 2026-10-29 — heures où un moniteur est occupé dans son propre Google Agenda (API 1.21.1) : un moniteur peut faire fermer sur Alpine Pal les heures que son Google Agenda indique comme occupées. Elles ne font pas partie de l'horaire : GET /instructors/{id}/availability ne les liste pas et un PUT ne les voit ni ne les modifie, tandis que /availability/expanded laisse ces heures hors de `free`, comme tout ce qui n'est pas en vente. Rien d'autre ne change.

  • 2026-10-27 — prix par saison et élèves inclus (API 1.21.0) : Instructor et UnassignedLessons ajoutent `seasonPrices` et `includedStudents`, tous deux modifiables avec PATCH /instructors/{id} et PATCH /unassigned. `seasonPrices` liste jusqu'à 4 saisons, chacune avec un `id`, un `name` pour le tableau de bord (null s'il n'y en a pas), de 1 à 10 plages de dates (`from` et `to`, incluses) et ses propres `hourlyPrice` et `durationPrices` : un cours à une date comprise dans une saison est facturé aux prix de cette saison, et une date hors de toute saison aux prix propres du moniteur ou du poste, comme jusqu'ici. Les plages ne se chevauchent jamais, ni dans une saison ni entre saisons, et les prix de chaque saison suivent les mêmes règles que ceux du moniteur ou du poste. Un PATCH envoie la liste entière (null ou une liste vide l'efface) : gardez les ids renvoyés par un GET et omettez l'id pour une nouvelle saison. GET renvoie null quand il n'y en a aucune. `includedStudents` (de 1 à 20, 1 par défaut) est le nombre d'élèves que couvre le prix : `extraStudentPrice` n'est facturé que pour chaque élève au-delà. Des saisons qui enfreignent une règle répondent 422 `invalid_season_prices`, et un nombre hors de sa plage 422 `invalid_included_students`. Purement additif : sans saisons et avec un élève inclus, tous les prix restent comme avant.

  • 2026-10-26 — un prix de 0 ou moins est refusé (API 1.20.4) : dans `durationPrices`, une durée que le moniteur ou le poste vend avec un prix de 0 ou négatif donne désormais un 422 `invalid_duration_prices`. Jusqu'ici il était écarté sans rien dire et la requête répondait 200, si bien que cette durée était facturée à l'heure. Pour ne pas lui donner de prix propre, n'envoyez pas sa clé (ou envoyez null pour tout le champ).

  • 2026-10-26 — corrections de la documentation (API 1.20.3) : cette page et la spec disent désormais ce que l'API faisait déjà. Accepter une demande revérifie le créneau (une réservation active, un cours collectif ou un bloc externe qui le chevauche donne un 409 `slot_conflict` ; sur le poste sans moniteur, rien n'est revérifié). Les clés ne cessent de fonctionner, avec un 403 `school_not_active`, que si l'école est fermée ou non approuvée ; une école suspendue garde son API. Sur un endpoint désactivé, une livraison en attente ne passe en échec que lorsque sa tentative arrive à échéance ; les nouvelles tentatives dues après sa réactivation sont envoyées. Un `extraStudentPer` inconnu est un 400 `validation_error`, et non le 422 `invalid_price` qu'indiquait l'entrée de la 1.13.0 ; `invalid_price` est un `extraStudentPrice` négatif. `platformFee` vaut 10 %, ou la commission plus élevée convenue par écrit par une école, selon la vente de la réservation. La réservation d'exemple gagne `unassigned`, `groupClassId`, `format`, `pack`, `equipment` et `freeCancellationHours`, et son point de rendez-vous indique `not_agreed`. Le tableau des erreurs liste tous les codes, dont `managed_by_school`, ceux des packs, des formules et des cours collectifs, et `inside_notice_window` (avec `hours`). Les livraisons se listent par 50 par défaut et jusqu'à 100 avec `limit`, et un renvoi est une livraison à part entière avec le même id d'événement. Un ping est une livraison ordinaire : réessayé s'il échoue, compté pour la désactivation de l'endpoint et, endpoint désactivé, marqué en échec sans être envoyé. Aucun changement de comportement.

  • 2026-10-26 — deux corrections (API 1.20.2) : PATCH /instructors/{id} juge désormais `durationPrices` sur les durées de cours du moniteur lui-même, comme le panneau, et ne refuse donc plus les prix de qui commence à 2 heures comme plus chers qu'un cours d'1 heure qu'il ne vend pas. Et avec une clé de test, GET et PATCH /unassigned décrivent les formules que vend le bac à sable (celles de la vraie école, que POST /sandbox/bookings utilisait déjà) : `saleMode` et `lessonFormats` n'indiquent plus `hours` et vide pour une place qui vend des formules.

  • 2026-10-26 — attribuer un jour d'un pack (API 1.20.1) : POST /bookings/{id}/assign répond 409 `pack_pending` pour un jour d'un pack de plusieurs jours qui attend encore la réponse de l'école quand le moniteur gère lui-même ses réservations (`managedBy: instructor`). Un pack se répond en entier, et ce moniteur ne pourrait pas répondre pour la part de l'école. Acceptez d'abord le pack, ou attribuez le jour à un moniteur géré par l'école ; les jours déjà confirmés s'attribuent un par un comme avant.

  • 2026-10-25 — fermer une formule chez un moniteur (API 1.20.0) : un bloc blocked avec `formatId` est désormais accepté dans le calendrier d'un moniteur qui vend en formules, comme sur la place sans moniteur attribué depuis la 1.15.0, et ne ferme que cette formule à sa date ou ses jours de la semaine ; les autres formules de ces heures restent en vente. Un moniteur qui suit les heures de l'école (`followsSchoolHours`) reçoit maintenant aussi les fermetures d'une formule de la place. Les réservations respectaient déjà ces fermetures sur tout calendrier.

  • 2026-10-25 — les heures ouvertes de l'école pour ses moniteurs (API 1.19.0) : Instructor gagne `followsSchoolHours`. Un moniteur qui arrive par l'invitation de l'école, ou que l'école ajoute sans compte, démarre désormais en `managedBy: school` avec cette option activée : son calendrier est une copie des heures ouvertes de la place sans moniteur attribué, dans les stations où il enseigne et avec un cours à la fois, et chaque modification de l'horaire de la place s'y reporte. L'école la change dans son panneau (Cours privés). Un PUT /instructors/{id}/availability lui donne ses propres heures à partir de ce moment : `followsSchoolHours` passe à false. Les moniteurs déjà dans l'équipe ne changent pas.

  • 2026-10-23 — moniteurs vendus en formules fixes (API 1.18.0) : Instructor gagne `saleMode`, `hours` ou `formats`. L'école peut désormais vendre n'importe lequel de ses moniteurs en formules fixes avec un prix par saison, comme sa place sans moniteur attribué (à régler dans son panneau, sous Cours privés) ; ses réservations privées portent alors `format`, comme celles de la place, et les tarifs horaires ne vendent plus rien. Booking.format n'est plus réservé à la place. Le 422 `format_required` indique désormais « Choose one of the lesson formats ». Purement additif : chaque moniteur vend à l'heure tant que l'école ne change rien.

  • 2026-10-23 — francs suisses (API 1.17.0) : School.currency peut valoir `CHF`. Les écoles suisses et leurs moniteurs fixent leurs prix et sont payés en francs, donc tous leurs montants (prix, subtotal, paiements) sont en CHF, avec un minimum horaire propre.

  • 2026-10-23 — politique d'annulation par école (API 1.16.0) : Booking gagne `freeCancellationHours`, y compris dans data.booking de chaque événement de réservation : jusqu'à combien d'heures avant le début le client peut annuler avec remboursement intégral, le délai sous lequel la réservation a été vendue — 48, 72, 168, 336, 672 ou 1008. GET /me renvoie le `freeCancellationHours` actuel de l'école. L'école fixe sa politique dans son panneau (Profil → Politique d'annulation) ; elle s'applique à tout ce que l'école vend (les cours privés et collectifs de ses moniteurs et sa place sans moniteur attribué), et une réservation garde le délai sous lequel elle a été faite, quoi que devienne la politique. En lecture seule dans l'API. Purement additif : les réservations antérieures portent 48.

  • 2026-10-21 — fermer une seule formule (API 1.15.0) : un bloc `blocked` de la place sans moniteur attribué peut porter `formatId` et ne ferme alors que cette formule à sa date ou ses jours de la semaine. Les places de ces heures restent aux autres formules ; fermer les heures fermait toutes les formules qui y partageaient une minute. Fermer la journée entière reste fermer ses heures. Le GET renvoie le champ ; un PUT qui l'omet retire la fermeture.

  • 2026-10-21 — formules pour Westendorf (API 1.14.0) : le début et la fin d'une formule tombent désormais au quart d'heure (08:45, 13:15), pas seulement à l'heure pile ou à la demie. Le `lessonFormats` d'UnassignedLessons ajoute `extraStudentPrice` : ce que chaque élève au-delà du premier ajoute à un cours par formules, une fois par cours (par jour d'un pack), à part du supplément horaire de la place ; 0 garde le prix pour tout le groupe. Se règle dans le panneau.

  • 2026-10-20 — supplément par élève et par cours (API 1.13.0) : Instructor et UnassignedLessons ajoutent `extraStudentPer`, `hour` (comme jusqu'ici) ou `lesson` : `extraStudentPrice` est facturé pour chaque heure du cours ou une seule fois pour tout le cours. Modifiable avec PATCH /instructors/{id} et PATCH /unassigned ; toute autre valeur donne 422 `invalid_price`.

  • 2026-10-20 — conditions de Courchevel et de Westendorf (API 1.12.0) : chaque formule d'une saison a son propre minimum de jours d'affilée (`prices[].minDays` ; le `minDays` de la saison devient le plus petit d'entre eux et est obsolète), si bien que les matinées peuvent se vendre à partir de cinq jours et les après-midi à l'unité les mêmes semaines. UnassignedLessons ajoute `minBookingNoticeHours` (désormais aussi deux et trois jours) et `disciplines`, tous deux modifiables avec PATCH /unassigned. Un pack de formules sur une place sans confirmation immédiate attend désormais l'école, comme toute autre demande ; accepter ou refuser l'un de ses jours répond pour tout le pack. La place ajoute aussi `minimumLessonDuration` et `maximumLessonDuration` (heures entières, PATCH /unassigned ; 422 `invalid_lesson_length`) : Snowsports Westendorf vend deux et trois heures, jamais une. Si aucun cours vendu n'est facturé à l'heure, `hourlyPrice` peut valoir 0 et la place reste en vente. `durationPrices` accepte une clé pour chaque durée de deux à six heures ; une durée sans prix suit le tarif horaire de la dernière durée précédente qui en a un. Et `singleLevelBookings` (aussi PATCH /unassigned) : activé, un cours particulier dont les élèves mélangent les niveaux, ou adultes et enfants, est refusé, et le client fait une réservation par groupe ; Courchevel met un moniteur par niveau et par âge.

  • 2026-10-20 — location de matériel (API 1.11.0) : une école peut louer du matériel avec ses cours, à un prix par jour et par article fixé dans son panneau. Booking gagne `equipment` (null si rien n'a été loué) : la part de cette réservation (amount, déjà comprise dans subtotal), la location entière, ses jours et, par élève, les articles avec leur prix et les tailles (pointure EU, taille en cm). La commission et netAmount la comptent comme le cours, et un remboursement la rend avec le cours.