Una cuenta de escuela en Alpine Pal te da la plantilla de instructores, sus calendarios y sus solicitudes de reserva en un solo panel. La API es ese mismo panel para una máquina: un channel manager, un CRM o un script tuyo puede leer y escribir la disponibilidad de tus instructores, enterarse de cada reserva pagada en el momento y aceptarla, rechazarla o proponer otra hora.
Es estrecha a propósito. El cliente paga en Alpine Pal, así que la API no crea reservas: registra las clases vendidas fuera como bloqueos que mantienen el calendario en orden. Lo que mueve dinero por lo que pasó en la pista — marcar una ausencia, cerrar una disputa — sigue siendo un acto humano en el panel.
Conseguir una clave
Las claves son de la escuela, no de una persona, y solo el titular de la escuela puede crearlas o revocarlas, desde la pestaña Conexiones del panel. Una clave se enseña una sola vez, al crearla; guardamos un hash y no podemos volver a mostrarla. Una escuela puede tener hasta 5 claves vivas, así que cada integración lleva la suya y se puede revocar una vieja sin tocar las demás. Las claves reales empiezan por ap_live_; las del entorno de pruebas empiezan por ap_test_ y se cuentan aparte.
Una clave deja de funcionar en el momento en que se revoca, y todas reciben un 403 school_not_active en cuanto la escuela se cierra o no está aprobada. Una escuela solo suspendida (oculta en la web, sin su página) conserva sus claves: sus reservas siguen adelante, y la API con ellas. Las claves no se gestionan desde la propia API. Para rotar una: crea la nueva, cambia tu sistema a ella y revoca la vieja; entre medias las dos valen. El panel muestra cuándo se usó cada clave por última vez, con 5 minutos de precisión. Las claves son para servidores: nunca metas una en un navegador ni en una app móvil.
Autenticación, cabeceras y cuerpos
Cada petición lleva la clave como bearer token. La URL base es https://alpinepal.com/api/v1; las respuestas son JSON, nunca se cachean, y cada una lleva un X-Request-Id que puedes citarnos. Las horas del día van en el reloj de la estación como HH:mm, las fechas como YYYY-MM-DD y los instantes en ISO 8601 en UTC con milisegundos.
Los cuerpos deben ser JSON con cabecera Content-Type: application/json (415 si no), de menos de 256 KB (413), y se comprueban de forma estricta: un campo desconocido es un 400 validation_error que lista las rutas que sobran. Un cuerpo vacío se lee como un objeto vacío. Un método que la ruta no admite es un 405 con cabecera Allow; una barra final es un 404.
| Cabecera | Dónde |
|---|---|
Authorization: Bearer <clave> | Toda petición |
Content-Type: application/json | Toda petición con cuerpo |
X-Request-Id | Toda respuesta, errores incluidos; cítalo cuando nos escribas |
Cache-Control: no-store, Vary: Authorization | Toda respuesta |
WWW-Authenticate: Bearer realm="alpinepal-api" | Respuestas 401 |
Allow | Respuestas 405 |
Retry-After (segundos) | Respuestas 429; el cuerpo lo repite como details.retryAfterSeconds |
curl https://alpinepal.com/api/v1/me \
-H "Authorization: Bearer ap_live_…"No hay CORS: la API es de servidor a servidor, y una petición desde un navegador fallará. Es a propósito: una clave en un navegador es una clave que cualquiera puede leer.
Límites de uso
Por clave, en ventanas fijas de un minuto: 600 lecturas (GET) y 120 escrituras (todo lo demás, DELETE incluido) por minuto. Pasado eso la respuesta es 429 rate_limited con cabecera Retry-After, en segundos. Una clave de prueba tiene su propio cupo. Los fallos de autenticación se limitan por IP, 20 en 10 minutos; a partir de ahí cualquier intento más recibe 429 antes siquiera de buscar la clave. Las pruebas y reenvíos de webhooks comparten un cupo aparte de 10 en 10 minutos por clave, porque cada uno es una petición al servidor de otro.
Errores
Todo error es el mismo sobre: un estado HTTP, un code estable para ramificar, un mensaje para una persona y el id de la petición. Cinco códigos llevan details: validation_error lista los campos que fallan como [{path, message}] (10 como máximo); booking_state dice en qué estado está ahora la reserva ({bookingStatus}); booking_conflict nombra las horas que estorban y, si esa reserva está pagada, su id y su referencia; rate_limited repite la espera como {retryAfterSeconds}; inside_notice_window da la antelación del instructor en horas ({hours}).
| Estado | Códigos | Cuándo |
|---|---|---|
400 | validation_error, invalid_cursor, invalid_json | La petición en sí está mal |
401 | unauthenticated, invalid_api_key | Sin clave, o con una mal formada, desconocida, revocada o del otro entorno |
403 | forbidden, school_not_active, managed_by_school, not_managed_by_school, not_sandbox | La clave vale pero esta acción no es tuya; not_sandbox es una clave real en un endpoint del entorno de pruebas |
404 | not_found | No existe, o no es de tus instructores. Nunca confirmamos que algo exista fuera de tu escuela |
405 | method_not_allowed | Método equivocado para la ruta; Allow lista los válidos |
409 | booking_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_disabled | Un conflicto de estado real; lee el estado actual y decide de nuevo |
413, 415 | payload_too_large, unsupported_media_type | Los cuerpos deben ser JSON y de menos de 256 KB |
422 | outside_availability, in_the_past, too_far_ahead, outside_ski_day, invalid_availability, api_blocks_read_only, limit_reached, invalid_webhook_url, unknown_event, range_too_long, unknown_resort, 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_window | Bien formada, pero rompe una regla del calendario, de la clase o de la escuela |
429 | rate_limited | Mira Retry-After |
500 | internal | Nuestro; el id de la petición nos ayuda a encontrarlo |
{
"error": {
"code": "booking_state",
"message": "Invalid booking state",
"requestId": "req_929e0418a4b7",
"details": { "bookingStatus": "confirmed" }
}
}Leer los datos
Ids. Los instructores llevan el id de su cuenta (user_…), o unclaimed_… mientras nadie haya reclamado un perfil que tu escuela añadió sin cuenta; el asiento de clases sin asignar de una escuela es pool_<schoolId>; los instructores del entorno de pruebas son sandbox_ia_… y sandbox_ib_…. Las reservas son b_…, los eventos evt_…, las entregas whd_…, los endpoints wh_…, las peticiones req_…. Las referencias son AP-<año>-<5 dígitos> en las reservas reales y SB<6 hex> en el entorno de pruebas.
Horas. date, startTime y endTime son el reloj de pared de resortTimeZone, nunca UTC; los instantes (createdAt, updatedAt, paidAt, instructorResponseDeadline…) son UTC. Una clase la noche del cambio de hora se resuelve a la primera aparición de una hora ambigua, y una hora que esa noche no existe se adelanta. endTime puede ser 24:00. Si una hora ya pasó se juzga en el reloj de la estación.
Dinero. subtotal = (hourlyPrice + extraStudentPrice × max(0, numberOfStudents − includedStudents)) × durationHours, redondeado a la moneda; es lo que paga el cliente. hourlyPrice es el de la fecha de la clase: el de una temporada (seasonPrices) si la fecha cae en una, y si no el propio del instructor o del asiento; includedStudents es cuántos alumnos cubre, 1 salvo que la escuela ponga más. platformFee es nuestra comisión: el 10 %, o la más alta que tu escuela haya pactado por escrito con nosotros; cada reserva conserva la que tenía al venderse. netAmount es el resto, subtotal − platformFee, que se abona en el libro de tu escuela cuando la clase se completa, la dé quien la dé y la gestione quien la gestione, y se anula si la reserva se reembolsa. Todos los instructores de tu plantilla cobran en la moneda de tu escuela; el precio por hora tiene un mínimo por moneda (10 EUR, 11 USD, 10 CHF) y extraStudentPrice es 0 o más. El material que el cliente alquila a tu escuela con la clase también se suma a subtotal (equipment.amount) y cuenta como la clase.
| Campo | Valores |
|---|---|
bookingStatus | Ver la tabla de Reservas |
paymentStatus | pending, processing, succeeded, failed, authentication_required, refund_pending, refunded; a la API solo llegan succeeded y los dos estados de reembolso |
acceptedVia | instructor, school, auto, customer, o null mientras no se acepta |
studentLevels | Una entrada por alumno: { level: beginner | intermediate | advanced, age: adult | child }; vacío en el entorno de pruebas |
discipline | ski o snowboard, el de la clase; las disciplines de un instructor pueden decir también both |
language / customer.language | El idioma de la clase como código de dos letras, y el idioma en que el cliente usa la web (en, es, it, fr, de, pl, nl, ru) |
meetingPoint | status not_agreed, proposed o confirmed, más el title cuando existe |
verificationStatus (instructor) | draft, pending_review, verified, rejected |
Tu escuela, tus estaciones y tus instructores
| Endpoint | Devuelve |
|---|---|
GET / | El descriptor: nombre, versión, enlaces a esta página y al spec (sin clave) |
GET /me | Tu escuela: id, slug, nombre, moneda, país, freeCancellationHours (su política de cancelación, en horas) y sandbox (true con una clave de prueba) |
GET /resorts | Toda estación en la que enseña alguno de tus instructores o tu asiento de clases sin asignar, con su zona horaria y su moneda |
GET /instructors | Tu plantilla, activos e inactivos; los que dejaron la escuela desaparecen de ella, y el asiento nunca está |
GET /instructors/{id} | Un instructor: tarifas, duración de clases, margen, antelación, disciplinas, idiomas, verificationStatus, managedBy, autoAcceptBookings |
PATCH /instructors/{id} | Las tarifas — hourlyPrice, extraStudentPrice, extraStudentPer, durationPrices, seasonPrices, includedStudents, groupClassesEnabled y groupPricePerStudent — de cualquiera de la plantilla (los precios por hora y por plaza tienen un mínimo por moneda, un extraStudentPrice negativo es un 422 invalid_price, y activar las clases grupales exige un precio por plaza); un groupPricePerStudent nuevo re-estampa además las clases grupales activas. autoAcceptBookings solo en un instructor que gestiona la escuela. Todo se comprueba primero y se escribe junto: un cuerpo rechazado por un campo no cambia nada |
managedBy es la regla que decide quién responde: cuando dice instructor, el calendario y las solicitudes son suyos y la API puede leer pero no escribir; cuando dice school, la escuela, y por tanto la API, escribe el calendario y responde las solicitudes, y el instructor mira. Se fija desde la plantilla en el panel, por una persona, y no se cambia por API. Devolverle el calendario al instructor borra los bloqueos externos que tu sistema escribió en él.
Cuando un instructor deja la escuela, sus reservas desaparecen de GET /bookings y sus eventos se detienen, mientras que lo que ya ganó se queda en tu libro. Ni eso ni un cambio de managedBy manda evento: compara GET /instructors en cada sincronización.
unclaimed marca a un instructor que la escuela añadió en el panel sin cuenta: nadie inicia sesión como él, así que siempre es managedBy school y la escuela responde por él en todo, también en el chat. Cuando la persona reclama el perfil con su propia cuenta, unclaimed pasa a false y el id del instructor cambia a uno definitivo: todas las rutas y el filtro instructorId siguen aceptando el id antiguo, el instructor lo lleva en previousId y los eventos desde entonces nombran el nuevo. Ningún evento lo anuncia: compara GET /instructors en cada sincronización y casa previousId.
Disponibilidad
La disponibilidad de un instructor es una lista de bloques. Un bloque es available, blocked o vacation, pertenece a una estación en la que el instructor enseña, va de startTime a endTime (endTime puede ser 24:00), y se ancla o a un día de la semana (dayOfWeek 0–6, domingo primero, opcionalmente con since y until como primera y última fecha en que aplica) o a una fecha concreta: exactamente una de las dos. Las horas available nuevas tienen que caer entre las 07:00 y las 21:00, hora de la estación (si no, 422 outside_ski_day); los bloques blocked y vacation pueden cubrir cualquier hora. Los clientes solo pueden reservar dentro de bloques available que nada más cubra.
PUT /instructors/{id}/availability sustituye todo el horario, todas las estaciones y todos los bloques, hasta 400, por lo que envías, exactamente como guardar el calendario en el panel. Los ids son opcionales: el servidor asigna uno a un bloque sin él, conserva los que envías y rechaza duplicados o el prefijo xb_ de los bloqueos externos. Los bloqueos externos (más abajo) nunca los toca el PUT y no deben aparecer en él. Un bloque mal formado, una estación desconocida, los dos anclajes a la vez o un since posterior al until del bloque son un 422 invalid_availability. Los bloques que ya no pueden aplicarse (una fecha pasada, un bloque semanal pasado su until) se descartan: el PUT no los guarda y el GET no los devuelve. Una fecha ha pasado cuando ha pasado en todas partes, a las 12:00 UTC del día siguiente.
{
"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 hace la aritmética por ti: para una estación y hasta 62 días devuelve, por día, los huecos libres que un cliente podría reservar y los ocupados, cada ocupado etiquetado booking (una clase confirmada), pending (una solicitud pagada esperando respuesta) o external (un bloqueo de la API). Aplica el margen entre clases y la antelación que fijó el instructor; no descarta huecos más cortos que su duración mínima, así que un hueco de treinta minutos sale como libre.
La vista expandida
{
"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": [] }
]
}Bloqueos externos
Una clase vendida fuera de Alpine Pal ocupa al instructor. Regístrala como bloqueo externo y la franja desaparece de la búsqueda, en todas las estaciones donde enseña: una persona no puede estar en dos valles a la vez. Los bloqueos exigen que la escuela gestione al instructor (managedBy = school).
Cada bloqueo lleva tu propio externalRef. Enviar la misma referencia otra vez con la misma fecha y horas es idempotente y devuelve 200 con el bloqueo guardado, pases el resortId que pases; la misma referencia con otras horas es un 409 external_ref_exists: borra y vuelve a crear. Un bloqueo que solapa una reserva viva es un 409 booking_conflict con las horas que estorban y, si esa reserva está pagada, su id y su referencia: esa clase ya está vendida por nuestro lado.
Un instructor puede tener como mucho 400 bloqueos externos de hoy en adelante; uno más es un 422 limit_reached. Un bloqueo cuya fecha ha pasado (a las 12:00 UTC del día siguiente) se va solo: GET /blocks ya no lo lista, un DELETE sobre él es un 404 y deja de contar para los 400.
| Endpoint | Notas |
|---|---|
GET /instructors/{id}/blocks | Filtros opcionales from, to, externalRef |
POST /instructors/{id}/blocks | date, startTime, endTime, externalRef (1–120 caracteres), resortId opcional (una en la que enseñe; por defecto la primera); 07:00–21:00, no en el pasado según el reloj de esa estación, como mucho 30 días más allá del horizonte de reservas de 182 días |
DELETE /instructors/{id}/blocks/{blockId} | 204; un bloque del horario (no de la API) es un 404 aquí |
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" }'Aceptar una solicitud vuelve a comprobar la franja: un bloqueo externo en sus horas, igual que una reserva viva o una clase grupal, da un 409 slot_conflict, y la escuela debe rechazarla o proponer otra hora. En el asiento de clases sin asignar, aceptar no vuelve a comprobar nada: sus plazas se contaron cuando el cliente pagó. Cuando tu bloqueo recibe 201 la hora es tuya: un pago que ya estuviera en curso sobre ella se reembolsa al intentar liquidarse y te llega como booking.expired.
Clases sin instructor asignado
Una escuela puede vender horas sin decir quién dará la clase: el cliente reserva y paga, y la escuela asigna un instructor cuando quiera, por la API, desde el panel o en el punto de encuentro con quien esté libre. En la API esto es el asiento propio de la escuela: tiene calendario, precio e interruptor de confirmación inmediata propios, no aparece en /instructors, y sus reservas llegan con unassigned: true e instructorId igual al asiento (pool_…). Vende tantas clases a la vez como plazas tenga su calendario: un bloque available puede llevar capacity (hasta 50; 1 si no la lleva), contadas en la estación del bloque minuto a minuto: los bloques del propio día sustituyen a los semanales en los minutos que cubren, y dentro de cada capa los que se solapan se suman. Una clase necesita plaza durante todo su tramo. Las clases grupales del asiento no ocupan plazas: van a la vez que las particulares y que otras clases.
GET /unassigned enseña los ajustes; PATCH /unassigned fija cualquiera de hourlyPrice, extraStudentPrice (0 o más; uno negativo es un 422 invalid_price), extraStudentPer, durationPrices, seasonPrices, includedStudents, autoAcceptBookings y maximumStudents. El primer PATCH crea el asiento, y hasta entonces sus endpoints de calendario y bloqueos dan 404. No hay interruptor: active dice si el asiento está a la venta, y lo está en cuanto su precio por hora supera el mínimo (o, si vende formatos, sus formatos y temporadas están completos); vende en las horas que abra su calendario, así que una escuela que no quiera estas clases no abre ninguna. Un active en un PATCH se ignora. Por lo demás, el calendario del asiento funciona como el de un instructor, en las estaciones de la escuela.
Formatos fijos. En vez de por horas, una escuela puede vender las clases particulares de su asiento en formatos fijos, que se configuran en el panel: hasta 6 (mañana, tarde, día completo u otro, cada uno con su inicio y su fin) y temporadas, tramos de fechas con un precio por formato, por clase y para todo el grupo. saleMode en GET /unassigned dice cuál, y lessonFormats lista los formatos y las temporadas (solo lectura en la API). Mientras el asiento vende formatos, cada clase particular suya es uno de ellos: un formato sin precio en la temporada de una fecha no se vende ese día, y una fecha fuera de toda temporada no vende nada. Un formato necesita plaza durante todo su horario, como cualquier clase del asiento, y no tiene el límite de 6 horas de las clases por horas. Un asiento así está a la venta en cuanto sus formatos y temporadas están completos, sea cual sea su precio por hora. Sus reservas llevan format (id y tipo), subtotal es el precio de ese formato ese día, y propose-time solo lo mueve al mismo horario en un día que venda su formato al precio pagado (si no, 409 format_fixed_time o format_price_differs). En el entorno de pruebas, POST /sandbox/bookings sobre el asiento acepta formatId y usa los formatos de tu escuela.
| Endpoint | Igual que |
|---|---|
GET, PUT /unassigned/availability | GET, PUT /instructors/{id}/availability |
GET /unassigned/availability/expanded | La vista expandida, para una de las estaciones de la escuela; cada tramo libre dice además cuántas plazas quedan (places) |
GET, POST /unassigned/blocks · DELETE /unassigned/blocks/{blockId} | Bloqueos externos sobre el asiento: cada uno ocupa una plaza en su estación (con más de una estación, resortId es obligatorio: 422 resort_required), y solo se rechaza (409 booking_conflict) donde dejaría más clases que plazas con alguna nuestra entre ellas |
GET /bookings?instructorId=pool_<schoolId> | Solo las clases sin asignar |
POST /bookings/{id}/assign | instructorId en el cuerpo: pasa una clase sin asignar a alguien de tu plantilla; ver Responder una solicitud |
{
"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 }]
}
]
}
}Hasta que la clase se asigna, la escuela es su lado instructor: responde la solicitud, chatea con el cliente y acuerda el punto de encuentro. Una vez asignada, la reserva es del instructor como cualquier otra, y lo que la escuela aún puede hacer con ella sigue a managedBy.
Clases grupales
Una clase grupal es un evento con plazas que clientes independientes compran por separado: puntual (date) o serie semanal (dayOfWeek, opcionalmente con since y until), con aforo, precio por plaza para la clase entera, nivel, para quién es (audience: todos, niños o adultos; una clase de niños puede indicar minAge y maxAge, y cada alumno que reserva plaza tiene que encajar), punto de encuentro fijo y un mínimo de asistentes para impartirse. Las clases de un instructor toman su precio por plaza de los ajustes del propio instructor (groupClassesEnabled y groupPricePerStudent, escribibles por PATCH /instructors/{id}); solo el asiento sin asignar lo pone al crear, y un cambio de precio re-estampa las clases activas. Las clases se publican, se entregan a un instructor y se cancelan desde el panel — en la API son de solo lectura en v1 — y viven en un instructor de la plantilla o en el asiento sin asignar.
GET /instructors/{id}/classes (y GET /unassigned/classes) listan las activas. GET …/classes/expanded?from&to las resuelve a fechas concretas con sus plazas ocupadas, pagos en curso dentro de sus 10 minutos de retención incluidos — como mucho 62 días por llamada. En el calendario de un instructor, las horas de una clase salen además como ocupadas en la vista expandida de la disponibilidad, y un bloqueo externo sobre ellas se rechaza con un 409 booking_conflict: esas horas no son tuyas para venderlas fuera. El asiento sin asignar es un equipo: sus clases no le quitan plazas particulares, van a la vez que ellas y que otras clases, y un bloqueo externo nunca choca con ellas.
{
"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"
}
]
}{
"data": [
{
"classId": "gc_8c4d21f0a9b3",
"date": "2027-01-13",
"startTime": "10:00",
"endTime": "12:00",
"capacity": 8,
"seatsTaken": 5
}
]
}Cada plaza vendida llega por /bookings como una reserva normal con su groupClassId, confirmada en cuanto se paga (acceptedVia auto). propose-time y assign la rechazan — la hora es de la clase y de todos sus participantes, y una clase se entrega a un instructor entera, desde el panel. Cancelar una clase (o un día de la serie) reembolsa cada plaza íntegra y envía un booking.cancelled por plaza; una clase que 24 horas antes de empezar sigue por debajo de su minStudents se cancela igual, con cancelledBy platform.
Reservas
A la API solo llegan reservas pagadas: una reserva existe para ti desde el instante en que entra su pago (paidAt), y una reembolsada sigue listada con su paymentStatus. Un cliente elige instructor y franja, paga, y la solicitud aterriza en el instructor o en la escuela. A partir de ahí, o se responde, o caduca sin respuesta y se reembolsa al cliente.
El reloj: el plazo de respuesta son 48 horas desde el pago, nunca más tarde del inicio de la clase; una propuesta o un rechazo de propuesta lo reinician (nunca más allá del inicio más temprano entre el original y el propuesto); un barrido horario caduca lo vencido, así que la caducidad puede llegar hasta una hora tarde. Una clase confirmada pasa a upcoming 24 horas antes de empezar y a in_progress mientras dura. Se completa cuando el cliente la confirma, o sola 48 horas después de acabar; completarse libera tu parte. El cliente puede cancelar gratis hasta freeCancellationHours horas antes del inicio: la ventana de la propia reserva, la que tenía la política de cancelación de tu escuela cuando se hizo (48 por defecto), y que conserva aunque la política cambie después.
| bookingStatus | Significado |
|---|---|
pending_instructor_approval | Pagada, esperando respuesta antes de instructorResponseDeadline |
change_proposed | Se propuso otra hora; el cliente acepta, rechaza o la deja caducar. La franja original sigue ocupada; la propuesta no, y su aceptación la vuelve a comprobar |
confirmed | Aceptada (acceptedVia dice por quién: instructor, school, auto, customer) |
upcoming, in_progress | Confirmada y cerca de la clase o durante ella |
completed | Hecha; tu parte del dinero queda liberada |
rejected_by_instructor, expired | Reembolsada íntegra |
cancelled_by_customer, cancelled_by_instructor | Cancelada; reembolso según la política de cancelación |
no_show_customer, no_show_instructor, disputed, refunded | Se fijan desde el panel o por nosotros |
{
"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"
}Del cliente recibes un nombre corto (nombre e inicial), su idioma preferido y su mensaje: suficiente para dar la clase, y lo que nuestra política de privacidad le promete. Nunca se envían email, teléfono ni identificador de cuenta; la conversación con el cliente sigue en el chat de Alpine Pal, donde está el instructor.
Listar y mantenerse al día
GET /bookings lista las reservas pagadas de tus instructores, del cambio más antiguo al más nuevo, con filtros por status (lista separada por comas), instructorId, from y to (fechas de clase) y updatedSince (cualquier instante ISO 8601; inclusivo). Las páginas van por cursor sobre (updatedAt, id): sigue nextCursor hasta que sea null. El cursor es opaco y no caduca, pero pertenece a los filtros con los que se hizo: si los cambias, empieza de nuevo; uno inventado es un 400.
El patrón de una sincronización nocturna u horaria es el mismo: recuerda el updatedAt más nuevo que hayas visto y pide todo desde ahí, tal cual lo recibiste. Los webhooks te avisan en el momento en que algo cambia; este endpoint es cómo te pones al día tras una caída. updatedAt también se mueve con cambios que no mandan evento (la clase pasa a upcoming o in_progress, o se envía un recordatorio), así que una fila puede aparecer en una sincronización sin nada visible distinto.
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_…"Responder una solicitud
Las acciones están abiertas a la escuela sobre los instructores que gestiona y sobre su propio asiento de clases sin asignar; sobre un instructor que se gestiona a sí mismo la respuesta es 403 not_managed_by_school. Cada acción es una actualización condicional: si la reserva ya no está en el estado que la acción espera, la respuesta es 409 booking_state con el estado actual en details, y no cambia nada. Repetir una acción es por tanto seguro, y la forma de confirmar qué pasó es GET /bookings/{id}.
| Endpoint | Regla |
|---|---|
POST /bookings/{id}/accept | Desde pendiente; la clase no debe haber empezado. La franja se vuelve a comprobar: una reserva viva o una clase grupal que la solape (con el margen del instructor, también solicitudes pagadas), o un bloqueo externo en sus horas, es un 409 slot_conflict. En el asiento de clases sin asignar no se vuelve a comprobar nada: sus plazas se contaron al pagar |
POST /bookings/{id}/reject | Desde pendiente o change_proposed; reembolsa íntegro al cliente |
POST /bookings/{id}/propose-time | date, startTime, message opcional; solo desde pendiente, una propuesta cada vez. El cliente acepta, rechaza o la deja caducar; la franja original sigue ocupada y la propuesta no |
POST /bookings/{id}/cancel | Una clase confirmada, próxima o en curso que no haya acabado; reembolsa íntegro al cliente |
POST /bookings/{id}/assign | instructorId en el cuerpo; solo una clase con unassigned: true, desde pendiente, confirmada o próxima. El instructor debe estar en tu plantilla, activo y verificado, enseñar esa disciplina en esa estación, cobrar en esa moneda, admitir esos alumnos y esa duración, y estar libre a esa hora; sus horas publicadas no se comprueban, eso es cosa tuya. El plazo no se mueve; a partir de ahí responde quien gestione al instructor. Se avisa al cliente y al instructor, y sigue un booking.updated con reason instructor_assigned |
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?" }'Dos cosas no están aquí a propósito: marcar una ausencia y dar la clase por completada. Las dos mueven dinero por lo que pasó en la pista, y las dos siguen siendo un acto humano en el panel.
Webhooks
Registra una URL https y hacemos POST de cada evento de reserva a ella: el mismo JSON que devuelven los endpoints de reservas, envuelto en un evento. Hasta 10 endpoints por escuela, cada uno suscrito a algunos eventos o a todos con "*". Regístralos desde el panel o por la API; el panel enseña el registro de entregas en ambos casos. La URL debe ser https, con un nombre de host que tenga punto, sin credenciales ni fragmento, y no una dirección privada ni la nuestra.
| Endpoint | Hace |
|---|---|
GET /webhooks | Tus endpoints, con sus secretos |
POST /webhooks | url, events (1–20, o ["*"]), description opcional; 201 con el secreto |
PATCH /webhooks/{id} | Cualquiera de url, events, description, active; volver a encenderlo pone a cero la cuenta de fallos |
DELETE /webhooks/{id} | Lo elimina con su registro de entregas |
POST /webhooks/{id}/rotate-secret | Un secreto nuevo; el viejo muere al instante |
POST /webhooks/{id}/test | Un ping, en cola como una entrega más e intentado ahora; informa de cómo fue ese intento, y si falla se reintenta como cualquier otra |
GET /webhooks/{id}/deliveries | Las más recientes primero, sin cuerpos: 50 por defecto, hasta 100 con ?limit= |
POST /webhooks/{id}/deliveries/{deliveryId}/replay | El mismo evento (mismo id, mismo contenido) otra vez, como una entrega nueva y propia, intentada ahora y reintentada como cualquier otra |
curl -X POST https://alpinepal.com/api/v1/webhooks \
-H "Authorization: Bearer ap_live_…" \
-H "Content-Type: application/json" \
-d '{ "url": "https://crm.example.com/alpinepal", "events": ["*"] }'
# → 201 { "id": "wh_…", "secret": "whsec_…", … } — keep the secret{
"id": "evt_4b2c9e17a0d3",
"type": "booking.confirmed",
"apiVersion": "v1",
"createdAt": "2027-01-04T10:02:17.000Z",
"schoolId": "sch_2a71",
"sandbox": false,
"data": {
"booking": { "id": "b_3e8c1f", "bookingStatus": "confirmed", "acceptedVia": "school", "…": "…" },
"previousStatus": "pending_instructor_approval"
}
}POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: AlpinePal-Webhooks/1.0
X-AlpinePal-Event: booking.confirmed
X-AlpinePal-Event-Id: evt_4b2c9e17a0d3
X-AlpinePal-Delivery: whd_00dd9d2f0cdd
X-AlpinePal-Signature: t=1799999999,v1=5f1c…e2a9Eventos. booking.requested: llegó una solicitud pagada. booking.confirmed: aceptada por el instructor o la escuela, confirmada automáticamente, o el cliente aceptó una hora propuesta; acceptedVia dice cuál, y previousStatus es change_proposed en el último caso. booking.time_proposed: se propuso otra hora al cliente. booking.rejected. booking.cancelled: cancelledBy es customer, instructor, school o platform (se borró la cuenta detrás de la reserva, o una clase grupal se canceló por no llegar a su mínimo). booking.expired: nadie respondió a tiempo. booking.completed: la clase acabó, ausencias incluidas. booking.updated: cualquier otra cosa que merezca aviso, con data.reason: proposal_declined, meeting_point_proposed, meeting_point_confirmed, dispute_opened, dispute_resolved, refund_settled (el dinero ya volvió al cliente), refund_issued (por nosotros) o instructor_assigned (previousInstructorId nombra el asiento en que estaba la clase). ping es el evento de prueba, con data vacío; no es un tipo al que suscribirse. No hay evento para upcoming, in_progress, ni cambios de plantilla o de managedBy.
Qué esperar. La entrega es al menos una vez: una respuesta que llega después de nuestros 10 segundos de espera cuenta como fallo y el evento vuelve, así que trata el id del evento como clave e ignora las repeticiones. El orden no está garantizado, los reintentos y el barrido lo alteran, así que ordena por data.booking.updatedAt. No toda reserva empieza con booking.requested: la de un instructor con la confirmación inmediata activada (autoAcceptBookings) llega directamente como booking.confirmed, con previousStatus pending_instructor_approval. Así que da de alta la reserva en tu sistema con el primer evento que veas de su id, sea del tipo que sea. El payload se congela al crear el evento; un reintento trae lo que era cierto entonces, y un evento posterior, el estado más nuevo. Un evento puede perderse si falla nuestro intento de registrarlo; GET /bookings?updatedSince es la red de seguridad. Todo evento lleva sandbox: true o false. El registro guarda las entregas 30 días; un reenvío manda otra vez una pasada.
Verificar las entregas
Cada entrega va firmada con el secreto del endpoint: la cabecera X-AlpinePal-Signature lleva una marca de tiempo y un HMAC-SHA256 de la marca, un punto y el cuerpo en bruto. Verifícala antes de fiarte de nada, compara en tiempo constante y rechaza marcas a más de 5 minutos de tu reloj. El secreto se devuelve al crear el endpoint y en GET /webhooks, y se puede rotar desde el panel o con POST /webhooks/{id}/rotate-secret; el viejo muere al instante.
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"));
}Responde con cualquier 2xx en 10 segundos. Cualquier otra cosa, un 3xx incluido porque no seguimos redirecciones, es un fallo: reintentamos a los 5 minutos, 15 minutos, 1 hora, 4 horas, 12 horas y 24 horas, unas 42 horas en total, cada vez con el mismo id de evento para que puedas deduplicar. Un endpoint solo se apaga, y se avisa a los administradores de la escuela, cuando una entrega ha agotado todos sus reintentos sin que llegue ninguna otra entrega entretanto; un corte breve no lo apaga, aunque haya muchos eventos en vuelo. Mientras está apagado no se le encolan eventos nuevos (GET /bookings?updatedSince te pone al día), y una entrega pendiente cuyo intento vence en ese tiempo pasa a fallida sin enviarse; los reintentos que vencen después de volver a encenderlo salen con normalidad. Reenvía las que hayan fallado. POST /webhooks/{id}/test encola un ping como una entrega más, lo intenta ahora mismo e informa de cómo fue ese intento: un ping fallido se reintenta igual, cuenta como cualquier entrega para apagar el endpoint y, con el endpoint apagado, pasa a fallido sin enviarse. GET /webhooks/{id}/deliveries enseña las entregas más recientes primero, 50 por defecto y hasta 100 con ?limit=, con su cuenta de intentos y sin sus cuerpos: cada evento tiene una entrega por endpoint, más una por cada reenvío, con el mismo id de evento.
Entorno de pruebas
Toda escuela tiene una gemela de pruebas desde que su titular crea una clave de prueba en el panel (Conexiones → Entorno de pruebas): una copia de la escuela con dos instructores ficticios, sandbox_ia_…, que responde las solicitudes a mano, y sandbox_ib_…, con la confirmación inmediata activada, y el asiento de clases sin asignar, cada uno con un calendario de 09:00 a 17:00 todos los días en las estaciones de la escuela, de 1 a 6 horas por clase, hasta 6 alumnos, sin margen ni antelación. Las claves de prueba empiezan por ap_test_ y solo llegan a la gemela; las reales nunca. Todo lo demás es la misma API: listar los instructores de la gemela, escribir sus calendarios, registrar webhooks, responder solicitudes, asignar clases.
Lo que un cliente hace en la web lo hace por ti POST /sandbox/bookings: crea una reserva pagada para el instructor que indiques, con las mismas comprobaciones que una real, disponibilidad publicada y sin solapes, y sin dinero de por medio. A partir de ahí corre la maquinaria real, y las acciones de abajo hacen de cliente y de reloj, así que todos los eventos se ven en minutos. Ningún correo sale del entorno de pruebas y nada de él es público nunca; cada evento que envía lleva sandbox: true, igual que GET /me. POST /sandbox/reset lo vacía para que una batería de pruebas empiece limpia.
| Endpoint | Notas |
|---|---|
POST /sandbox/bookings | instructorId (un instructor de la gemela o su asiento), date, startTime, durationHours (dentro de los límites del instructor); opcionales numberOfStudents, resortId, discipline, language, customerMessage. 201 con la reserva; 403 not_sandbox con una clave real |
POST /sandbox/classes | Publica una clase grupal en un instructor de la gemela o en su asiento, con las reglas de producción; sus plazas se compran con POST /sandbox/bookings y groupClassId |
POST /sandbox/bookings/{id}/accept-proposal · decline-proposal | La respuesta del cliente a una hora propuesta |
POST /sandbox/bookings/{id}/cancel | El cliente cancela, con las mismas reglas que en la web |
POST /sandbox/bookings/{id}/complete | Lo que hace la confirmación del cliente, o el barrido 48 horas después de la clase: completed y tu parte liberada |
POST /sandbox/bookings/{id}/expire | Lo que hace el barrido pasado el plazo: expired y reembolsada |
POST /sandbox/reset | Borra las reservas, chats, apuntes y clases grupales de la gemela; las claves, los webhooks, los calendarios y el registro de entregas se quedan |
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 }'Sigue fuera del alcance del entorno de pruebas: disputas, ausencias, un reembolso emitido por nosotros, una cancelación con cancelledBy platform y el punto de encuentro (sus eventos nacen en la web). Las reservas del entorno de pruebas llevan studentLevels vacío y un cliente llamado Sandbox C. que usa la web en inglés.
OpenAPI
La descripción legible por máquina vive en https://alpinepal.com/api/v1/openapi.json: cada ruta, método, parámetro y esquema de arriba, en OpenAPI 3.1. Apunta un generador para tener un cliente tipado, o impórtala en tu herramienta HTTP para navegarla. Se sirve sin autenticación y se cachea una hora.
Versiones y cambios
Esto es la v1. Dentro de ella solo añadimos: campos nuevos, parámetros opcionales nuevos, tipos de evento nuevos, endpoints nuevos. Tu integración debe ignorar los campos y eventos que no conozca. Cualquier cosa que rompiera un cliente existente iría a una v2 con otra URL base, con la v1 en marcha mientras migras.
2026-09 — v1: escuela, estaciones, instructores, disponibilidad y vista expandida, bloqueos externos, reservas con paginación por cursor, accept / reject / propose-time / cancel, webhooks firmados con reintentos.
2026-09-18 — hourlyPrice y extraStudentPrice en PATCH /instructors/{id}; el asiento de clases sin asignar en /unassigned y POST /bookings/{id}/assign; reason y previousInstructorId en booking.updated; el entorno de pruebas, las claves ap_test_ y sandbox en cada evento.
2026-09-18 — revisión: booking_state trae el estado actual; updatedSince acepta cualquier instante ISO; el reembolso de una reserva sin pago también emite refund_settled; un booking.completed por clase; assign comprueba los límites del instructor; meeting_point_proposed; POST /webhooks/{id}/deliveries/{deliveryId}/replay; las acciones de cliente y reloj del entorno de pruebas; esta página reescrita sobre lo que hace el código.
2026-09-20 — PATCH /instructors/{id} escribe sus campos juntos o ninguno; un bloqueo con 201 gana ahora a un pago que estuviera en curso sobre la misma hora (el cliente recibe el reembolso y tú booking.expired).
2026-09-22 — clases grupales: GroupClass y sus ocurrencias en /instructors/{id}/classes y /unassigned/classes (solo lectura), groupClassId en toda reserva, cancelledBy platform cuando una clase no llega a su mínimo, POST /sandbox/classes y groupClassId en POST /sandbox/bookings.
2026-09-23 — precios grupales en el instructor: groupClassesEnabled y groupPricePerStudent en Instructor y en PATCH /instructors/{id}; un precio por plaza nuevo re-estampa las clases activas; el mínimo por plaza es 10 EUR / 10 USD.
2026-09-24 — documentado: la reserva de un instructor con confirmación inmediata llega solo como booking.confirmed, nunca como booking.requested; da de alta las reservas con el primer evento de su id. Sin cambio de comportamiento.
2026-09-27 — documentado: proponer un punto de encuentro sí manda booking.updated, con reason meeting_point_proposed; la sección de sincronización decía que no mandaba evento. Sin cambio de comportamiento.
2026-09-27 — los bloques cuya fecha ha pasado se descartan: PUT /instructors/{id}/availability ya no los guarda ni los devuelve, y un bloqueo externo sale de GET /blocks cuando pasa su fecha y deja de contar para el límite de bloqueos externos, que ahora cuenta de hoy en adelante. Hasta ahora unos y otros se acumulaban hasta que el instructor llegaba al límite.
2026-10-05 — instructores que la escuela añade sin cuenta: unclaimed y previousId en Instructor. Cuando la persona reclama el perfil, su id cambia; todas las rutas y el filtro instructorId siguen aceptando el antiguo.
2026-10-06 — plazas en el asiento sin asignar: capacity en sus bloques available (varias clases a la vez, contadas por estación), places en cada tramo libre de su vista expandida, y sus bloqueos externos ocupan una plaza cada uno en vez de cerrar las horas. Sus clases grupales ya solo chocan en su propia estación. Los calendarios de los instructores no cambian: capacity en uno de ellos es un 422 invalid_availability.
2026-10-06 — el asiento sin asignar da sus clases grupales a la vez que sus plazas particulares y que otras clases: una clase ya no se queda las horas (siguen en free, con sus plazas) y un bloqueo externo ya no choca con ella. GroupClass gana audience (all, children, adults) con minAge y maxAge opcionales; una plaza de una clase de niños solo se vende a alumnos reservados como niños, y la de una de adultos solo a adultos. POST /sandbox/classes acepta los mismos campos.
2026-10-06 — los bloques semanales y las series de clases grupales pueden empezar más tarde: since, su primera fecha (incluida), permite a una escuela cargar la temporada antes de que abra sin vender ya sus horas. PUT /instructors/{id}/availability lo acepta en los bloques con dayOfWeek (un since posterior al until del bloque es un 422 invalid_availability), el GET lo devuelve, GroupClass lo lleva y POST /sandbox/classes lo acepta. Sin since, una serie aplica desde ya, como antes.
2026-10-08 — un endpoint solo se apaga cuando una entrega ha agotado todos sus reintentos sin que llegue ninguna otra entretanto. Antes se apagaba con diez intentos fallidos seguidos contando todas las entregas, así que un corte breve con muchos eventos en vuelo podía apagarlo y perder sus reintentos.
2026-10-16 — el día de clases va de 07:00 a 21:00, hora de la estación (antes, de 06:00 a 23:00): los bloques externos, las horas propuestas y las reservas y clases grupales del sandbox fuera de él dan 422 outside_ski_day, y también las horas available nuevas fuera de él en PUT /instructors/{id}/availability y PUT /unassigned/availability. Las horas ya guardadas se conservan, los bloques blocked y vacation pueden cubrir cualquier hora y una clase grupal publicada antes mantiene su horario.
2026-10-16 — formatos fijos en el asiento sin asignar (API 1.8.0): GET /unassigned gana saleMode y lessonFormats (formatos y temporadas con un precio por formato, solo lectura), Booking gana format (null en las clases vendidas por horas), POST /sandbox/bookings acepta formatId, y propose-time en una clase de formato mantiene su horario y su precio (409 format_fixed_time, format_price_differs). Nada cambia para un asiento que vende por horas.
2026-10-16 — sin interruptor para las clases sin asignar (API 1.8.1): active en GET /unassigned dice ahora si el asiento está a la venta, que lo está en cuanto su precio por hora supera el mínimo (o sus formatos y temporadas están completos), y PATCH lo ignora; una escuela que no quiera estas clases no abre horas para ellas.
2026-10-20 — precios por varias horas seguidas (API 1.10.0): Instructor y UnassignedLessons añaden `durationPrices`, precios cerrados opcionales para una clase entera de 2, 3 o 4 horas para un alumno. El de 4 horas también vale para clases más largas, a su tarifa por hora; una duración sin precio propio toma la tarifa de la duración más larga, entre las más cortas, que tenga precio; y los alumnos de más se siguen cobrando por hora. Sin ellos, todo sigue por horas. PATCH /instructors/{id} y PATCH /unassigned los aceptan (null los borra); unos precios más caros por hora que una clase más corta, o más baratos en total que una más corta, responden 422 `invalid_duration_prices`.
2026-10-19 — packs de varios días (API 1.9.0): un cliente puede comprar en un solo pago varios días del curso de una clase grupal (GroupClass.packs, con precio por persona según su tramo de edad, y courseId) o varios días seguidos de una franja fija (UnassignedLessons: packDays y, por temporada, minDays y packPrices). Cada día es una reserva propia con su parte del precio y un `pack` que dice de qué pack y qué día es; studentLevels puede llevar el `band` de cada alumno.
2026-11-02 — condiciones por estación (API 1.22.0): UnassignedLessons añade `resortTerms`: por id de estación, cada estación que vende distinto, con todos los precios y condiciones del asiento tal como se venden allí (los mismos campos que el nivel superior: `hourlyPrice`, `durationPrices`, `seasonPrices`, `saleMode`, `lessonFormats` y los demás). Es `{}` cuando el asiento tiene unas solas condiciones, y una estación que no aparece vende con los valores del nivel superior. PATCH /unassigned acepta `resortId` para escribir las condiciones de esa estación y dejar las demás como están; una estación donde la escuela no trabaja responde 422 `unknown_resort`. Sin `resortId` escribe el nivel superior, que una estación de `resortTerms` no lee. `active` es true mientras el asiento venda en una estación al menos. Es solo un añadido: un asiento sin condiciones por estación se lee y vende como antes.
2026-10-29 — horas en que un instructor está ocupado en su propio Google Calendar (API 1.21.1): un instructor puede hacer que las horas que su Google Calendar marca como ocupadas se cierren en Alpine Pal. No son parte del horario: GET /instructors/{id}/availability no las lista y un PUT ni las ve ni las cambia, mientras que /availability/expanded deja esas horas fuera de `free`, como todo lo que no está a la venta. Nada más cambia.
2026-10-27 — precios por temporada y alumnos incluidos (API 1.21.0): Instructor y UnassignedLessons añaden `seasonPrices` e `includedStudents`, los dos modificables con PATCH /instructors/{id} y PATCH /unassigned. `seasonPrices` lista hasta 4 temporadas, cada una con un `id`, un `name` para el panel (null si no tiene), de 1 a 10 rangos de fechas (`from` y `to`, ambos incluidos) y su propio `hourlyPrice` y `durationPrices`: una clase en una fecha dentro de una temporada se cobra con los precios de esa temporada, y una fecha fuera de todas con los propios del instructor o del asiento, como hasta ahora. Los rangos nunca se solapan, ni dentro de una temporada ni entre temporadas, y los precios de cada temporada siguen las mismas reglas que los propios del instructor o del asiento. Un PATCH envía la lista entera (null o una lista vacía la borra): conserva los ids que devolvió un GET y no envíes id en una temporada nueva. GET devuelve null cuando no hay ninguna. `includedStudents` (de 1 a 20, 1 por defecto) es cuántos alumnos cubre el precio: `extraStudentPrice` solo se cobra por cada alumno que pase de esa cifra. Unas temporadas que incumplen una regla responden 422 `invalid_season_prices`, y un número fuera de su rango 422 `invalid_included_students`. Es solo un añadido: sin temporadas y con un alumno incluido, todos los precios quedan como antes.
2026-10-26 — un precio de 0 o menos se rechaza (API 1.20.4): en `durationPrices`, una duración que el instructor o el asiento vende con un precio de 0 o negativo es ahora un 422 `invalid_duration_prices`. Hasta ahora se descartaba sin decir nada y la petición respondía 200, así que esa duración iba por horas. Para no darle precio propio, no envíes su clave (o envía null en todo el campo).
2026-10-26 — correcciones de la documentación (API 1.20.3): esta página y el spec dicen ahora lo que la API ya hacía. Aceptar una solicitud vuelve a comprobar la franja (una reserva viva, una clase grupal o un bloqueo externo sobre ella es un 409 `slot_conflict`; en el asiento de clases sin asignar no se comprueba nada). Las claves solo dejan de funcionar, con un 403 `school_not_active`, si la escuela está cerrada o no aprobada; una escuela suspendida conserva su API. Con un endpoint apagado, una entrega pendiente pasa a fallida solo cuando vence su intento; los reintentos que vencen después de encenderlo se envían. Un `extraStudentPer` desconocido es un 400 `validation_error`, no el 422 `invalid_price` que decía la entrada de 1.13.0; `invalid_price` es un `extraStudentPrice` negativo. `platformFee` es el 10 %, o la comisión más alta que una escuela haya pactado por escrito, según se vendió la reserva. La reserva de ejemplo añade `unassigned`, `groupClassId`, `format`, `pack`, `equipment` y `freeCancellationHours`, y su punto de encuentro dice `not_agreed`. La tabla de errores lista todos los códigos, entre ellos `managed_by_school`, los de packs, formatos y clases grupales e `inside_notice_window` (con `hours`). Se listan 50 entregas por defecto y hasta 100 con `limit`, y un reenvío es una entrega propia con el mismo id de evento. Un ping es una entrega normal: se reintenta si falla, cuenta para apagar el endpoint y, con el endpoint apagado, pasa a fallido sin enviarse. Sin cambio de comportamiento.
2026-10-26 — dos correcciones (API 1.20.2): PATCH /instructors/{id} juzga ahora `durationPrices` con las duraciones de clase del propio instructor, como el panel, así que ya no rechaza los precios de quien empieza en 2 horas por ser más caros que una clase de 1 hora que no vende. Y con una clave de pruebas, GET y PATCH /unassigned describen los formatos que vende la sandbox (los de la escuela real, que POST /sandbox/bookings ya usaba): `saleMode` y `lessonFormats` ya no dicen `hours` y vacío en un asiento que vende formatos.
2026-10-26 — asignar un día de un pack (API 1.20.1): POST /bookings/{id}/assign responde 409 `pack_pending` para un día de un pack de varios días que aún espera la respuesta de la escuela cuando el instructor gestiona sus propias reservas (`managedBy: instructor`). Un pack se responde entero, y ese instructor no podría responder la parte de la escuela. Acepta antes el pack, o asigna el día a un instructor que gestione la escuela; los días ya confirmados se asignan uno a uno como hasta ahora.
2026-10-25 — cerrar una franja en un instructor (API 1.20.0): un bloque blocked con `formatId` se acepta ahora en el calendario de un instructor que vende por franjas, como en el asiento sin asignar desde la 1.15.0, y cierra solo esa franja en su fecha o sus días de la semana; las demás franjas de esas horas siguen a la venta. Un instructor que sigue las horas de la escuela (`followsSchoolHours`) recibe ahora también los cierres de una franja del asiento. Las reservas ya respetaban estos cierres en cualquier calendario.
2026-10-25 — las horas abiertas de la escuela para sus instructores (API 1.19.0): Instructor gana `followsSchoolHours`. Un instructor que entra con la invitación de la escuela, o que la escuela da de alta sin cuenta, empieza ahora con `managedBy: school` y esto activado: su calendario es una copia de las horas abiertas del asiento sin asignar, en las estaciones donde da clase y con una clase a la vez, y cada cambio en el horario del asiento le llega. La escuela lo cambia en el panel (Clases privadas). Un PUT /instructors/{id}/availability le da horas propias desde ese momento: `followsSchoolHours` pasa a false. Los instructores que ya están en la plantilla no cambian.
2026-10-23 — instructores que venden por franjas (API 1.18.0): Instructor gana `saleMode`, `hours` o `formats`. La escuela ya puede vender a cualquiera de sus instructores en franjas fijas con precio por temporada, como su asiento sin asignar (se pone en el panel, en Clases privadas); sus reservas privadas llevan entonces `format`, como las del asiento, y las tarifas por hora no venden nada. Booking.format ya no es solo del asiento. El 422 `format_required` dice ahora «Choose one of the lesson formats». Es solo un añadido: cada instructor vende por horas hasta que la escuela lo cambie.
2026-10-23 — francos suizos (API 1.17.0): School.currency puede ser `CHF`. Las escuelas suizas y sus instructores ponen precios y cobran en francos, así que todos sus importes (precios, subtotal, pagos) van en CHF, con un mínimo por hora propio.
2026-10-23 — política de cancelación por escuela (API 1.16.0): Booking gana `freeCancellationHours`, también en data.booking de cada evento de reserva: hasta cuántas horas antes del inicio puede cancelar el cliente con reembolso íntegro, la ventana con la que se vendió la reserva — 48, 72, 168, 336, 672 o 1008. GET /me devuelve el `freeCancellationHours` actual de la escuela. La escuela fija su política en el panel (Perfil → Política de cancelación); vale para todo lo que vende la escuela (las clases privadas y grupales de sus instructores y su asiento sin asignar), y una reserva conserva la ventana con la que se hizo, cambie como cambie la política. Solo lectura en la API. Es solo un añadido: las reservas anteriores llevan 48.
2026-10-21 — cerrar una sola franja (API 1.15.0): un bloque `blocked` del asiento sin asignar puede llevar `formatId` y entonces cierra solo esa franja en su fecha o sus días de la semana. Las plazas de esas horas siguen para las demás franjas; cerrar las horas cerraba todas las franjas que compartían un minuto con ellas. Cerrar el día entero sigue siendo cerrar sus horas. El GET devuelve el campo; un PUT que no lo incluye quita el cierre.
2026-10-21 — franjas para Westendorf (API 1.14.0): el inicio y el fin de una franja van ahora en cuartos de hora (08:45, 13:15), no solo en punto o y media. El `lessonFormats` de UnassignedLessons añade `extraStudentPrice`: lo que suma cada alumno a partir del segundo a una clase por franjas, una vez por clase (por día de un pack), aparte del extra por horas del asiento; 0 deja el precio para todo el grupo. Se pone en el panel.
2026-10-20 — extra por alumno por clase (API 1.13.0): Instructor y UnassignedLessons añaden `extraStudentPer`, `hour` (como hasta ahora) o `lesson`: si `extraStudentPrice` se cobra por cada hora de la clase o una sola vez por la clase entera. Se cambia con PATCH /instructors/{id} y PATCH /unassigned; otro valor es 422 `invalid_price`.
2026-10-20 — condiciones de Courchevel y Westendorf (API 1.12.0): cada franja de una temporada tiene su propio mínimo de días seguidos (`prices[].minDays`; el `minDays` de la temporada pasa a ser el menor de ellos y queda obsoleto), así que las mañanas pueden venderse desde cinco días y las tardes sueltas en las mismas semanas. UnassignedLessons añade `minBookingNoticeHours` (ahora también dos y tres días) y `disciplines`, los dos ajustables con PATCH /unassigned. Un pack de franjas en un asiento sin confirmación inmediata ahora espera a la escuela, como cualquier otra solicitud; aceptar o rechazar cualquiera de sus días responde el pack entero. El asiento añade además `minimumLessonDuration` y `maximumLessonDuration` (horas enteras, PATCH /unassigned; 422 `invalid_lesson_length`): Snowsports Westendorf vende dos y tres horas, nunca una. Si ninguna clase que vende va por horas, `hourlyPrice` puede ser 0 y el asiento sigue a la venta. `durationPrices` admite una clave para cada duración de dos a seis horas; una duración sin precio va a la tarifa por hora de la última anterior con precio. Y `singleLevelBookings` (también PATCH /unassigned): encendido, se rechaza una clase particular cuyos alumnos mezclen niveles, o adultos y niños, y el cliente hace una reserva por grupo; Courchevel pone un monitor por nivel y por edad.
2026-10-20 — alquiler de material (API 1.11.0): una escuela puede alquilar material con sus clases, a un precio por día por artículo que fija en su panel. Booking gana `equipment` (null si no se alquiló nada): la parte de esta reserva (amount, ya dentro de subtotal), el alquiler entero, sus días y, por alumno, los artículos con su precio y las tallas (talla de pie EU, altura en cm). La comisión y netAmount lo cuentan como la clase, y un reembolso lo devuelve con la clase.