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

Página: https://alpinepal.com/es/api-para-escuelas-de-esqui

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**

```bash
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 |

**409 Conflict**

```json
{
  "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.

**GET /instructors/{id}/availability**

```json
{
  "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**

```json
{
  "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í |

**POST /instructors/{id}/blocks**

```bash
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 |

**GET /unassigned**

```json
{
  "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 |

**A booking**

```json
{
  "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**

```bash
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 |

**POST /bookings/{id}/propose-time**

```bash
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 |

**POST /webhooks**

```bash
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**

```json
{
  "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**

```http
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)**

```js
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 |

**POST /sandbox/bookings**

```bash
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.
