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 API 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 five 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 stops if the school is deactivated or closed. 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 five-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.
| Header | Where |
|---|---|
Authorization: Bearer <key> | Every request |
Content-Type: application/json | Every request with a body |
X-Request-Id | Every response, errors included; quote it when writing to us |
Cache-Control: no-store, Vary: Authorization | Every response |
WWW-Authenticate: Bearer realm="alpinepal-api" | 401 responses |
Allow | 405 responses |
Retry-After (seconds) | 429 responses; the body repeats it as details.retryAfterSeconds |
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, twenty in ten 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 ten in ten 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. Four codes carry details: a validation_error lists the offending fields as [{path, message}] (at most ten); 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}.
| Status | Codes | When |
|---|---|---|
400 | validation_error, invalid_cursor, invalid_json | The request itself is wrong |
401 | unauthenticated, invalid_api_key | No key, or a malformed, unknown, revoked or wrong-environment one |
403 | forbidden, school_not_active, not_managed_by_school, not_sandbox | The key is fine but this action is not yours to take; not_sandbox is a live key on a sandbox endpoint |
404 | not_found | Not there, or not one of your instructors. We never confirm that something exists outside your school |
405 | method_not_allowed | Wrong method for the path; Allow lists the right ones |
409 | booking_state, slot_conflict, booking_conflict, external_ref_exists, concurrent_update, not_unassigned, not_on_sale, cancel_window_closed, lesson_started, lesson_ended | A true state conflict; read the current state and decide again |
413, 415 | payload_too_large, unsupported_media_type | Bodies must be JSON and under 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 | Well-formed, but breaks a rule of the calendar, the lesson or the school |
429 | rate_limited | See Retry-After |
500 | internal | Ours; the request id helps us find it |
{
"error": {
"code": "booking_state",
"message": "Invalid booking state",
"requestId": "req_929e0418a4b7",
"details": { "bookingStatus": "confirmed" }
}
}Reading the data
Ids. Instructors carry their account id (user_…); 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 × (numberOfStudents − 1)) × durationHours, rounded to the currency; it is what the customer pays. platformFee is our 10 %. netAmount is the 90 % 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) and extraStudentPrice is 0 or more.
| Field | Values |
|---|---|
bookingStatus | See the table under Bookings |
paymentStatus | pending, processing, succeeded, failed, authentication_required, refund_pending, refunded — only succeeded and the two refund states ever reach the API |
acceptedVia | instructor, school, auto, customer, or null while not accepted |
studentLevels | One entry per student: { level: beginner | intermediate | advanced, age: adult | child }; empty in the sandbox |
discipline | ski or snowboard — the lesson's; an instructor's disciplines may also say both |
language / customer.language | The lesson's language as a two-letter code, and the language the customer uses the site in (en, es, it) |
meetingPoint | status not_agreed, proposed or confirmed, plus the title once one exists |
verificationStatus (instructor) | draft, pending_review, verified, rejected |
Your school, resorts and instructors
| Endpoint | Returns |
|---|---|
GET / | The descriptor: name, version, links to this page and to the spec (no key needed) |
GET /me | Your school: id, slug, name, currency, country, and sandbox (true behind a test key) |
GET /resorts | Every resort one of your instructors or your unassigned seat teaches at, with its time zone and currency |
GET /instructors | Your 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} | hourlyPrice and extraStudentPrice for anyone on the roster (the hourly price has a floor per currency); autoAcceptBookings only for an instructor the school manages. Fields are applied one after another: if the second is refused, the first stays |
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.
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 until as the last date it applies) or to one date — exactly one of the two. 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 or an overlap of the two anchors is a 422 invalid_availability.
{
"blocks": [
{ "id": "ab_1", "resortId": "r_baqueira", "type": "available",
"dayOfWeek": 6, "startTime": "09:00", "endTime": "13:00", "until": "2027-04-15" },
{ "id": "ab_2", "resortId": "r_baqueira", "type": "vacation",
"date": "2026-12-25", "startTime": "06:00", "endTime": "23:00" },
{ "id": "xb_7c1d", "resortId": "r_baqueira", "type": "blocked",
"date": "2027-01-10", "startTime": "10:00", "endTime": "12:00",
"source": "api", "externalRef": "lueira:LS-4471" }
]
}GET …/availability/expanded 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
{
"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.
| Endpoint | Notes |
|---|---|
GET /instructors/{id}/blocks | Optional from, to, externalRef filters |
POST /instructors/{id}/blocks | date, startTime, endTime, externalRef (1–120 characters), optional resortId (one the instructor teaches at; defaults to their first); 06:00–23: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 |
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 does not re-check blocks: if a request arrived before your block, the school must reject it or propose another time. A block and a checkout racing each other on the same hour can both succeed — when your block is answered 201, list the instructor's bookings once more if the hour matters.
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_…). One lesson per time slot.
GET /unassigned shows the settings; PATCH /unassigned sets any of active, hourlyPrice, extraStudentPrice, autoAcceptBookings and maximumStudents. The first PATCH creates the seat, and its calendar and block endpoints are 404 until then. Switching it on needs a price above the floor in the same or an earlier call and the school's resorts chosen in the panel; switching it off is always allowed. The seat's calendar works exactly like an instructor's, at the school's resorts.
| Endpoint | Same as |
|---|---|
GET, PUT /unassigned/availability | GET, PUT /instructors/{id}/availability |
GET /unassigned/availability/expanded | The expanded view, for one of the school's resorts |
GET, POST /unassigned/blocks · DELETE /unassigned/blocks/{blockId} | External blocks on the seat: the slot is no longer sold as an unassigned lesson |
GET /bookings?instructorId=pool_<schoolId> | Only the unassigned lessons |
POST /bookings/{id}/assign | instructorId in the body: moves an unassigned lesson to someone on your roster — see Answering a request |
{
"instructorId": "pool_sch_2a71",
"configured": true,
"active": true,
"hourlyPrice": 55,
"extraStudentPrice": 15,
"autoAcceptBookings": false,
"maximumStudents": 6,
"currency": "EUR",
"resorts": ["r_baqueira", "r_grandvalira"]
}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.
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 48 hours before the start.
| bookingStatus | Meaning |
|---|---|
pending_instructor_approval | Paid, waiting for an answer before instructorResponseDeadline |
change_proposed | Another 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 |
confirmed | Accepted (acceptedVia says by whom: instructor, school, auto, customer) |
upcoming, in_progress | Confirmed and close to or during the lesson |
completed | Done; your share of the money is released |
rejected_by_instructor, expired | Refunded in full |
cancelled_by_customer, cancelled_by_instructor | Cancelled; refund per the cancellation policy |
no_show_customer, no_show_instructor, disputed, refunded | Set from the panel or by us |
{
"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"
}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, a meeting point proposed — so a row can turn up in a sync with nothing visibly different.
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}.
| Endpoint | Rule |
|---|---|
POST /bookings/{id}/accept | From pending; the lesson must not have started; any live booking overlapping the slot (their buffer included, paid requests too) is a 409 slot_conflict |
POST /bookings/{id}/reject | From pending or change_proposed; refunds the customer in full |
POST /bookings/{id}/propose-time | date, 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}/cancel | A confirmed, upcoming or in-progress lesson that has not ended; refunds the customer in full |
POST /bookings/{id}/assign | instructorId 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 |
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 ten 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.
| Endpoint | Does |
|---|---|
GET /webhooks | Your endpoints, secrets included |
POST /webhooks | url, 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-secret | A new secret; the old one dies at once |
POST /webhooks/{id}/test | A ping, delivered now; reports how it went |
GET /webhooks/{id}/deliveries | The last hundred deliveries, without bodies |
POST /webhooks/{id}/deliveries/{deliveryId}/replay | The same event again as a new delivery, sent now |
curl -X POST https://alpinepal.com/api/v1/webhooks \
-H "Authorization: Bearer ap_live_…" \
-H "Content-Type: application/json" \
-d '{ "url": "https://crm.example.com/alpinepal", "events": ["*"] }'
# → 201 { "id": "wh_…", "secret": "whsec_…", … } — keep the secret{
"id": "evt_4b2c9e17a0d3",
"type": "booking.confirmed",
"apiVersion": "v1",
"createdAt": "2027-01-04T10:02:17.000Z",
"schoolId": "sch_2a71",
"sandbox": false,
"data": {
"booking": { "id": "b_3e8c1f", "bookingStatus": "confirmed", "acceptedVia": "school", "…": "…" },
"previousStatus": "pending_instructor_approval"
}
}POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: AlpinePal-Webhooks/1.0
X-AlpinePal-Event: booking.confirmed
X-AlpinePal-Event-Id: evt_4b2c9e17a0d3
X-AlpinePal-Delivery: whd_00dd9d2f0cdd
X-AlpinePal-Signature: t=1799999999,v1=5f1c…e2a9Events. 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). 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 ten-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. 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 thirty 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 five 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.
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 ten 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 that fails ten attempts in a row is switched off and the school's managers are told; its pending deliveries are then marked failed and are not sent when you switch it back on — replay the ones you need. POST /webhooks/{id}/test sends a ping right away and reports how it went, and GET /webhooks/{id}/deliveries shows the last hundred deliveries (one per event per endpoint, with their attempt count) without their bodies.
Sandbox
Every school gets a sandbox twin the moment its owner mints a test key from the panel (Connect your booking system → 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.
| Endpoint | Notes |
|---|---|
POST /sandbox/bookings | instructorId (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/bookings/{id}/accept-proposal · decline-proposal | The customer's answer to a proposed time |
POST /sandbox/bookings/{id}/cancel | The customer cancels, under the same rules as on the site |
POST /sandbox/bookings/{id}/complete | What the customer's confirmation, or the sweep 48 hours after the lesson, does: completed and your share released |
POST /sandbox/bookings/{id}/expire | What the sweep does past the deadline: expired and refunded |
POST /sandbox/reset | Deletes the twin's bookings, chats and ledger rows; keys, webhooks, calendars and the delivery log stay |
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.