Zum Inhalt springen
Für Skischulen

API für Skischulen

Verbinde die Software, mit der deine Skischule schon arbeitet: Verfügbarkeiten rein, Buchungen per signiertem Webhook raus, und deine Antworten zurück, ohne das Dashboard zu öffnen.

Ein Skischulkonto auf Alpine Pal gibt dir den Kader deiner Skilehrer, ihre Kalender und ihre Buchungsanfragen in einem Dashboard. Die API ist dasselbe Dashboard für eine Maschine: Ein Channel-Manager, ein CRM oder ein eigenes Skript kann die Verfügbarkeit deiner Skilehrer lesen und schreiben, von jeder bezahlten Buchung in dem Moment erfahren, in dem sie entsteht, und sie annehmen, ablehnen oder einen anderen Termin dafür vorschlagen.

Sie ist bewusst schmal gehalten. Kunden bezahlen auf Alpine Pal, deshalb erstellt die API keine Buchungen; sie trägt anderswo verkauften Unterricht als Blöcke ein, die den Kalender ehrlich halten. Was auf der Piste Geld bewegt – ein Nichterscheinen vermerken, eine Reklamation abschließen –, bleibt eine menschliche Handlung im Dashboard.

Einen Schlüssel erhalten

Schlüssel gehören der Skischule, nicht einer Person, und nur der Inhaber der Skischule kann sie erstellen oder widerrufen, im Reiter API des Skischul-Dashboards. Ein Schlüssel wird einmal angezeigt, bei der Erstellung; wir speichern einen Hash und können ihn nicht noch einmal anzeigen. Eine Skischule kann bis zu fünf Live-Schlüssel halten, sodass eine neue Integration ihren eigenen bekommt und ein alter widerrufen werden kann, ohne die übrigen anzurühren. Live-Schlüssel beginnen mit ap_live_; die Schlüssel der Testumgebung beginnen mit ap_test_ und werden getrennt gezählt.

Ein Schlüssel funktioniert ab dem Moment nicht mehr, in dem er widerrufen wird, und alle Schlüssel hören auf zu funktionieren, wenn die Skischule deaktiviert oder geschlossen wird. Schlüssel lassen sich nicht über die API selbst verwalten. Zum Rotieren: Erstelle den neuen Schlüssel, stelle dein System darauf um und widerrufe dann den alten – dazwischen sind beide gültig. Das Dashboard zeigt, wann jeder Schlüssel zuletzt benutzt wurde, auf fünf Minuten genau. Schlüssel sind für Server: Liefere nie einen in einem Browser oder einer Mobile-App aus.

Authentifizierung, Header und Request-Body

Jede Anfrage trägt den Schlüssel als Bearer-Token. Die Basis-URL ist https://alpinepal.com/api/v1; Antworten sind JSON, werden nie gecacht, und jede trägt eine X-Request-Id, die du uns nennen kannst. Uhrzeiten gelten nach der Ortszeit des Skigebiets als HH:mm, Daten sind YYYY-MM-DD, und Zeitpunkte sind ISO 8601 in UTC mit Millisekunden.

Request-Bodys müssen JSON sein, mit dem Header Content-Type: application/json (sonst 415), unter 256 KB (413), und werden strikt geprüft: Ein unbekanntes Feld ist ein 400 validation_error, der die betroffenen Pfade auflistet. Ein leerer Body wird als leeres Objekt gelesen. Eine Methode, die der Pfad nicht unterstützt, ist ein 405 mit einem Allow-Header; ein Schrägstrich am Ende ist ein 404.

HeaderWo
Authorization: Bearer <Schlüssel>Jede Anfrage
Content-Type: application/jsonJede Anfrage mit Body
X-Request-IdJede Antwort, Fehler eingeschlossen; nenne sie, wenn du uns schreibst
Cache-Control: no-store, Vary: AuthorizationJede Antwort
WWW-Authenticate: Bearer realm="alpinepal-api"401-Antworten
Allow405-Antworten
Retry-After (Sekunden)429-Antworten; der Body wiederholt es als details.retryAfterSeconds
curl
curl https://alpinepal.com/api/v1/me \
  -H "Authorization: Bearer ap_live_…"

Es gibt kein CORS: Die API ist Server-zu-Server, und eine Anfrage aus einem Browser schlägt fehl. Das ist Absicht, denn ein Schlüssel im Browser ist ein Schlüssel, den jeder lesen kann.

Anfragelimits

Pro Schlüssel, in festen Fenstern von einer Minute: 600 Lesezugriffe (GET) und 120 Schreibzugriffe (alles andere, DELETE eingeschlossen) pro Minute. Darüber hinaus lautet die Antwort 429 rate_limited mit einem Retry-After-Header, in Sekunden. Ein Test-Schlüssel hat sein eigenes Kontingent. Fehlgeschlagene Authentifizierungen sind pro IP-Adresse begrenzt, zwanzig in zehn Minuten; danach bekommt jeder weitere Versuch 429, noch bevor der Schlüssel überhaupt nachgeschlagen wird. Webhook-Tests und erneute Zustellungen teilen sich ein eigenes Kontingent von zehn in zehn Minuten pro Schlüssel, denn jede einzelne ist eine Anfrage an einen fremden Server.

Fehler

Jeder Fehler hat dieselbe Form: einen HTTP-Status, einen stabilen code, nach dem du verzweigen kannst, eine Nachricht für Menschen und die Request-Id. Vier Codes tragen details: validation_error listet die betroffenen Felder als [{path, message}] (höchstens zehn); booking_state sagt, in welchem Zustand die Buchung jetzt ist ({bookingStatus}); booking_conflict nennt die Stunden, die im Weg sind, und, wenn diese Buchung bezahlt ist, ihre Id und Referenz; rate_limited wiederholt die Wartezeit als {retryAfterSeconds}.

StatusCodesWann
400validation_error, invalid_cursor, invalid_jsonDie Anfrage selbst ist fehlerhaft
401unauthenticated, invalid_api_keyKein Schlüssel, oder ein fehlerhafter, unbekannter, widerrufener oder zur falschen Umgebung gehörender
403forbidden, school_not_active, not_managed_by_school, not_sandboxDer Schlüssel ist in Ordnung, aber diese Aktion steht dir nicht zu; not_sandbox ist ein Live-Schlüssel auf einem Endpoint der Testumgebung
404not_foundNicht vorhanden, oder keiner deiner Skilehrer. Wir bestätigen nie, dass etwas außerhalb deiner Skischule existiert
405method_not_allowedFalsche Methode für den Pfad; Allow listet die richtigen auf
409booking_state, slot_conflict, booking_conflict, external_ref_exists, concurrent_update, not_unassigned, not_on_sale, cancel_window_closed, lesson_started, lesson_endedEin echter Zustandskonflikt; lies den aktuellen Zustand und entscheide neu
413, 415payload_too_large, unsupported_media_typeBodys müssen JSON und unter 256 KB sein
422outside_availability, in_the_past, too_far_ahead, outside_ski_day, invalid_availability, api_blocks_read_only, limit_reached, invalid_webhook_url, unknown_event, range_too_long, unknown_resort, price_below_minimum, invalid_price, no_resorts, invalid_max_students, currency_mismatch, invalid_duration, too_many_students, instructor_not_verified, wrong_disciplineWohlgeformt, aber verstößt gegen eine Regel des Kalenders, des Unterrichts oder der Skischule
429rate_limitedSiehe Retry-After
500internalUnser Fehler; die Request-Id hilft uns, ihn zu finden
409 Conflict
{
  "error": {
    "code": "booking_state",
    "message": "Invalid booking state",
    "requestId": "req_929e0418a4b7",
    "details": { "bookingStatus": "confirmed" }
  }
}

Die Daten lesen

Ids. Skilehrer tragen die Id ihres Kontos (user_…); der Platz einer Skischule für Unterricht ohne zugewiesenen Skilehrer ist pool_<schoolId>; die Skilehrer der Testumgebung sind sandbox_ia_… und sandbox_ib_…. Buchungen sind b_…, Ereignisse evt_…, Zustellungen whd_…, Endpoints wh_…, Anfragen req_…. Referenzen sind AP-<Jahr>-<5 Ziffern> für echte Buchungen und SB<6 hex> in der Testumgebung.

Zeiten. date, startTime und endTime sind die Ortszeit von resortTimeZone, nie UTC; die Zeitpunkte (createdAt, updatedAt, paidAt, instructorResponseDeadline…) sind UTC. Ein Unterricht in der Nacht der Zeitumstellung wird auf das erste Vorkommen einer doppelten Stunde gelegt, und eine Stunde, die es in dieser Nacht nicht gibt, wird auf die nächstfolgende existierende Stunde verschoben. endTime darf 24:00 sein. Ob eine Zeit in der Vergangenheit liegt, wird nach der Uhr des Skigebiets beurteilt.

Geld. subtotal = (hourlyPrice + extraStudentPrice × (numberOfStudents − 1)) × durationHours, auf die Währung gerundet; das ist, was der Kunde bezahlt. platformFee sind unsere 10 %. netAmount sind die 90 %, die dem Guthaben deiner Skischule gutgeschrieben werden, wenn der Unterricht abgeschlossen ist – wer auch immer ihn gegeben hat und wer auch immer diese Person verwaltet –, und die entfallen, wenn die Buchung erstattet wird. Jeder Skilehrer in deinem Kader rechnet in der Währung deiner Skischule ab; Stundenpreise haben eine Untergrenze pro Währung (10 EUR, 11 USD), und extraStudentPrice ist 0 oder mehr.

FeldWerte
bookingStatusSiehe die Tabelle unter Buchungen
paymentStatuspending, processing, succeeded, failed, authentication_required, refund_pending, refunded – nur succeeded und die beiden Erstattungszustände erreichen die API überhaupt
acceptedViainstructor, school, auto, customer, oder null, solange nicht angenommen
studentLevelsEin Eintrag pro Teilnehmer: { level: beginner | intermediate | advanced, age: adult | child }; leer in der Testumgebung
disciplineski oder snowboard – die des Unterrichts; die disciplines eines Skilehrers können auch both sagen
language / customer.languageDie Sprache des Unterrichts als Zwei-Buchstaben-Code, und die Sprache, in der der Kunde die Website nutzt (en, es, it)
meetingPointstatus not_agreed, proposed oder confirmed, plus title, sobald einer existiert
verificationStatus (Skilehrer)draft, pending_review, verified, rejected

Deine Skischule, Skigebiete und Skilehrer

EndpointLiefert
GET /Der Deskriptor: Name, Version, Links zu dieser Seite und zur Spezifikation (kein Schlüssel nötig)
GET /meDeine Skischule: id, slug, name, currency, country und sandbox (true hinter einem Test-Schlüssel)
GET /resortsJedes Skigebiet, in dem einer deiner Skilehrer oder dein Platz für Unterricht ohne zugewiesenen Skilehrer unterrichtet, mit Zeitzone und Währung
GET /instructorsDein Kader, aktive wie inaktive; Skilehrer, die die Skischule verlassen haben, sind daraus verschwunden, und der Platz ist nie darin
GET /instructors/{id}Ein Skilehrer: Preise, Unterrichtsdauern, Puffer, Vorlaufzeit, Disziplinen, Sprachen, verificationStatus, managedBy, autoAcceptBookings
PATCH /instructors/{id}hourlyPrice und extraStudentPrice für jeden im Kader (der Stundenpreis hat eine Untergrenze pro Währung); autoAcceptBookings nur für einen Skilehrer, den die Skischule verwaltet. Beide werden zuerst geprüft und zusammen geschrieben: Ein Body, der wegen eines Feldes abgelehnt wird, ändert nichts

managedBy ist die Regel, die entscheidet, wer antwortet: Steht dort instructor, gehören Kalender und Anfragen dem Skilehrer, und die API kann lesen, aber nicht schreiben; steht dort school, schreibt die Skischule – und damit die API – den Kalender und beantwortet die Anfragen, und der Skilehrer schaut zu. Gesetzt wird es von einer Person, im Kader des Dashboards; über die API lässt es sich nicht ändern. Gibst du einen Kalender an den Skilehrer zurück, werden die externen Blöcke gelöscht, die dein System darin geschrieben hat.

Verlässt ein Skilehrer die Skischule, verschwinden seine Buchungen aus GET /bookings und seine Ereignisse hören auf, während das, was er schon verdient hat, in deinem Guthaben bleibt. Weder das noch eine Änderung von managedBy sendet ein Ereignis: Vergleiche GET /instructors bei jeder Synchronisation.

Verfügbarkeit

Die Verfügbarkeit eines Skilehrers ist eine Liste von Blöcken. Ein Block ist available, blocked oder vacation, gehört zu einem Skigebiet, in dem der Skilehrer unterrichtet, reicht von startTime bis endTime (endTime darf 24:00 sein) und ist entweder an einen Wochentag gebunden (dayOfWeek 0–6, Sonntag zuerst, optional mit until als letztem Datum, an dem er gilt) oder an ein Datum – genau eines von beiden. Kunden können nur innerhalb von available-Blöcken buchen, die nichts anderes überdeckt.

PUT /instructors/{id}/availability ersetzt den ganzen Plan – jedes Skigebiet, jeden Block, bis zu 400 – durch das, was du sendest, genau wie das Speichern des Kalenders im Dashboard. Ids sind optional: Der Server vergibt eine an einen Block ohne Id, behält die, die du sendest, und lehnt Duplikate oder das Präfix xb_ der externen Blöcke ab. Externe Blöcke (siehe unten) werden von PUT nie angerührt und dürfen darin nicht vorkommen. Ein fehlerhafter Block, ein unbekanntes Skigebiet oder beide Anker zugleich sind ein 422 invalid_availability.

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

GET …/availability/expanded übernimmt das Rechnen für dich: Für ein Skigebiet und bis zu 62 Tage liefert es pro Tag die freien Bereiche, die ein Kunde buchen könnte, und die belegten, jeder belegte Bereich beschriftet mit booking (ein bestätigter Unterricht), pending (eine bezahlte Anfrage, die auf Antwort wartet) oder external (ein Block aus der API). Es wendet den Puffer zwischen zwei Unterrichtseinheiten und die Vorlaufzeit an, die der Skilehrer eingestellt hat; Lücken, die kürzer sind als seine minimale Unterrichtsdauer, lässt es nicht weg, sodass eine Lücke von dreißig Minuten als frei erscheint.

Die erweiterte Ansicht

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

Externe Blöcke

Ein Unterricht, den du außerhalb von Alpine Pal verkauft hast, belegt den Skilehrer. Trage ihn als externen Block ein, und das Zeitfenster verschwindet aus der Suche, in jedem Skigebiet, in dem der Skilehrer unterrichtet – eine Person kann nicht in zwei Tälern zugleich sein. Blöcke setzen voraus, dass die Skischule den Skilehrer verwaltet (managedBy = school).

Jeder Block trägt deine eigene externalRef. Dieselbe Referenz noch einmal mit demselben Datum und denselben Stunden zu senden ist idempotent und liefert 200 mit dem gespeicherten Block, egal welche resortId du übergibst; dieselbe Referenz mit anderen Stunden ist ein 409 external_ref_exists – löschen und neu anlegen. Ein Block, der sich mit einer aktiven Buchung überschneidet, ist ein 409 booking_conflict mit den Stunden, die im Weg sind, und, wenn diese Buchung bezahlt ist, ihrer Id und Referenz: Dieser Unterricht ist auf unserer Seite schon verkauft.

EndpointHinweise
GET /instructors/{id}/blocksOptionale Filter from, to, externalRef
POST /instructors/{id}/blocksdate, startTime, endTime, externalRef (1–120 Zeichen), optional resortId (eines, in dem der Skilehrer unterrichtet; Standard ist sein erstes); 06:00–23:00, nicht in der Vergangenheit nach der Uhr dieses Skigebiets, höchstens 30 Tage über den Buchungshorizont von 182 Tagen hinaus
DELETE /instructors/{id}/blocks/{blockId}204; ein Block des Plans (nicht aus der API) ist hier ein 404
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" }'

Das Annehmen einer Buchungsanfrage prüft Blöcke nicht erneut: Ist eine Anfrage vor deinem Block eingetroffen, muss die Skischule sie ablehnen oder einen anderen Termin vorschlagen. Sobald dein Block mit 201 beantwortet ist, gehört dir die Stunde: Eine Bezahlung, die darauf schon im Gang war, wird erstattet, sobald ihre Zahlung eintrifft, und erreicht dich als booking.expired.

Unterricht ohne zugewiesenen Skilehrer

Eine Skischule kann Stunden verkaufen, ohne zu sagen, wer sie geben wird: Der Kunde bucht und bezahlt, und die Skischule weist einen Skilehrer zu, wann immer sie möchte – über die API, aus dem Dashboard oder am Treffpunkt mit dem, der gerade frei ist. In der API ist das der eigene Platz der Skischule: Er hat einen eigenen Kalender, einen eigenen Preis und einen eigenen Schalter für die Sofortbestätigung, er ist nicht unter /instructors aufgeführt, und seine Buchungen kommen mit unassigned: true und instructorId gleich dem Platz (pool_…) an. Ein Unterricht pro Zeitfenster.

GET /unassigned zeigt die Einstellungen; PATCH /unassigned setzt beliebige von active, hourlyPrice, extraStudentPrice, autoAcceptBookings und maximumStudents. Der erste PATCH erstellt den Platz, und bis dahin sind seine Kalender- und Block-Endpoints 404. Einschalten setzt einen Preis über der Untergrenze voraus, im selben oder einem früheren Aufruf, und im Dashboard gewählte Skigebiete der Skischule; Ausschalten ist immer erlaubt. Der Kalender des Platzes funktioniert genau wie der eines Skilehrers, in den Skigebieten der Skischule.

EndpointEntspricht
GET, PUT /unassigned/availabilityGET, PUT /instructors/{id}/availability
GET /unassigned/availability/expandedDie erweiterte Ansicht, für eines der Skigebiete der Skischule
GET, POST /unassigned/blocks · DELETE /unassigned/blocks/{blockId}Externe Blöcke auf dem Platz: Das Zeitfenster wird nicht mehr als Unterricht ohne zugewiesenen Skilehrer verkauft
GET /bookings?instructorId=pool_<schoolId>Nur der Unterricht ohne zugewiesenen Skilehrer
POST /bookings/{id}/assigninstructorId im Body: verschiebt einen Unterricht ohne zugewiesenen Skilehrer zu jemandem aus deinem Kader – siehe Eine Anfrage beantworten
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"]
}

Bis der Unterricht zugewiesen ist, übernimmt die Skischule die Skilehrer-Seite: Sie beantwortet die Anfrage, chattet mit dem Kunden und vereinbart den Treffpunkt. Einmal zugewiesen, ist die Buchung die des Skilehrers wie jede andere, und was die Skischule damit noch tun darf, richtet sich nach managedBy.

Buchungen

Nur bezahlte Buchungen erreichen die API: Eine Buchung existiert für dich ab dem Augenblick, in dem ihre Zahlung eingeht (paidAt), und eine erstattete bleibt mit ihrem paymentStatus gelistet. Ein Kunde wählt einen Skilehrer und ein Zeitfenster, bezahlt, und die Anfrage landet beim Skilehrer oder bei der Skischule. Von da an wird sie entweder beantwortet, oder sie läuft unbeantwortet ab und der Kunde bekommt sein Geld zurück.

Die Uhr: Die Antwortfrist beträgt 48 Stunden ab Zahlung, nie später als der Beginn des Unterrichts; ein Vorschlag oder dessen Ablehnung setzt sie zurück (nie über die frühere der beiden Anfangszeiten hinaus, der ursprünglichen und der vorgeschlagenen); ein stündlicher Durchlauf lässt ablaufen, was überfällig ist, sodass der Ablauf bis zu eine Stunde verspätet eintreten kann. Ein bestätigter Unterricht wird 24 Stunden vor Beginn zu upcoming und ist in_progress, während er läuft. Er wird abgeschlossen, wenn der Kunde ihn bestätigt, oder von selbst 48 Stunden nach seinem Ende; der Abschluss gibt deinen Anteil frei. Der Kunde kann bis 48 Stunden vor Beginn kostenlos stornieren.

bookingStatusBedeutung
pending_instructor_approvalBezahlt, wartet auf eine Antwort vor instructorResponseDeadline
change_proposedEin anderer Termin wurde vorgeschlagen; der Kunde nimmt ihn an, lehnt ihn ab oder lässt ihn ablaufen. Das ursprüngliche Zeitfenster bleibt gehalten; das vorgeschlagene nicht, und seine Annahme prüft es erneut
confirmedAngenommen (acceptedVia sagt, von wem: instructor, school, auto, customer)
upcoming, in_progressBestätigt und kurz vor dem Unterricht oder währenddessen
completedAbgeschlossen; dein Anteil am Geld ist freigegeben
rejected_by_instructor, expiredVollständig erstattet
cancelled_by_customer, cancelled_by_instructorStorniert; Erstattung gemäß Stornierungsbedingungen
no_show_customer, no_show_instructor, disputed, refundedAus dem Dashboard oder von uns gesetzt
A booking
{
  "id": "b_3e8c1f",
  "reference": "AP-2027-51234",
  "instructorId": "user_2Zk9qX1aR7",
  "resortId": "r_baqueira",
  "resortTimeZone": "Europe/Madrid",
  "date": "2027-01-12",
  "startTime": "10:00",
  "endTime": "12:00",
  "durationHours": 2,
  "discipline": "ski",
  "language": "en",
  "numberOfStudents": 2,
  "studentLevels": [{ "level": "beginner", "age": "adult" }, { "level": "beginner", "age": "adult" }],
  "customerMessage": "Two adults, first time on skis.",
  "customer": { "name": "Santi P.", "language": "es" },
  "meetingPoint": { "status": "pending" },
  "subtotal": 130,
  "platformFee": 13,
  "netAmount": 117,
  "currency": "EUR",
  "bookingStatus": "pending_instructor_approval",
  "paymentStatus": "succeeded",
  "acceptedVia": null,
  "instructorResponseDeadline": "2027-01-06T10:00:00.000Z",
  "proposed": null,
  "createdAt": "2027-01-04T09:12:41.000Z",
  "updatedAt": "2027-01-04T09:13:02.000Z",
  "paidAt": "2027-01-04T09:13:02.000Z",
  "completedAt": null,
  "refundedAt": null,
  "url": "https://alpinepal.com/en/booking/b_3e8c1f"
}

Vom Kunden erhältst du einen Kurznamen (Vorname und Initiale), seine bevorzugte Sprache und seine Nachricht – genug, um den Unterricht zu geben, und das, was unsere Datenschutzerklärung ihm verspricht. E-Mail, Telefon und jede Kontokennung werden nie gesendet; das Gespräch mit dem Kunden bleibt im Chat auf Alpine Pal, wo der Skilehrer ist.

Auflisten und synchron bleiben

GET /bookings listet die bezahlten Buchungen deiner Skilehrer, älteste Änderung zuerst, mit Filtern für status (eine kommagetrennte Liste), instructorId, from und to (Unterrichtsdaten) und updatedSince (ein beliebiger ISO-8601-Zeitpunkt; einschließlich). Die Seiten sind Keyset-basiert auf (updatedAt, id): Folge nextCursor, bis er null ist. Der Cursor ist opak und läuft nie ab, aber er gehört zu den Filtern, mit denen er erzeugt wurde – änderst du sie, fängst du von vorn an; ein ausgedachter ist ein 400.

Das Muster für eine nächtliche oder stündliche Synchronisation ist dasselbe: Merke dir das neueste updatedAt, das du gesehen hast, und frage alles seitdem ab, genau so, wie du es erhalten hast. Webhooks sagen dir in dem Moment Bescheid, in dem sich etwas ändert; dieser Endpoint ist der Weg, nach einem Ausfall aufzuholen. updatedAt bewegt sich auch bei Änderungen, die kein Ereignis senden – der Unterricht wird upcoming oder in_progress, eine Erinnerung wurde gesendet, ein Treffpunkt vorgeschlagen –, sodass eine Zeile in einer Synchronisation auftauchen kann, ohne dass sich sichtbar etwas geändert hat.

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

Eine Anfrage beantworten

Aktionen stehen der Skischule bei Skilehrern offen, die sie verwaltet, und bei ihrem eigenen Platz für Unterricht ohne zugewiesenen Skilehrer; bei einem Skilehrer, der sich selbst verwaltet, lautet die Antwort 403 not_managed_by_school. Jede Aktion ist ein bedingtes Update: Ist die Buchung nicht mehr in dem Zustand, den die Aktion erwartet, lautet die Antwort 409 booking_state mit dem aktuellen Zustand in details, und nichts ändert sich. Eine Aktion zu wiederholen ist deshalb ungefährlich, und der Weg zu bestätigen, was passiert ist, ist GET /bookings/{id}.

EndpointRegel
POST /bookings/{id}/acceptAus pending; der Unterricht darf nicht begonnen haben; jede aktive Buchung, die sich mit dem Zeitfenster überschneidet (ihr Puffer eingeschlossen, bezahlte Anfragen ebenso), ist ein 409 slot_conflict
POST /bookings/{id}/rejectAus pending oder change_proposed; erstattet dem Kunden den vollen Betrag
POST /bookings/{id}/propose-timedate, startTime, optional message; nur aus pending, ein Vorschlag auf einmal. Der Kunde nimmt ihn an, lehnt ihn ab oder lässt ihn ablaufen; das ursprüngliche Zeitfenster bleibt gehalten und das vorgeschlagene nicht
POST /bookings/{id}/cancelEin bestätigter, bevorstehender oder laufender Unterricht, der noch nicht geendet hat; erstattet dem Kunden den vollen Betrag
POST /bookings/{id}/assigninstructorId im Body; nur ein Unterricht mit unassigned: true, aus pending, confirmed oder upcoming. Der Skilehrer muss in deinem Kader sein, aktiv und verifiziert, diese Disziplin in diesem Skigebiet unterrichten, in dieser Währung abrechnen, so viele Teilnehmer und diese Dauer zulassen und zu der Zeit frei sein; seine veröffentlichten Zeiten werden nicht geprüft – das ist deine Entscheidung. Die Frist bewegt sich nicht; ab hier antwortet, wer den Skilehrer verwaltet. Kunde und Skilehrer werden benachrichtigt, und ein booking.updated mit reason instructor_assigned folgt
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?" }'

Zwei Dinge fehlen hier mit Absicht: ein Nichterscheinen vermerken und einen Unterricht abschließen. Beide bewegen Geld aufgrund dessen, was auf der Piste passiert ist, und beide bleiben eine menschliche Handlung im Dashboard.

Webhooks

Registriere eine https-URL, und wir senden jedes Buchungsereignis per POST dorthin – dasselbe JSON, das die Buchungs-Endpoints liefern, verpackt in ein Ereignis. Bis zu zehn Endpoints pro Skischule, jeder abonniert einige Ereignisse oder mit "*" alle. Registriere sie aus dem Dashboard oder über die API; das Dashboard zeigt in beiden Fällen das Zustellprotokoll. Die URL muss https sein, mit einem Hostnamen, der einen Punkt enthält, ohne Zugangsdaten oder Fragment, und weder eine private Adresse noch unsere.

EndpointWirkung
GET /webhooksDeine Endpoints, Secrets eingeschlossen
POST /webhooksurl, events (1–20, oder ["*"]), optional description; 201 mit dem Secret
PATCH /webhooks/{id}Beliebige von url, events, description, active; Wiedereinschalten setzt den Fehlerzähler zurück
DELETE /webhooks/{id}Entfernt ihn samt Zustellprotokoll
POST /webhooks/{id}/rotate-secretEin neues Secret; das alte stirbt sofort
POST /webhooks/{id}/testEin Ping, jetzt zugestellt; meldet, wie es gelaufen ist
GET /webhooks/{id}/deliveriesDie letzten hundert Zustellungen, ohne Bodys
POST /webhooks/{id}/deliveries/{deliveryId}/replayDasselbe Ereignis noch einmal als neue Zustellung, jetzt gesendet
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

Ereignisse. booking.requested – eine bezahlte Anfrage ist eingetroffen. booking.confirmed – vom Skilehrer oder von der Skischule angenommen, automatisch bestätigt, oder der Kunde hat einen vorgeschlagenen Termin angenommen: acceptedVia sagt, was davon, und previousStatus ist im letzten Fall change_proposed. booking.time_proposed – dem Kunden wurde ein anderer Termin vorgeschlagen. booking.rejected. booking.cancelled – cancelledBy ist customer, instructor, school oder platform (das Konto hinter der Buchung wurde gelöscht). booking.expired – niemand hat rechtzeitig geantwortet. booking.completed – der Unterricht ist zu Ende, Nichterscheinen eingeschlossen. booking.updated – alles andere, was sich zu melden lohnt, mit data.reason: proposal_declined, meeting_point_proposed, meeting_point_confirmed, dispute_opened, dispute_resolved, refund_settled (das Geld ist wieder beim Kunden), refund_issued (von uns) oder instructor_assigned (previousInstructorId nennt den Platz, auf dem der Unterricht lag). ping ist das Test-Ereignis, mit leerem data; es ist kein Typ, den du abonnierst. Für upcoming, in_progress, Kader- oder managedBy-Änderungen wird kein Ereignis ausgelöst.

Was dich erwartet. Die Zustellung erfolgt mindestens einmal: Eine Antwort, die nach unserem Timeout von zehn Sekunden eintrifft, zählt als Fehlschlag, und das Ereignis kommt erneut; behandle also die Ereignis-Id als Schlüssel und ignoriere Wiederholungen. Die Reihenfolge ist nicht garantiert – Wiederholungsversuche und der Durchlauf ordnen Ereignisse um –, sortiere also nach data.booking.updatedAt. Der Payload wird beim Erzeugen des Ereignisses eingefroren; ein Wiederholungsversuch trägt, was damals galt, und ein späteres Ereignis den neueren Zustand. Ein Ereignis kann verloren gehen, wenn unser Versuch, es aufzuzeichnen, fehlschlägt; GET /bookings?updatedSince ist das Sicherheitsnetz. Jedes Ereignis trägt sandbox: true oder false. Das Protokoll bewahrt Zustellungen dreißig Tage auf; eine erneute Zustellung sendet eine vergangene noch einmal.

Zustellungen verifizieren

Jede Zustellung ist mit dem Secret des Endpoints signiert: Der Header X-AlpinePal-Signature trägt einen Zeitstempel und einen HMAC-SHA256 über den Zeitstempel, einen Punkt und den rohen Body. Verifiziere sie, bevor du irgendetwas vertraust, vergleiche in konstanter Zeit und lehne Zeitstempel ab, die mehr als fünf Minuten von deiner Uhr entfernt sind. Das Secret wird beim Erstellen des Endpoints und von GET /webhooks zurückgegeben und kann aus dem Dashboard oder mit POST /webhooks/{id}/rotate-secret rotiert werden; das alte stirbt sofort.

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

Antworte mit einem beliebigen 2xx innerhalb von zehn Sekunden. Alles andere – ein 3xx eingeschlossen, denn wir folgen keinen Weiterleitungen – ist ein Fehlschlag: Wir versuchen es erneut nach 5 Minuten, 15 Minuten, 1 Stunde, 4 Stunden, 12 Stunden und 24 Stunden, insgesamt etwa 42 Stunden, jedes Mal mit derselben Ereignis-Id, damit du deduplizieren kannst. Ein Endpoint, der zehn Versuche hintereinander fehlschlägt, wird abgeschaltet, und die Verwalter der Skischule werden benachrichtigt; seine ausstehenden Zustellungen werden dann als fehlgeschlagen markiert und beim Wiedereinschalten nicht gesendet – stelle die erneut zu, die du brauchst. POST /webhooks/{id}/test sendet sofort einen Ping und meldet, wie es gelaufen ist, und GET /webhooks/{id}/deliveries zeigt die letzten hundert Zustellungen (eine pro Ereignis und Endpoint, mit ihrer Versuchszahl) ohne ihre Bodys.

Testumgebung

Jede Skischule bekommt einen Testzwilling, sobald ihr Inhaber im Dashboard einen Test-Schlüssel erzeugt (Verbinde dein Buchungssystem → Testumgebung): eine Kopie der Skischule mit zwei erfundenen Skilehrern – sandbox_ia_…, der Anfragen von Hand beantwortet, und sandbox_ib_…, mit eingeschalteter Sofortbestätigung – und dem Platz für Unterricht ohne zugewiesenen Skilehrer, jeder mit einem Kalender von 09:00–17:00 an jedem Tag in den Skigebieten der Skischule, 1 bis 6 Stunden pro Unterricht, bis zu 6 Teilnehmer, ohne Puffer und ohne Vorlaufzeit. Test-Schlüssel beginnen mit ap_test_ und erreichen nur den Zwilling; Live-Schlüssel nie. Alles andere ist dieselbe API: die Skilehrer des Zwillings auflisten, ihre Kalender schreiben, Webhooks registrieren, Anfragen beantworten, Unterricht zuweisen.

Was ein Kunde auf der Website tut, erledigt POST /sandbox/bookings für dich: Der Aufruf erstellt eine bezahlte Buchung für den Skilehrer, den du nennst, mit denselben Prüfungen wie eine echte – veröffentlichte Verfügbarkeit, keine Überschneidung – und ohne Geld im Spiel. Von da an läuft die echte Maschinerie, und die Aktionen unten vertreten den Kunden und die Uhr, sodass sich jedes Ereignis in Minuten beobachten lässt. Keine E-Mail verlässt die Testumgebung, und nichts darin ist je öffentlich; jedes Ereignis, das sie sendet, trägt sandbox: true, ebenso GET /me. POST /sandbox/reset leert sie, damit eine Testsuite sauber starten kann.

EndpointHinweise
POST /sandbox/bookingsinstructorId (ein Skilehrer des Zwillings oder sein Platz), date, startTime, durationHours (innerhalb der Grenzen des Skilehrers); optional numberOfStudents, resortId, discipline, language, customerMessage. 201 mit der Buchung; 403 not_sandbox mit einem Live-Schlüssel
POST /sandbox/bookings/{id}/accept-proposal · decline-proposalDie Antwort des Kunden auf einen vorgeschlagenen Termin
POST /sandbox/bookings/{id}/cancelDer Kunde storniert, nach denselben Regeln wie auf der Website
POST /sandbox/bookings/{id}/completeWas die Bestätigung des Kunden oder der Durchlauf 48 Stunden nach dem Unterricht tut: completed und dein Anteil freigegeben
POST /sandbox/bookings/{id}/expireWas der Durchlauf nach Ablauf der Frist tut: expired und erstattet
POST /sandbox/resetLöscht die Buchungen, Chats und Guthabenbewegungen des Zwillings; Schlüssel, Webhooks, Kalender und das Zustellprotokoll bleiben
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 }'

In der Testumgebung weiterhin außer Reichweite: Reklamationen, Nichterscheinen, eine von uns ausgestellte Erstattung, eine Stornierung mit cancelledBy platform und der Treffpunkt (seine Ereignisse kommen von der Website). Buchungen der Testumgebung tragen ein leeres studentLevels und einen Kunden namens Sandbox C., der die Website auf Englisch liest.

OpenAPI

Die maschinenlesbare Beschreibung liegt unter https://alpinepal.com/api/v1/openapi.json – jeder Pfad, jede Methode, jeder Parameter und jedes Schema von oben, in OpenAPI 3.1. Richte einen Generator darauf, um einen typisierten Client zu bekommen, oder importiere sie in dein HTTP-Werkzeug zum Stöbern. Sie wird ohne Authentifizierung ausgeliefert und eine Stunde lang gecacht.

Versionen und Änderungen

Das ist v1. Innerhalb davon fügen wir nur hinzu: neue Felder, neue optionale Parameter, neue Ereignistypen, neue Endpoints. Deine Integration sollte Felder und Ereignisse ignorieren, die sie nicht kennt. Alles, was einen bestehenden Client brechen würde, kommt in eine v2 unter einer neuen Basis-URL, wobei v1 weiterläuft, während du umziehst.

  • 2026-09 — v1: Skischule, Skigebiete, Skilehrer, Verfügbarkeit und erweiterte Ansicht, externe Blöcke, Buchungen mit Keyset-Paginierung, accept / reject / propose-time / cancel, signierte Webhooks mit Wiederholungsversuchen.

  • 2026-09-18 — hourlyPrice und extraStudentPrice auf PATCH /instructors/{id}; der Platz für Unterricht ohne zugewiesenen Skilehrer unter /unassigned und POST /bookings/{id}/assign; reason und previousInstructorId auf booking.updated; die Testumgebung, ap_test_-Schlüssel und sandbox in jedem Ereignis.

  • 2026-09-18 — Überarbeitung: booking_state trägt den aktuellen Zustand; updatedSince akzeptiert jeden ISO-Zeitpunkt; die Erstattung einer Buchung ohne Zahlung sendet ebenfalls refund_settled; ein booking.completed pro Unterricht; assign prüft die Grenzen des Skilehrers; meeting_point_proposed; POST /webhooks/{id}/deliveries/{deliveryId}/replay; die Kunden- und Uhr-Aktionen der Testumgebung; diese Seite rund um das, was der Code tut, neu geschrieben.

  • 2026-09-20 — PATCH /instructors/{id} schreibt seine Felder zusammen oder gar nicht; ein Block, der mit 201 beantwortet wird, setzt sich jetzt gegen eine Bezahlung durch, die auf derselben Stunde im Gang war (der Kunde wird erstattet, du erhältst booking.expired).