Saltar al contenido
Para escuelas de esquí

API para escuelas de esquí

Conecta el software con el que ya trabaja tu escuela: disponibilidad hacia dentro, reservas hacia fuera por webhook firmado, y respuestas de vuelta sin abrir el panel.

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.

CabeceraDónde
Authorization: Bearer <clave>Toda petición
Content-Type: application/jsonToda petición con cuerpo
X-Request-IdToda respuesta, errores incluidos; cítalo cuando nos escribas
Cache-Control: no-store, Vary: AuthorizationToda respuesta
WWW-Authenticate: Bearer realm="alpinepal-api"Respuestas 401
AllowRespuestas 405
Retry-After (segundos)Respuestas 429; el cuerpo lo repite como details.retryAfterSeconds
curl
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}.

EstadoCódigosCuándo
400validation_error, invalid_cursor, invalid_jsonLa petición en sí está mal
401unauthenticated, invalid_api_keySin clave, o con una mal formada, desconocida, revocada o del otro entorno
403forbidden, school_not_active, not_managed_by_school, not_sandboxLa clave vale pero esta acción no es tuya; not_sandbox es una clave real en un endpoint del entorno de pruebas
404not_foundNo existe, o no es de tus instructores. Nunca confirmamos que algo exista fuera de tu escuela
405method_not_allowedMétodo equivocado para la ruta; Allow lista los válidos
409booking_state, slot_conflict, booking_conflict, external_ref_exists, concurrent_update, not_unassigned, not_on_sale, cancel_window_closed, lesson_started, lesson_endedUn conflicto de estado real; lee el estado actual y decide de nuevo
413, 415payload_too_large, unsupported_media_typeLos cuerpos deben ser JSON y de menos de 256 KB
422outside_availability, in_the_past, too_far_ahead, outside_ski_day, invalid_availability, api_blocks_read_only, limit_reached, invalid_webhook_url, unknown_event, range_too_long, unknown_resort, price_below_minimum, invalid_price, no_resorts, invalid_max_students, currency_mismatch, invalid_duration, too_many_students, instructor_not_verified, wrong_disciplineBien formada, pero rompe una regla del calendario, de la clase o de la escuela
429rate_limitedMira Retry-After
500internalNuestro; el id de la petición nos ayuda a encontrarlo
409 Conflict
{
  "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.

CampoValores
bookingStatusVer la tabla de Reservas
paymentStatuspending, processing, succeeded, failed, authentication_required, refund_pending, refunded; a la API solo llegan succeeded y los dos estados de reembolso
acceptedViainstructor, school, auto, customer, o null mientras no se acepta
studentLevelsUna entrada por alumno: { level: beginner | intermediate | advanced, age: adult | child }; vacío en el entorno de pruebas
disciplineski o snowboard, el de la clase; las disciplines de un instructor pueden decir también both
language / customer.languageEl idioma de la clase como código de dos letras, y el idioma en que el cliente usa la web (en, es, it)
meetingPointstatus 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

EndpointDevuelve
GET /El descriptor: nombre, versión, enlaces a esta página y al spec (sin clave)
GET /meTu escuela: id, slug, nombre, moneda, país y sandbox (true con una clave de prueba)
GET /resortsToda 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 /instructorsTu 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.

GET /instructors/{id}/availability
{
  "blocks": [
    { "id": "ab_1", "resortId": "r_baqueira", "type": "available",
      "dayOfWeek": 6, "startTime": "09:00", "endTime": "13:00", "until": "2027-04-15" },
    { "id": "ab_2", "resortId": "r_baqueira", "type": "vacation",
      "date": "2026-12-25", "startTime": "06:00", "endTime": "23:00" },
    { "id": "xb_7c1d", "resortId": "r_baqueira", "type": "blocked",
      "date": "2027-01-10", "startTime": "10:00", "endTime": "12:00",
      "source": "api", "externalRef": "lueira:LS-4471" }
  ]
}

GET …/availability/expanded 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

GET /instructors/{id}/availability/expanded?resortId=r_baqueira&from=2027-01-10&to=2027-01-11
{
  "instructorId": "user_2Zk9qX1aR7",
  "resortId": "r_baqueira",
  "timeZone": "Europe/Madrid",
  "days": [
    { "date": "2027-01-10",
      "free": [{ "start": "09:00", "end": "10:00" }, { "start": "12:00", "end": "13:00" }],
      "busy": [{ "start": "10:00", "end": "12:00", "kind": "external" }] },
    { "date": "2027-01-11",
      "free": [{ "start": "09:00", "end": "13:00" }],
      "busy": [] }
  ]
}

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.

EndpointNotas
GET /instructors/{id}/blocksFiltros opcionales from, to, externalRef
POST /instructors/{id}/blocksdate, 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í
POST /instructors/{id}/blocks
curl -X POST https://alpinepal.com/api/v1/instructors/user_2Zk9qX1aR7/blocks \
  -H "Authorization: Bearer ap_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "date": "2027-01-10", "startTime": "10:00", "endTime": "12:00",
        "externalRef": "lueira:LS-4471" }'

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.

EndpointIgual que
GET, PUT /unassigned/availabilityGET, PUT /instructors/{id}/availability
GET /unassigned/availability/expandedLa 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}/assigninstructorId en el cuerpo: pasa una clase sin asignar a alguien de tu plantilla; ver Responder una solicitud
GET /unassigned
{
  "instructorId": "pool_sch_2a71",
  "configured": true,
  "active": true,
  "hourlyPrice": 55,
  "extraStudentPrice": 15,
  "autoAcceptBookings": false,
  "maximumStudents": 6,
  "currency": "EUR",
  "resorts": ["r_baqueira", "r_grandvalira"]
}

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.

bookingStatusSignificado
pending_instructor_approvalPagada, esperando respuesta antes de instructorResponseDeadline
change_proposedSe 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
confirmedAceptada (acceptedVia dice por quién: instructor, school, auto, customer)
upcoming, in_progressConfirmada y cerca de la clase o durante ella
completedHecha; tu parte del dinero queda liberada
rejected_by_instructor, expiredReembolsada íntegra
cancelled_by_customer, cancelled_by_instructorCancelada; reembolso según la política de cancelación
no_show_customer, no_show_instructor, disputed, refundedSe fijan desde el panel o por nosotros
A booking
{
  "id": "b_3e8c1f",
  "reference": "AP-2027-51234",
  "instructorId": "user_2Zk9qX1aR7",
  "resortId": "r_baqueira",
  "resortTimeZone": "Europe/Madrid",
  "date": "2027-01-12",
  "startTime": "10:00",
  "endTime": "12:00",
  "durationHours": 2,
  "discipline": "ski",
  "language": "en",
  "numberOfStudents": 2,
  "studentLevels": [{ "level": "beginner", "age": "adult" }, { "level": "beginner", "age": "adult" }],
  "customerMessage": "Two adults, first time on skis.",
  "customer": { "name": "Santi P.", "language": "es" },
  "meetingPoint": { "status": "pending" },
  "subtotal": 130,
  "platformFee": 13,
  "netAmount": 117,
  "currency": "EUR",
  "bookingStatus": "pending_instructor_approval",
  "paymentStatus": "succeeded",
  "acceptedVia": null,
  "instructorResponseDeadline": "2027-01-06T10:00:00.000Z",
  "proposed": null,
  "createdAt": "2027-01-04T09:12:41.000Z",
  "updatedAt": "2027-01-04T09:13:02.000Z",
  "paidAt": "2027-01-04T09:13:02.000Z",
  "completedAt": null,
  "refundedAt": null,
  "url": "https://alpinepal.com/en/booking/b_3e8c1f"
}

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.

Everything that changed since your last sync
curl "https://alpinepal.com/api/v1/bookings?updatedSince=2027-01-04T09:00:00Z&limit=100" \
  -H "Authorization: Bearer ap_live_…"
# → { "data": [ … ], "nextCursor": "eyJ1IjoiMjAyNy0…" }
# then, while nextCursor is not null:
curl "https://alpinepal.com/api/v1/bookings?updatedSince=2027-01-04T09:00:00Z&limit=100&cursor=eyJ1IjoiMjAyNy0…" \
  -H "Authorization: Bearer ap_live_…"

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}.

EndpointRegla
POST /bookings/{id}/acceptDesde 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}/rejectDesde pendiente o change_proposed; reembolsa íntegro al cliente
POST /bookings/{id}/propose-timedate, 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}/cancelUna clase confirmada, próxima o en curso que no haya acabado; reembolsa íntegro al cliente
POST /bookings/{id}/assigninstructorId 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
POST /bookings/{id}/propose-time
curl -X POST https://alpinepal.com/api/v1/bookings/b_3e8c1f/propose-time \
  -H "Authorization: Bearer ap_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "date": "2027-01-12", "startTime": "14:00",
        "message": "The morning is taken; would 14:00 work?" }'

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.

EndpointHace
GET /webhooksTus endpoints, con sus secretos
POST /webhooksurl, 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-secretUn secreto nuevo; el viejo muere al instante
POST /webhooks/{id}/testUn ping, entregado ahora; informa de cómo fue
GET /webhooks/{id}/deliveriesLas últimas cien entregas, sin cuerpos
POST /webhooks/{id}/deliveries/{deliveryId}/replayEl mismo evento otra vez como entrega nueva, enviada ahora
POST /webhooks
curl -X POST https://alpinepal.com/api/v1/webhooks \
  -H "Authorization: Bearer ap_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://crm.example.com/alpinepal", "events": ["*"] }'
# → 201 { "id": "wh_…", "secret": "whsec_…", … }  — keep the secret
What a delivery carries
{
  "id": "evt_4b2c9e17a0d3",
  "type": "booking.confirmed",
  "apiVersion": "v1",
  "createdAt": "2027-01-04T10:02:17.000Z",
  "schoolId": "sch_2a71",
  "sandbox": false,
  "data": {
    "booking": { "id": "b_3e8c1f", "bookingStatus": "confirmed", "acceptedVia": "school", "…": "…" },
    "previousStatus": "pending_instructor_approval"
  }
}
Request headers on every delivery
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: AlpinePal-Webhooks/1.0
X-AlpinePal-Event: booking.confirmed
X-AlpinePal-Event-Id: evt_4b2c9e17a0d3
X-AlpinePal-Delivery: whd_00dd9d2f0cdd
X-AlpinePal-Signature: t=1799999999,v1=5f1c…e2a9

Eventos. 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.

Verifying the signature (Node)
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody must be the exact bytes received, before any JSON parsing.
export function verifyAlpinePal(secret, rawBody, signatureHeader, toleranceSeconds = 300) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, "hex"));
}

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.

EndpointNotas
POST /sandbox/bookingsinstructorId (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-proposalLa respuesta del cliente a una hora propuesta
POST /sandbox/bookings/{id}/cancelEl cliente cancela, con las mismas reglas que en la web
POST /sandbox/bookings/{id}/completeLo 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}/expireLo que hace el barrido pasado el plazo: expired y reembolsada
POST /sandbox/resetBorra las reservas, chats y apuntes de la gemela; las claves, los webhooks, los calendarios y el registro de entregas se quedan
POST /sandbox/bookings
curl -X POST https://alpinepal.com/api/v1/sandbox/bookings \
  -H "Authorization: Bearer ap_test_…" \
  -H "Content-Type: application/json" \
  -d '{ "instructorId": "sandbox_ia_3f9a", "date": "2027-01-10",
        "startTime": "10:00", "durationHours": 2, "numberOfStudents": 2 }'

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.