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 API 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 cinco 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 dejan de funcionar si la escuela se desactiva o se cierra. 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 cinco 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, veinte en diez 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 diez en diez 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. Cuatro códigos llevan details: validation_error lista los campos que fallan como [{path, message}] (diez 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}.
| 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, 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, slot_conflict, booking_conflict, external_ref_exists, concurrent_update, not_unassigned, not_on_sale, cancel_window_closed, lesson_started, lesson_ended | 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, price_below_minimum, invalid_price, no_resorts, invalid_max_students, currency_mismatch, invalid_duration, too_many_students, instructor_not_verified, wrong_discipline | 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_…); 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 × (numberOfStudents − 1)) × durationHours, redondeado a la moneda; es lo que paga el cliente. platformFee es nuestro 10 %. netAmount es el 90 % 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) y extraStudentPrice es 0 o más.
| 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) |
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 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} | hourlyPrice y extraStudentPrice de cualquiera de la plantilla (el precio por hora tiene un mínimo por moneda); autoAcceptBookings solo en un instructor que gestiona la escuela. Los campos se aplican uno tras otro: si el segundo se rechaza, el primero se queda |
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.
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 until como última fecha en que aplica) o a una fecha concreta: exactamente una de las dos. 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 o los dos anclajes a la vez son un 422 invalid_availability.
{
"blocks": [
{ "id": "ab_1", "resortId": "r_baqueira", "type": "available",
"dayOfWeek": 6, "startTime": "09:00", "endTime": "13:00", "until": "2027-04-15" },
{ "id": "ab_2", "resortId": "r_baqueira", "type": "vacation",
"date": "2026-12-25", "startTime": "06:00", "endTime": "23:00" },
{ "id": "xb_7c1d", "resortId": "r_baqueira", "type": "blocked",
"date": "2027-01-10", "startTime": "10:00", "endTime": "12:00",
"source": "api", "externalRef": "lueira:LS-4471" }
]
}GET …/availability/expanded 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.
| 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); 06:00–23: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 no vuelve a mirar los bloqueos: si la solicitud llegó antes que tu bloqueo, la escuela debe rechazarla o proponer otra hora. Un bloqueo y un pago que compiten por la misma hora pueden salir bien los dos: cuando tu bloqueo reciba 201, vuelve a listar las reservas del instructor si la hora importa.
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_…). Una clase por franja horaria.
GET /unassigned enseña los ajustes; PATCH /unassigned fija cualquiera de active, hourlyPrice, extraStudentPrice, autoAcceptBookings y maximumStudents. El primer PATCH crea el asiento, y hasta entonces sus endpoints de calendario y bloqueos dan 404. Encenderlo exige un precio por encima del mínimo en la misma llamada o en una anterior y que la escuela haya elegido sus estaciones en el panel; apagarlo siempre se permite. El calendario del asiento funciona exactamente como el de un instructor, en las estaciones de la 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 |
GET, POST /unassigned/blocks · DELETE /unassigned/blocks/{blockId} | Bloqueos externos sobre el asiento: la franja deja de venderse como clase sin asignar |
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"]
}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.
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 48 horas antes del inicio.
| 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",
"resortId": "r_baqueira",
"resortTimeZone": "Europe/Madrid",
"date": "2027-01-12",
"startTime": "10:00",
"endTime": "12:00",
"durationHours": 2,
"discipline": "ski",
"language": "en",
"numberOfStudents": 2,
"studentLevels": [{ "level": "beginner", "age": "adult" }, { "level": "beginner", "age": "adult" }],
"customerMessage": "Two adults, first time on skis.",
"customer": { "name": "Santi P.", "language": "es" },
"meetingPoint": { "status": "pending" },
"subtotal": 130,
"platformFee": 13,
"netAmount": 117,
"currency": "EUR",
"bookingStatus": "pending_instructor_approval",
"paymentStatus": "succeeded",
"acceptedVia": null,
"instructorResponseDeadline": "2027-01-06T10:00:00.000Z",
"proposed": null,
"createdAt": "2027-01-04T09:12:41.000Z",
"updatedAt": "2027-01-04T09:13:02.000Z",
"paidAt": "2027-01-04T09:13:02.000Z",
"completedAt": null,
"refundedAt": null,
"url": "https://alpinepal.com/en/booking/b_3e8c1f"
}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, se envía un recordatorio, se propone un punto de encuentro), 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; cualquier reserva viva que solape la franja (con su margen, también solicitudes pagadas) es un 409 slot_conflict |
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 diez 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, entregado ahora; informa de cómo fue |
GET /webhooks/{id}/deliveries | Las últimas cien entregas, sin cuerpos |
POST /webhooks/{id}/deliveries/{deliveryId}/replay | El mismo evento otra vez como entrega nueva, enviada ahora |
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). 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 diez 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. 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 treinta 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 cinco 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 diez 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 que falla diez intentos seguidos se apaga y se avisa a los gestores de la escuela; sus entregas pendientes pasan a fallidas y no se envían al volver a encenderlo: reenvía las que necesites. POST /webhooks/{id}/test manda un ping ahora mismo e informa de cómo fue, y GET /webhooks/{id}/deliveries enseña las últimas cien entregas (una por evento y endpoint, con su cuenta de intentos) sin sus cuerpos.
Entorno de pruebas
Toda escuela tiene una gemela de pruebas desde que su titular crea una clave de prueba en el panel (Conecta tu sistema de reservas → 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/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 y apuntes 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.