Skip to content
For ski schools

API for ski schools

Connect the software your school already runs on: availability in, bookings out by signed webhook, and answers back without opening the panel.

A school account on Alpine Pal gives you a roster of instructors, their calendars and their booking requests in one panel. The API is the same panel for a machine: a channel manager, a CRM or a script of your own can read and write your instructors' availability, learn about every paid booking the moment it happens, and accept, reject or propose another time for it.

It is deliberately narrow. Customers pay on Alpine Pal, so the API does not create bookings; it registers lessons sold elsewhere as blocks that keep the calendar honest. What moves money on the slope — marking a no-show, closing a dispute — stays a human act in the panel.

Getting a key

Keys belong to the school, not to a person, and only the school's owner can create or revoke them, from the Connections tab of the school panel. A key is shown once, at creation; we store a hash and cannot show it again. A school may hold up to 5 live keys, so a new integration gets its own and an old one can be revoked without touching the rest. Live keys start with ap_live_; the sandbox's keys start with ap_test_ and are counted separately.

A key stops working the moment it is revoked, and every key is refused with a 403 school_not_active once the school is closed or is not approved. A school that is only suspended — hidden from the site, its page gone — keeps its keys: its bookings carry on, and the API with them. Keys cannot be managed through the API itself. To rotate one: create the new key, switch your system to it, then revoke the old one — both are valid in between. The panel shows when each key was last used, at 5-minute granularity. Keys are for servers: never ship one inside a browser or a mobile app.

Authentication, headers and bodies

Every request carries the key as a bearer token. The base URL is https://alpinepal.com/api/v1; responses are JSON, never cached, and each one carries an X-Request-Id you can quote to us. Times of day are on the resort's clock as HH:mm, dates are YYYY-MM-DD, and instants are ISO 8601 in UTC with milliseconds.

Request bodies must be JSON with a Content-Type: application/json header (415 otherwise), under 256 KB (413), and are checked strictly: an unknown field is a 400 validation_error listing the offending paths. An empty body reads as an empty object. A method the path does not support is a 405 with an Allow header; a trailing slash is a 404.

HeaderWhere
Authorization: Bearer <key>Every request
Content-Type: application/jsonEvery request with a body
X-Request-IdEvery response, errors included; quote it when writing to us
Cache-Control: no-store, Vary: AuthorizationEvery response
WWW-Authenticate: Bearer realm="alpinepal-api"401 responses
Allow405 responses
Retry-After (seconds)429 responses; the body repeats it as details.retryAfterSeconds
curl
curl https://alpinepal.com/api/v1/me \
  -H "Authorization: Bearer ap_live_…"

There is no CORS: the API is server to server, and a request from a browser will fail. That is on purpose, since a key in a browser is a key anybody can read.

Rate limits

Per key, in fixed one-minute windows: 600 reads (GET) and 120 writes (everything else, DELETE included) per minute. Beyond that the answer is 429 rate_limited with a Retry-After header, in seconds. A test key has its own allowance. Failed authentication is limited per IP address, 20 in 10 minutes; past that the answer to any further guess is 429, before the key is even looked up. Webhook tests and replays share a separate allowance of 10 in 10 minutes per key, since each one is a request to somebody else's server.

Errors

Every error is the same envelope: an HTTP status, a stable code to branch on, a message for a human and the request id. Five codes carry details: a validation_error lists the offending fields as [{path, message}] (at most 10); booking_state says which state the booking is in now ({bookingStatus}); booking_conflict names the hours in the way and, when that booking is paid, its id and reference; rate_limited repeats the wait as {retryAfterSeconds}; inside_notice_window gives the instructor's notice in hours ({hours}).

StatusCodesWhen
400validation_error, invalid_cursor, invalid_jsonThe request itself is wrong
401unauthenticated, invalid_api_keyNo key, or a malformed, unknown, revoked or wrong-environment one
403forbidden, school_not_active, managed_by_school, not_managed_by_school, not_sandboxThe key is fine but this action is not yours to take; not_sandbox is a live key on a sandbox endpoint
404not_foundNot there, or not one of your instructors. We never confirm that something exists outside your school
405method_not_allowedWrong method for the path; Allow lists the right ones
409booking_state, pack_pending, slot_conflict, booking_conflict, external_ref_exists, concurrent_update, not_unassigned, not_on_sale, cancel_window_closed, lesson_started, lesson_ended, pack_day_fixed, format_fixed_time, format_price_differs, group_lesson_fixed_time, group_lesson_not_assignable, group_lesson_full, group_lessons_disabledA true state conflict; read the current state and decide again
413, 415payload_too_large, unsupported_media_typeBodies must be JSON and under 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, resort_required, price_below_minimum, invalid_price, no_resorts, invalid_max_students, currency_mismatch, invalid_duration, too_many_students, instructor_not_verified, wrong_discipline, invalid_duration_prices, invalid_season_prices, invalid_included_students, invalid_lesson_length, invalid_notice, invalid_disciplines, invalid_lesson_formats, formats_not_sold, format_required, unknown_format, format_not_on_date, format_wrong_time, invalid_group_lesson, group_lesson_wrong_date, inside_notice_windowWell-formed, but breaks a rule of the calendar, the lesson or the school
429rate_limitedSee Retry-After
500internalOurs; the request id helps us find it
409 Conflict
{
  "error": {
    "code": "booking_state",
    "message": "Invalid booking state",
    "requestId": "req_929e0418a4b7",
    "details": { "bookingStatus": "confirmed" }
  }
}

Reading the data

Ids. Instructors carry their account id (user_…), or unclaimed_… while a profile your school added without an account has not been claimed; a school's unassigned-lessons seat is pool_<schoolId>; sandbox instructors are sandbox_ia_… and sandbox_ib_…. Bookings are b_…, events evt_…, deliveries whd_…, endpoints wh_…, requests req_…. References are AP-<year>-<5 digits> for real bookings and SB<6 hex> in the sandbox.

Times. date, startTime and endTime are the wall clock of resortTimeZone, never UTC; the instants (createdAt, updatedAt, paidAt, instructorResponseDeadline…) are UTC. A lesson on the night the clocks change resolves to the first occurrence of an ambiguous hour, and an hour that does not exist that night moves forward. endTime may be 24:00. Whether a time is in the past is judged on the resort's clock.

Money. subtotal = (hourlyPrice + extraStudentPrice × max(0, numberOfStudents − includedStudents)) × durationHours, rounded to the currency; it is what the customer pays. hourlyPrice is that of the lesson's date: a season's (seasonPrices) when the date falls in one, the instructor's or the seat's own otherwise; includedStudents is how many students it covers, 1 unless the school sets more. platformFee is our commission: 10 %, or the higher one your school has agreed with us in writing; each booking keeps the rate it was sold under. netAmount is the rest, subtotal − platformFee, credited to your school's ledger when the lesson completes, whoever taught it and whoever manages them — and voided if the booking is refunded. Every instructor on your roster charges in your school's currency; hourly prices have a floor per currency (10 EUR, 11 USD, 10 CHF) and extraStudentPrice is 0 or more. Equipment the customer rents from your school with the lesson is added to subtotal too (equipment.amount), and counts like the lesson.

FieldValues
bookingStatusSee the table under Bookings
paymentStatuspending, processing, succeeded, failed, authentication_required, refund_pending, refunded — only succeeded and the two refund states ever reach the API
acceptedViainstructor, school, auto, customer, or null while not accepted
studentLevelsOne entry per student: { level: beginner | intermediate | advanced, age: adult | child }; empty in the sandbox
disciplineski or snowboard — the lesson's; an instructor's disciplines may also say both
language / customer.languageThe lesson's language as a two-letter code, and the language the customer uses the site in (en, es, it, fr, de, pl, nl, ru)
meetingPointstatus not_agreed, proposed or confirmed, plus the title once one exists
verificationStatus (instructor)draft, pending_review, verified, rejected

Your school, resorts and instructors

EndpointReturns
GET /The descriptor: name, version, links to this page and to the spec (no key needed)
GET /meYour school: id, slug, name, currency, country, freeCancellationHours (its cancellation policy, in hours) and sandbox (true behind a test key)
GET /resortsEvery resort one of your instructors or your unassigned seat teaches at, with its time zone and currency
GET /instructorsYour roster, active and inactive; instructors who left the school are gone from it, and the unassigned seat is never in it
GET /instructors/{id}One instructor: rates, lesson lengths, buffer, notice, disciplines, languages, verificationStatus, managedBy, autoAcceptBookings
PATCH /instructors/{id}The rates — hourlyPrice, extraStudentPrice, extraStudentPer, durationPrices, seasonPrices, includedStudents, groupClassesEnabled and groupPricePerStudent — for anyone on the roster (the hourly and per-seat prices have a floor per currency, a negative extraStudentPrice is a 422 invalid_price, and switching group lessons on demands a per-seat price); a new groupPricePerStudent also re-stamps the active group lessons. autoAcceptBookings only for an instructor the school manages. Everything is checked first and written together: a body refused on one field changes nothing

managedBy is the rule that decides who answers: when it says instructor, the calendar and the requests are theirs and the API can read but not write; when it says school, the school — and so the API — writes the calendar and answers the requests, and the instructor watches. It is set from the roster in the panel, by a person, and cannot be changed through the API. Handing a calendar back to the instructor deletes the external blocks your system wrote on it.

When an instructor leaves the school, their bookings disappear from GET /bookings and their events stop, while what they already earned stays on your ledger. Neither that nor a managedBy change sends an event: diff GET /instructors on each sync.

unclaimed marks an instructor the school added in the panel without an account: nobody signs in as them, so they are always managedBy school and the school answers for them in everything, the chat included. When the person claims the profile with their own account, unclaimed turns false and the instructor's id changes to a permanent one: every path and the instructorId filter keep accepting the old id, the instructor carries it as previousId, and events from then on name the new one. No event announces it: diff GET /instructors on each sync and match previousId.

Availability

An instructor's availability is a list of blocks. A block is available, blocked or vacation, belongs to a resort the instructor teaches at, spans startTime to endTime (endTime may be 24:00), and is anchored either to a day of the week (dayOfWeek 0–6, Sunday first, optionally with since and until as the first and last dates it applies) or to one date — exactly one of the two. New available hours must fall between 07:00 and 21:00 on the resort's clock (a 422 outside_ski_day otherwise); blocked and vacation blocks may cover any time. Customers can only book inside available blocks that nothing else covers.

PUT /instructors/{id}/availability replaces the whole schedule — every resort, every block, up to 400 — with what you send, exactly like saving the calendar in the panel does. Ids are optional: the server assigns one to a block without one, keeps the ones you send, and refuses duplicates or the xb_ prefix of external blocks. External blocks (see below) are never touched by PUT and must not appear in it. A malformed block, an unknown resort, an overlap of the two anchors or a since later than the block's until is a 422 invalid_availability. Blocks that can no longer apply (a date that is over, a weekly block past its until) are dropped: PUT does not store them and GET does not return them. A date is over once it is over everywhere, at 12:00 UTC the next day.

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

GET …/availability/expanded does the arithmetic for you: for one resort and up to 62 days it returns, per day, the free ranges a customer could book and the busy ones, each busy range labelled booking (a confirmed lesson), pending (a paid request awaiting an answer) or external (a block from the API). It applies the buffer between lessons and the notice window the instructor set; it does not drop gaps shorter than their minimum lesson length, so a thirty-minute gap shows as free.

The expanded view

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": [] }
  ]
}

External blocks

A lesson you sold outside Alpine Pal occupies the instructor. Register it as an external block and the slot disappears from the search, across every resort the instructor teaches at — a person cannot be in two valleys at once. Blocks need the school to manage the instructor (managedBy = school).

Each block carries your own externalRef. Sending the same reference again with the same date and hours is idempotent and returns 200 with the stored block, whatever resortId you pass; the same reference with different hours is a 409 external_ref_exists — delete and recreate. A block that overlaps a live booking is a 409 booking_conflict with the hours in the way and, when that booking is paid, its id and reference: that lesson is already sold on our side.

An instructor holds at most 400 external blocks from today on; one more is a 422 limit_reached. A block whose date is over (at 12:00 UTC the next day) goes by itself: GET /blocks no longer lists it, a DELETE of it is a 404 and it stops counting toward the 400.

EndpointNotes
GET /instructors/{id}/blocksOptional from, to, externalRef filters
POST /instructors/{id}/blocksdate, startTime, endTime, externalRef (1–120 characters), optional resortId (one the instructor teaches at; defaults to their first); 07:00–21:00, not in the past on that resort's clock, at most 30 days beyond the booking horizon of 182 days
DELETE /instructors/{id}/blocks/{blockId}204; a schedule block (not from the API) is a 404 here
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" }'

Accepting a booking request checks the slot again: an external block on its hours, like a live booking or a group lesson there, makes the answer a 409 slot_conflict, and the school must reject the request or propose another time. On the unassigned seat, accepting re-checks nothing: its places were counted when the customer paid. Once your block is answered 201 the hour is yours: a checkout that was already under way on it is refunded when its payment tries to land, and reaches you as booking.expired.

Lessons without a named instructor

A school can sell hours without saying who will teach them: the customer books and pays, and the school assigns an instructor whenever it likes — through the API, from the panel, or at the meeting point with whoever is free. On the API this is the school's own seat: it has a calendar, a price and an instant-confirmation switch of its own, it is not listed among /instructors, and its bookings arrive with unassigned: true and instructorId set to the seat (pool_…). It sells as many lessons at once as its calendar has places: an available block may carry capacity (up to 50; 1 when absent), counted at the block's resort minute by minute — a day's own blocks replace the weekly ones for the minutes they cover, and within either, overlapping blocks add up. A lesson needs a place for its whole length. The seat's group lessons take no places: they run alongside the private lessons and each other.

GET /unassigned shows the settings; PATCH /unassigned sets any of hourlyPrice, extraStudentPrice (0 or more; a negative one is a 422 invalid_price), extraStudentPer, durationPrices, seasonPrices, includedStudents, autoAcceptBookings and maximumStudents. The first PATCH creates the seat, and its calendar and block endpoints are 404 until then. There is no switch: active says whether the seat is on sale, which it is as soon as its hourly price is above the floor (or, selling formats, its formats and seasons are complete); it sells on the hours its calendar opens, so a school that does not want these lessons opens none. An active in a PATCH is ignored. Otherwise the seat's calendar works like an instructor's, at the school's resorts.

Fixed formats. Instead of hours, a school can sell its seat's private lessons as fixed formats, set up in the panel: up to 6 (morning, afternoon, full day or other, each with its start and end), and seasons — date ranges with a price per format, per lesson for the whole group. saleMode in GET /unassigned says which, and lessonFormats lists the formats and seasons (read-only on the API). While the seat sells formats, every private lesson on it is one of them: a format not priced in the season of a date is not sold that day, and a date outside every season sells nothing. A format needs a place free all along its hours, like any lesson on the seat, and is not bound by the 6-hour limit of lessons sold by the hour. Such a seat is on sale once its formats and seasons are complete, whatever its hourly price. Its bookings carry format (id and kind), subtotal is that day's price for the format, and propose-time moves one only to the same hours on a day that sells its format at the price paid (otherwise 409 format_fixed_time or format_price_differs). In the sandbox, POST /sandbox/bookings on the seat takes formatId and uses your school's formats.

EndpointSame as
GET, PUT /unassigned/availabilityGET, PUT /instructors/{id}/availability
GET /unassigned/availability/expandedThe expanded view, for one of the school's resorts; each free stretch also says how many places are left (places)
GET, POST /unassigned/blocks · DELETE /unassigned/blocks/{blockId}External blocks on the seat: each takes one place at its resort (with more than one resort, resortId is required: 422 resort_required), and is refused (409 booking_conflict) only where it would leave more lessons than places with one of ours among them
GET /bookings?instructorId=pool_<schoolId>Only the unassigned lessons
POST /bookings/{id}/assigninstructorId in the body: moves an unassigned lesson to someone on your roster — see Answering a request
GET /unassigned
{
  "instructorId": "pool_sch_2a71",
  "configured": true,
  "active": true,
  "hourlyPrice": 55,
  "extraStudentPrice": 15,
  "autoAcceptBookings": false,
  "maximumStudents": 6,
  "currency": "EUR",
  "resorts": ["r_baqueira", "r_grandvalira"],
  "saleMode": "formats",
  "lessonFormats": {
    "formats": [
      { "id": "fmt_3b9c1e7a20", "kind": "morning", "startTime": "09:00", "endTime": "13:00", "durationHours": 4 },
      { "id": "fmt_8f02d6c4e1", "kind": "full_day", "startTime": "09:00", "endTime": "17:00", "durationHours": 8 }
    ],
    "seasons": [
      {
        "id": "sea_8d41f0c2b9",
        "name": "Low season",
        "ranges": [{ "from": "2026-11-29", "to": "2026-12-19" }, { "from": "2027-01-03", "to": "2027-02-06" }],
        "prices": [{ "formatId": "fmt_3b9c1e7a20", "price": 450 }, { "formatId": "fmt_8f02d6c4e1", "price": 700 }]
      }
    ]
  }
}

Until the lesson is assigned the school is its instructor side: it answers the request, chats with the customer and agrees the meeting point. Once assigned, the booking is the instructor's like any other, and what the school may still do to it follows managedBy.

Group lessons

A group lesson is an event with seats that independent customers buy separately: a one-off (date) or a weekly series (dayOfWeek, optionally with since and until), with a capacity, a per-seat price for the whole class, a level, who it is for (audience: all, children or adults — a children's class may name a minAge and maxAge, and every student booking a seat must fit), a fixed meeting point and a minimum number of participants to run. An instructor's classes take their per-seat price from the instructor's own settings (groupClassesEnabled and groupPricePerStudent, writable through PATCH /instructors/{id}); only the unassigned seat names it at creation, and a price change re-stamps the active classes. Classes are published, handed to a teacher and cancelled from the panel — on the API they are read-only in v1 — and live on a roster instructor or on the unassigned seat.

GET /instructors/{id}/classes (and GET /unassigned/classes) list the active ones. GET …/classes/expanded?from&to resolves them to concrete dates with their seats taken, checkouts under their 10-minute hold included — at most 62 days per call. On an instructor's calendar a class's hours also read as busy in the availability's expanded view, and an external block on them is refused with a 409 booking_conflict: those hours are not yours to sell elsewhere. The unassigned seat is a team: its classes take nothing from its private places, run alongside them and alongside each other, and an external block never collides with them.

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

Every seat sold arrives through /bookings as an ordinary booking with a groupClassId, confirmed the moment it is paid (acceptedVia auto). propose-time and assign refuse a seat — the time belongs to the class and every other participant, and a class is handed to a teacher whole, from the panel. Cancelling a class (or one date of a series) refunds every seat in full and sends one booking.cancelled per seat; a class still under its minStudents 24 hours before an occurrence starts is called off the same way, with cancelledBy platform.

Bookings

Only paid bookings reach the API: a booking exists for you from the instant its payment lands (paidAt), and a refunded one stays listed with its paymentStatus. A customer picks an instructor and a slot, pays, and the request lands with the instructor or the school. From then on it is either answered, or it expires unanswered and the customer is refunded.

The clock: the response deadline is 48 hours from payment, never later than the lesson's start; a proposal or a decline resets it (never past the earlier of the original and proposed starts); an hourly sweep expires what is overdue, so expiry can land up to an hour late. A confirmed lesson becomes upcoming 24 hours before it starts and in_progress while it runs. It completes when the customer confirms it, or 48 hours after its end by itself; completion releases your share. The customer may cancel free of charge until freeCancellationHours hours before the start: the booking's own window, the one your school's cancellation policy had when it was booked (48 by default), which it keeps whatever the policy says later.

bookingStatusMeaning
pending_instructor_approvalPaid, waiting for an answer before instructorResponseDeadline
change_proposedAnother time was proposed; the customer accepts, declines, or lets it expire. The original slot stays held; the proposed one is not, and their acceptance re-checks it
confirmedAccepted (acceptedVia says by whom: instructor, school, auto, customer)
upcoming, in_progressConfirmed and close to or during the lesson
completedDone; your share of the money is released
rejected_by_instructor, expiredRefunded in full
cancelled_by_customer, cancelled_by_instructorCancelled; refund per the cancellation policy
no_show_customer, no_show_instructor, disputed, refundedSet from the panel or by us
A booking
{
  "id": "b_3e8c1f",
  "reference": "AP-2027-51234",
  "instructorId": "user_2Zk9qX1aR7",
  "unassigned": false,
  "resortId": "r_baqueira",
  "resortTimeZone": "Europe/Madrid",
  "date": "2027-01-12",
  "startTime": "10:00",
  "endTime": "12:00",
  "durationHours": 2,
  "discipline": "ski",
  "language": "en",
  "numberOfStudents": 2,
  "studentLevels": [{ "level": "beginner", "age": "adult" }, { "level": "beginner", "age": "adult" }],
  "groupClassId": null,
  "format": null,
  "pack": null,
  "equipment": null,
  "customerMessage": "Two adults, first time on skis.",
  "customer": { "name": "Santi P.", "language": "es" },
  "meetingPoint": { "status": "not_agreed" },
  "subtotal": 130,
  "platformFee": 13,
  "netAmount": 117,
  "currency": "EUR",
  "bookingStatus": "pending_instructor_approval",
  "paymentStatus": "succeeded",
  "acceptedVia": null,
  "instructorResponseDeadline": "2027-01-06T10:00:00.000Z",
  "freeCancellationHours": 48,
  "proposed": null,
  "createdAt": "2027-01-04T09:12:41.000Z",
  "updatedAt": "2027-01-04T09:13:02.000Z",
  "paidAt": "2027-01-04T09:13:02.000Z",
  "completedAt": null,
  "refundedAt": null,
  "url": "https://alpinepal.com/en/booking/b_3e8c1f"
}

About the customer you receive a short name (first name and initial), their preferred language and their message — enough to teach the lesson, and what our privacy policy promises them. Email, phone and any account identifier are never sent; the conversation with the customer stays in the chat on Alpine Pal, where the instructor is.

Listing and keeping in sync

GET /bookings lists your instructors' paid bookings, oldest change first, with filters for status (a comma list), instructorId, from and to (lesson dates) and updatedSince (any ISO 8601 instant; inclusive). Pages are keyset-based on (updatedAt, id): follow nextCursor until it is null. The cursor is opaque and never expires, but it belongs to the filters it was made with — change them and start over; a made-up one is a 400.

The pattern for a nightly or hourly sync is the same one: remember the newest updatedAt you have seen and ask for everything since, exactly as you received it. Webhooks tell you the moment something changes; this endpoint is how you catch up after downtime. updatedAt also moves on changes that send no event — the lesson becoming upcoming or in_progress, a reminder sent — so a row can turn up in a sync with nothing visibly different.

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_…"

Answering a request

Actions are open to the school on instructors it manages and on its own unassigned seat; on an instructor who manages themself the answer is 403 not_managed_by_school. Every action is a conditional update: if the booking is no longer in the state the action expects, the answer is 409 booking_state with the current state in details, and nothing changes. Repeating an action is therefore safe, and the way to confirm what happened is GET /bookings/{id}.

EndpointRule
POST /bookings/{id}/acceptFrom pending; the lesson must not have started. The slot is checked again: a live booking or a group lesson overlapping it (the instructor's buffer included, paid requests too), or an external block on its hours, is a 409 slot_conflict. On the unassigned seat nothing is re-checked: its places were counted at payment
POST /bookings/{id}/rejectFrom pending or change_proposed; refunds the customer in full
POST /bookings/{id}/propose-timedate, startTime, optional message; only from pending, one proposal at a time. The customer accepts, declines, or lets it expire; the original slot stays held and the proposed one is not
POST /bookings/{id}/cancelA confirmed, upcoming or in-progress lesson that has not ended; refunds the customer in full
POST /bookings/{id}/assigninstructorId in the body; only a lesson with unassigned: true, from pending, confirmed or upcoming. The instructor must be on your roster, active and verified, teach that discipline at that resort, charge in that currency, allow that many students and that length, and be free then; their published hours are not checked — that is your call. The deadline does not move; whoever manages the instructor answers from here on. The customer and the instructor are told, and a booking.updated with reason instructor_assigned follows
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?" }'

Two things are not here on purpose: marking a no-show and completing a lesson. Both move money on the strength of what happened on the slope, and both stay a human act in the panel.

Webhooks

Register an https URL and we POST every booking event to it — the same JSON the booking endpoints return, wrapped in an event. Up to 10 endpoints per school, each subscribed to some events or to all of them with "*". Register them from the panel or through the API; the panel shows the delivery log either way. The URL must be https, with a hostname that has a dot, no credentials or fragment, and not a private address or ours.

EndpointDoes
GET /webhooksYour endpoints, secrets included
POST /webhooksurl, events (1–20, or ["*"]), optional description; 201 with the secret
PATCH /webhooks/{id}Any of url, events, description, active; switching back on resets the failure count
DELETE /webhooks/{id}Removes it and its delivery log
POST /webhooks/{id}/rotate-secretA new secret; the old one dies at once
POST /webhooks/{id}/testA ping, queued as an ordinary delivery and attempted now; reports how that attempt went, and a failed one is retried like any other
GET /webhooks/{id}/deliveriesNewest first, without bodies: 50 by default, up to 100 with ?limit=
POST /webhooks/{id}/deliveries/{deliveryId}/replayThe same event (same id, same payload) again as a new delivery of its own, attempted now and retried like any other
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

Events. booking.requested — a paid request arrived. booking.confirmed — accepted by the instructor or the school, confirmed automatically, or the customer accepted a proposed time: acceptedVia says which, and previousStatus is change_proposed in the last case. booking.time_proposed — another time was proposed to the customer. booking.rejected. booking.cancelled — cancelledBy is customer, instructor, school, or platform (the account behind the booking was deleted, or a group lesson was called off for missing its minimum). booking.expired — nobody answered in time. booking.completed — the lesson ended, no-shows included. booking.updated — anything else worth telling, with data.reason: proposal_declined, meeting_point_proposed, meeting_point_confirmed, dispute_opened, dispute_resolved, refund_settled (the money is back with the customer), refund_issued (by us), or instructor_assigned (previousInstructorId names the seat the lesson was on). ping is the test event, with an empty data; it is not a type you subscribe to. No event fires for upcoming, in_progress, roster or managedBy changes.

What to expect. Delivery is at least once: an answer that arrives after our 10-second timeout is counted as a failure and the event comes again, so treat the event id as the key and ignore repeats. Order is not guaranteed — retries and the sweep reorder events — so order by data.booking.updatedAt. Not every booking starts with booking.requested: one whose instructor has instant confirmation on (autoAcceptBookings) arrives directly as booking.confirmed, with previousStatus pending_instructor_approval. So create the booking in your system on the first event you see for its id, whatever its type. The payload is frozen when the event is made; a retry carries what was true then, and a later event, the newer state. An event can be lost if our attempt to record it fails; GET /bookings?updatedSince is the safety net. Every event carries sandbox: true or false. The log keeps deliveries for 30 days; a replay sends a past one again.

Verifying deliveries

Every delivery is signed with the endpoint's secret: the X-AlpinePal-Signature header carries a timestamp and an HMAC-SHA256 of the timestamp, a dot and the raw body. Verify it before trusting anything, compare in constant time, and reject timestamps more than 5 minutes away from your clock. The secret is returned when you create the endpoint and by GET /webhooks, and can be rotated from the panel or with POST /webhooks/{id}/rotate-secret; the old one dies at once.

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"));
}

Answer with any 2xx within 10 seconds. Anything else — a 3xx included, since we do not follow redirects — is a failure: we retry after 5 minutes, 15 minutes, 1 hour, 4 hours, 12 hours and 24 hours, about 42 hours in all, each time with the same event id so you can deduplicate. An endpoint is switched off, and the school's managers told, only once a delivery has failed every retry with not a single delivery to it getting through in the meantime; a short outage does not switch it off, however many events are in flight. While it is off, new events are not queued for it (GET /bookings?updatedSince catches you up), and a pending delivery whose attempt falls due meanwhile is marked failed without being sent; retries that fall due after you switch it back on go out as usual. Replay the ones that failed. POST /webhooks/{id}/test queues a ping as an ordinary delivery, attempts it right away and reports how that attempt went: a failed ping is retried the same way, counts toward switching the endpoint off like any delivery, and on an endpoint that is off is marked failed without being sent. GET /webhooks/{id}/deliveries lists deliveries newest first, 50 by default and up to 100 with ?limit=, with their attempt count and without their bodies: an event has one delivery per endpoint, plus one for each replay, with the same event id.

Sandbox

Every school gets a sandbox twin the moment its owner mints a test key from the panel (Connections → Sandbox): a copy of the school with two made-up instructors — sandbox_ia_…, who answers requests by hand, and sandbox_ib_…, with instant confirmation on — and the unassigned-lessons seat, each with a 09:00–17:00 calendar every day at the school's resorts, 1 to 6 hours per lesson, up to 6 students, no buffer and no notice. Test keys start with ap_test_ and reach only the twin; live keys never do. Everything else is the same API: list the twin's instructors, write their calendars, register webhooks, answer requests, assign lessons.

What a customer does on the site, POST /sandbox/bookings does for you: it creates a paid booking for the instructor you name, with the same checks as a real one — published availability, no overlap — and no money involved. From there the real machinery runs, and the actions below stand in for the customer and the clock, so every event can be seen in minutes. No email leaves the sandbox, and nothing in it is ever public; every event it sends carries sandbox: true, and so does GET /me. POST /sandbox/reset empties it so a test suite can start clean.

EndpointNotes
POST /sandbox/bookingsinstructorId (an instructor of the twin, or its seat), date, startTime, durationHours (within the instructor's limits); optional numberOfStudents, resortId, discipline, language, customerMessage. 201 with the booking; 403 not_sandbox with a live key
POST /sandbox/classesPublishes a group lesson on a sandbox instructor or the seat, under production rules; book its seats with POST /sandbox/bookings and groupClassId
POST /sandbox/bookings/{id}/accept-proposal · decline-proposalThe customer's answer to a proposed time
POST /sandbox/bookings/{id}/cancelThe customer cancels, under the same rules as on the site
POST /sandbox/bookings/{id}/completeWhat the customer's confirmation, or the sweep 48 hours after the lesson, does: completed and your share released
POST /sandbox/bookings/{id}/expireWhat the sweep does past the deadline: expired and refunded
POST /sandbox/resetDeletes the twin's bookings, chats, ledger rows and group lessons; keys, webhooks, calendars and the delivery log stay
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 }'

Still out of reach in the sandbox: disputes, no-shows, a refund issued by us, a cancellation with cancelledBy platform, and the meeting point (its events come from the site). Sandbox bookings carry an empty studentLevels and a customer named Sandbox C. who reads the site in English.

OpenAPI

The machine-readable description lives at https://alpinepal.com/api/v1/openapi.json — every path, method, parameter and schema above, in OpenAPI 3.1. Point a generator at it for a typed client, or import it into your HTTP tool to browse. It is served without authentication and cached for an hour.

Versioning and changes

This is v1. Within it we only add: new fields, new optional parameters, new event types, new endpoints. Your integration should ignore fields and events it does not know. Anything that would break an existing client goes to a v2 at a new base URL, with v1 kept running while you move.

  • 2026-09 — v1: school, resorts, instructors, availability and expanded view, external blocks, bookings with keyset paging, accept / reject / propose-time / cancel, signed webhooks with retries.

  • 2026-09-18 — hourlyPrice and extraStudentPrice on PATCH /instructors/{id}; the unassigned-lessons seat under /unassigned and POST /bookings/{id}/assign; reason and previousInstructorId on booking.updated; the sandbox, ap_test_ keys and sandbox on every event.

  • 2026-09-18 — review: booking_state carries the current state; updatedSince accepts any ISO instant; the refund of a booking without a payment also emits refund_settled; one booking.completed per lesson; assign checks the instructor's limits; meeting_point_proposed; POST /webhooks/{id}/deliveries/{deliveryId}/replay; the sandbox's customer and clock actions; this page rewritten around what the code does.

  • 2026-09-20 — PATCH /instructors/{id} writes its fields together, or nothing; a block answered 201 now wins over a checkout that was under way on the same hour (the customer is refunded, you receive booking.expired).

  • 2026-09-22 — group lessons: GroupClass and its occurrences under /instructors/{id}/classes and /unassigned/classes (read-only), groupClassId on every booking, cancelledBy platform when a class misses its minimum, POST /sandbox/classes and groupClassId on POST /sandbox/bookings.

  • 2026-09-23 — group pricing on the instructor: groupClassesEnabled and groupPricePerStudent on Instructor and on PATCH /instructors/{id}; a new per-seat price re-stamps the active classes; the per-seat floor is 10 EUR / 10 USD.

  • 2026-09-24 — documented: a booking whose instructor confirms instantly arrives as booking.confirmed alone, never as booking.requested; create bookings on the first event for their id. No change in behaviour.

  • 2026-09-27 — documented: proposing a meeting point does send booking.updated, with reason meeting_point_proposed; the section on keeping in sync said it sent no event. No change in behaviour.

  • 2026-09-27 — blocks whose date is over are dropped: PUT /instructors/{id}/availability no longer stores or returns them, and an external block leaves GET /blocks once its date is over and stops counting toward the limit of external blocks, which now counts from today on. Until now both kinds piled up until the instructor reached the limit.

  • 2026-10-05 — instructors the school adds without an account: unclaimed and previousId on Instructor. When the person claims the profile its id changes; every path and the instructorId filter still accept the old one.

  • 2026-10-06 — places on the unassigned seat: capacity on its available blocks (several lessons at once, counted per resort), places on each free stretch of its expanded view, and its external blocks take one place each instead of closing the hours. Its group lessons now collide only at their own resort. Instructors' calendars are unchanged: capacity on one is a 422 invalid_availability.

  • 2026-10-06 — the unassigned seat runs its group lessons alongside its private places and each other: a class no longer takes the hours (they stay in free, with their places), and an external block no longer collides with it. GroupClass gains audience (all, children, adults) with optional minAge and maxAge; a seat of a children's class is sold only to students booked as children, of an adults' class only to adults. POST /sandbox/classes accepts the same fields.

  • 2026-10-06 — weekly blocks and group lesson series can start later: since, their first date (inclusive), lets a school load a season before it opens without selling its hours now. PUT /instructors/{id}/availability takes it on blocks with dayOfWeek (a since later than the block's until is a 422 invalid_availability), GET returns it, GroupClass carries it and POST /sandbox/classes accepts it. Without since a series applies from now on, as before.

  • 2026-10-08 — an endpoint is switched off only when a delivery has failed every retry with no delivery to it getting through in the meantime. It used to go off after ten failed attempts in a row counted across deliveries, so a short outage with many events in flight could switch it off and drop their retries.

  • 2026-10-16 — the teaching day is 07:00 to 21:00 on the resort's clock (it was 06:00 to 23:00): external blocks, proposed times, and bookings and group lessons in the sandbox outside it are a 422 outside_ski_day, and so are new available hours outside it on PUT /instructors/{id}/availability and PUT /unassigned/availability. Hours stored before are kept, blocked and vacation blocks may cover any time, and a group lesson published before keeps its time.

  • 2026-10-16 — fixed lesson formats on the unassigned seat (API 1.8.0): GET /unassigned gains saleMode and lessonFormats (formats, and seasons with a price per format, read-only), Booking gains format (null on lessons sold by the hour), POST /sandbox/bookings accepts formatId, and propose-time on a format lesson keeps its hours and its price (409 format_fixed_time, format_price_differs). Nothing changes for a seat that sells by the hour.

  • 2026-10-16 — no switch for unassigned lessons (API 1.8.1): active in GET /unassigned now says whether the seat is on sale, which it is as soon as its hourly price is above the floor (or its formats and seasons are complete), and PATCH ignores it; a school that does not want these lessons opens no hours for them.

  • 2026-10-20 — prices for several hours in a row (API 1.10.0): Instructor and UnassignedLessons gain `durationPrices`, optional fixed prices for a whole lesson of 2, 3 or 4 hours for one student. The 4-hour price also covers longer lessons at its rate per hour, a length without a price of its own takes the rate of the longest shorter one that has one, and extra students are still charged per hour. Without them everything stays by the hour. PATCH /instructors/{id} and PATCH /unassigned accept them (null clears them); prices dearer per hour than a shorter lesson, or cheaper in total than a shorter one, answer 422 `invalid_duration_prices`.

  • 2026-10-19 — multi-day packs (API 1.9.0): a customer can buy several days of a group lesson's course (GroupClass.packs, priced per person by age band, with courseId) or several days in a row of a fixed format (UnassignedLessons: packDays, and per season minDays and packPrices) in one payment. Each day is a booking of its own with its share of the price and a `pack` saying which pack and which day it is; studentLevels may carry each student's `band`.

  • 2026-11-02 — terms by resort (API 1.22.0): UnassignedLessons gains `resortTerms`, by resort id, each resort that sells differently, with every price and condition of the seat as it sells there (the same fields as the top level: `hourlyPrice`, `durationPrices`, `seasonPrices`, `saleMode`, `lessonFormats` and the rest). It is `{}` when the seat has one set of terms, and a resort left out sells on the top-level values. PATCH /unassigned accepts `resortId` to write that resort's terms and leave the others as they are; a resort the school does not work at answers 422 `unknown_resort`. Without `resortId` it writes the top level, which a resort in `resortTerms` does not read. `active` is true while the seat sells at one resort at least. Purely additive: a seat without terms by resort reads and sells as before.

  • 2026-10-29 — hours an instructor is busy in their own Google Calendar (API 1.21.1): an instructor can now have the hours their Google Calendar marks busy closed on Alpine Pal. They are not part of the schedule: GET /instructors/{id}/availability does not list them and a PUT neither sees nor changes them, while /availability/expanded leaves those hours out of `free`, like anything else that is not on sale. Nothing else changes.

  • 2026-10-27 — prices by season and students included (API 1.21.0): Instructor and UnassignedLessons gain `seasonPrices` and `includedStudents`, both settable with PATCH /instructors/{id} and PATCH /unassigned. `seasonPrices` lists up to 4 seasons, each with an `id`, a `name` for the panel (null when none), 1 to 10 date ranges (`from` and `to`, both included) and its own `hourlyPrice` and `durationPrices`: a lesson on a date inside a season is priced with that season's prices, and a date in no season with the instructor's or the seat's own, as until now. Ranges never overlap, within a season or across seasons, and each season's prices follow the same rules as the instructor's or the seat's own. A PATCH sends the whole list (null or an empty list clears it): keep the ids a GET returned, and leave the id out for a new season. GET returns null when there are none. `includedStudents` (1 to 20, default 1) is how many students the price covers: `extraStudentPrice` is charged only for each student beyond them. Seasons that break a rule answer 422 `invalid_season_prices`, and a count outside its range 422 `invalid_included_students`. Purely additive: without seasons and with one student included, every price is as before.

  • 2026-10-26 — a price of 0 or below is refused (API 1.20.4): in `durationPrices`, a length the instructor or the seat sells with a price of 0 or a negative one is now a 422 `invalid_duration_prices`. Until now it was dropped without a word and the request answered 200, so that length went by the hour. To give a length no price of its own, leave its key out (or send null for the whole field).

  • 2026-10-26 — documentation corrections (API 1.20.3): this page and the spec now say what the API already does. Accepting a request re-checks the slot (a live booking, a group lesson or an external block on it is a 409 `slot_conflict`; nothing is re-checked on the unassigned seat). Keys stop, with a 403 `school_not_active`, only when the school is closed or not approved; a suspended school keeps its API. While an endpoint is off, a pending delivery is marked failed only when its attempt falls due; retries due after it is switched back on are sent. An unknown `extraStudentPer` is a 400 `validation_error`, not the 422 `invalid_price` the 1.13.0 entry said; `invalid_price` is a negative `extraStudentPrice`. `platformFee` is 10 %, or the higher commission a school has agreed with us in writing, as the booking was sold. The sample booking gains `unassigned`, `groupClassId`, `format`, `pack`, `equipment` and `freeCancellationHours`, and its meeting point reads `not_agreed`. The error table lists every code, among them `managed_by_school`, the pack, format and group lesson codes and `inside_notice_window` (with `hours`). Deliveries are listed 50 by default and up to 100 with `limit`, and a replay is a delivery of its own with the same event id. A ping is an ordinary delivery: retried when it fails, counted toward switching the endpoint off, and marked failed unsent while the endpoint is off. No change in behaviour.

  • 2026-10-26 — two corrections (API 1.20.2): PATCH /instructors/{id} now judges `durationPrices` over the instructor's own lesson lengths, as the panel does, so prices for an instructor whose lessons start at 2 hours are no longer refused as dearer than a 1-hour lesson they do not sell. And with a sandbox key, GET and PATCH /unassigned describe the formats the sandbox sells (the real school's, which POST /sandbox/bookings already used): `saleMode` and `lessonFormats` no longer read `hours` and empty for a seat that sells formats.

  • 2026-10-26 — assigning a day of a pack (API 1.20.1): POST /bookings/{id}/assign answers 409 `pack_pending` for a day of a multi-day pack still waiting for the school's answer when the instructor manages their own bookings (`managedBy: instructor`). A pack is answered whole, and that instructor could not answer the school's part of it. Accept the pack first, or assign the day to an instructor the school manages; days already confirmed are assigned one by one as before.

  • 2026-10-25 — closing one format on an instructor (API 1.20.0): a blocked block with `formatId` is now accepted on the calendar of an instructor selling formats, as on the unassigned seat since 1.15.0, and closes only that format on its date or weekdays; the other formats of those hours stay on sale. An instructor following the school's hours (`followsSchoolHours`) now also gets the seat's closures of one format. Bookings already honoured these closures on any calendar.

  • 2026-10-25 — the school's open hours for its instructors (API 1.19.0): Instructor gains `followsSchoolHours`. An instructor who joins through the school's invitation, or that the school adds without an account, now starts `managedBy: school` with it on: their calendar is a copy of the unassigned seat's open hours, at the resorts they teach at and one lesson at a time, and every change to the seat's schedule reaches it. The school switches it in the panel (Private lessons). A PUT /instructors/{id}/availability gives them hours of their own from then on: `followsSchoolHours` turns false. Instructors already on the roster are unchanged.

  • 2026-10-23 — instructors selling fixed formats (API 1.18.0): Instructor gains `saleMode`, `hours` or `formats`. The school may now sell any of its instructors as fixed formats with a price per season, as its unassigned seat (set in the panel, under Private lessons); their private bookings then carry `format`, as the seat's do, and the hourly rates sell nothing. Booking.format is no longer the seat's alone. The 422 `format_required` now reads “Choose one of the lesson formats”. Purely additive: every instructor sells by the hour until the school changes it.

  • 2026-10-23 — Swiss francs (API 1.17.0): School.currency may be `CHF`. Swiss schools and their instructors price and are paid in francs, so every amount of theirs (prices, subtotal, payouts) is in CHF, with an hourly floor of its own.

  • 2026-10-23 — cancellation policy per school (API 1.16.0): Booking gains `freeCancellationHours`, also in data.booking of every booking event: up to how many hours before the start the customer may cancel with a full refund, the window the booking was sold under — one of 48, 72, 168, 336, 672 or 1008. GET /me returns the school's current `freeCancellationHours`. The school sets its policy in the panel (Profile → Cancellation policy); it applies to everything the school sells (its instructors' private and group lessons and its unassigned seat), and a booking keeps the window it was made under whatever the policy becomes. Read-only through the API. Purely additive: bookings made before carry 48.

  • 2026-10-21 — closing one format (API 1.15.0): a blocked block on the unassigned seat may carry `formatId` and then closes only that format on its date or weekdays. The places of its hours stay for the other formats, where closing the hours closed every format that shared a minute with them. Closing the whole day is still closing its hours. GET returns the field; a PUT that leaves it out drops the closure.

  • 2026-10-21 — formats for Westendorf (API 1.14.0): a format's start and end now sit on the quarter hour (08:45, 13:15), not only on the hour or half hour. UnassignedLessons' `lessonFormats` gains `extraStudentPrice`: what each student beyond the first adds to a format lesson, once per lesson (per day of a pack), apart from the seat's hourly one; 0 keeps the price for the whole group. Set in the panel.

  • 2026-10-20 — extra per student per lesson (API 1.13.0): Instructor and UnassignedLessons gain `extraStudentPer`, `hour` (as until now) or `lesson`: whether `extraStudentPrice` is charged for every hour of the lesson or once for the whole lesson. Settable with PATCH /instructors/{id} and PATCH /unassigned; any other value is 422 `invalid_price`.

  • 2026-10-20 — Courchevel's and Westendorf's conditions (API 1.12.0): each format of a season has its own minimum of days in a row (`prices[].minDays`; the season's `minDays` is now the smallest of them and deprecated), so mornings can be sold from five days and afternoons one by one in the same weeks. UnassignedLessons gains `minBookingNoticeHours` (now also two and three days) and `disciplines`, both settable with PATCH /unassigned. A pack of fixed formats on a seat without instant confirmation now waits for the school, like any other request; accepting or rejecting any of its days answers the whole pack. The seat also gains `minimumLessonDuration` and `maximumLessonDuration` (whole hours, PATCH /unassigned; 422 `invalid_lesson_length`): Snowsports Westendorf sells two and three hours, never one. When no lesson it sells goes by the hour, `hourlyPrice` may be 0 and the seat is still on sale. `durationPrices` takes a key for every length from two to six hours; a length without a price goes at the rate per hour of the last one priced before it. And `singleLevelBookings` (also PATCH /unassigned): on, a private lesson whose students mix levels, or adults and children, is refused, and the customer makes one booking per group — Courchevel puts a teacher per level and per age.

  • 2026-10-20 — equipment rental (API 1.11.0): a school can rent equipment with its lessons at a price per day per item, set in its panel. Booking gains `equipment` (null when nothing was rented): this booking's share (amount, already inside subtotal), the whole rental, its days, and per student the items with their price and the sizes (EU shoe size, height in cm). The commission and netAmount count it like the lesson, and a refund returns it with the lesson.