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.
| Header | Wo |
|---|---|
Authorization: Bearer <Schlüssel> | Jede Anfrage |
Content-Type: application/json | Jede Anfrage mit Body |
X-Request-Id | Jede Antwort, Fehler eingeschlossen; nenne sie, wenn du uns schreibst |
Cache-Control: no-store, Vary: Authorization | Jede Antwort |
WWW-Authenticate: Bearer realm="alpinepal-api" | 401-Antworten |
Allow | 405-Antworten |
Retry-After (Sekunden) | 429-Antworten; der Body wiederholt es als details.retryAfterSeconds |
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}.
| Status | Codes | Wann |
|---|---|---|
400 | validation_error, invalid_cursor, invalid_json | Die Anfrage selbst ist fehlerhaft |
401 | unauthenticated, invalid_api_key | Kein Schlüssel, oder ein fehlerhafter, unbekannter, widerrufener oder zur falschen Umgebung gehörender |
403 | forbidden, school_not_active, not_managed_by_school, not_sandbox | Der Schlüssel ist in Ordnung, aber diese Aktion steht dir nicht zu; not_sandbox ist ein Live-Schlüssel auf einem Endpoint der Testumgebung |
404 | not_found | Nicht vorhanden, oder keiner deiner Skilehrer. Wir bestätigen nie, dass etwas außerhalb deiner Skischule existiert |
405 | method_not_allowed | Falsche Methode für den Pfad; Allow listet die richtigen auf |
409 | booking_state, slot_conflict, booking_conflict, external_ref_exists, concurrent_update, not_unassigned, not_on_sale, cancel_window_closed, lesson_started, lesson_ended | Ein echter Zustandskonflikt; lies den aktuellen Zustand und entscheide neu |
413, 415 | payload_too_large, unsupported_media_type | Bodys müssen JSON und unter 256 KB sein |
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 | Wohlgeformt, aber verstößt gegen eine Regel des Kalenders, des Unterrichts oder der Skischule |
429 | rate_limited | Siehe Retry-After |
500 | internal | Unser Fehler; die Request-Id hilft uns, ihn zu finden |
{
"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.
| Feld | Werte |
|---|---|
bookingStatus | Siehe die Tabelle unter Buchungen |
paymentStatus | pending, processing, succeeded, failed, authentication_required, refund_pending, refunded – nur succeeded und die beiden Erstattungszustände erreichen die API überhaupt |
acceptedVia | instructor, school, auto, customer, oder null, solange nicht angenommen |
studentLevels | Ein Eintrag pro Teilnehmer: { level: beginner | intermediate | advanced, age: adult | child }; leer in der Testumgebung |
discipline | ski oder snowboard – die des Unterrichts; die disciplines eines Skilehrers können auch both sagen |
language / customer.language | Die Sprache des Unterrichts als Zwei-Buchstaben-Code, und die Sprache, in der der Kunde die Website nutzt (en, es, it) |
meetingPoint | status not_agreed, proposed oder confirmed, plus title, sobald einer existiert |
verificationStatus (Skilehrer) | draft, pending_review, verified, rejected |
Deine Skischule, Skigebiete und Skilehrer
| Endpoint | Liefert |
|---|---|
GET / | Der Deskriptor: Name, Version, Links zu dieser Seite und zur Spezifikation (kein Schlüssel nötig) |
GET /me | Deine Skischule: id, slug, name, currency, country und sandbox (true hinter einem Test-Schlüssel) |
GET /resorts | Jedes Skigebiet, in dem einer deiner Skilehrer oder dein Platz für Unterricht ohne zugewiesenen Skilehrer unterrichtet, mit Zeitzone und Währung |
GET /instructors | Dein 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.
{
"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
{
"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.
| Endpoint | Hinweise |
|---|---|
GET /instructors/{id}/blocks | Optionale Filter from, to, externalRef |
POST /instructors/{id}/blocks | date, 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 |
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.
| Endpoint | Entspricht |
|---|---|
GET, PUT /unassigned/availability | GET, PUT /instructors/{id}/availability |
GET /unassigned/availability/expanded | Die 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}/assign | instructorId im Body: verschiebt einen Unterricht ohne zugewiesenen Skilehrer zu jemandem aus deinem Kader – siehe Eine Anfrage beantworten |
{
"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.
| bookingStatus | Bedeutung |
|---|---|
pending_instructor_approval | Bezahlt, wartet auf eine Antwort vor instructorResponseDeadline |
change_proposed | Ein 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 |
confirmed | Angenommen (acceptedVia sagt, von wem: instructor, school, auto, customer) |
upcoming, in_progress | Bestätigt und kurz vor dem Unterricht oder währenddessen |
completed | Abgeschlossen; dein Anteil am Geld ist freigegeben |
rejected_by_instructor, expired | Vollständig erstattet |
cancelled_by_customer, cancelled_by_instructor | Storniert; Erstattung gemäß Stornierungsbedingungen |
no_show_customer, no_show_instructor, disputed, refunded | Aus dem Dashboard oder von uns gesetzt |
{
"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.
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}.
| Endpoint | Regel |
|---|---|
POST /bookings/{id}/accept | Aus 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}/reject | Aus pending oder change_proposed; erstattet dem Kunden den vollen Betrag |
POST /bookings/{id}/propose-time | date, 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}/cancel | Ein bestätigter, bevorstehender oder laufender Unterricht, der noch nicht geendet hat; erstattet dem Kunden den vollen Betrag |
POST /bookings/{id}/assign | instructorId 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 |
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.
| Endpoint | Wirkung |
|---|---|
GET /webhooks | Deine Endpoints, Secrets eingeschlossen |
POST /webhooks | url, 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-secret | Ein neues Secret; das alte stirbt sofort |
POST /webhooks/{id}/test | Ein Ping, jetzt zugestellt; meldet, wie es gelaufen ist |
GET /webhooks/{id}/deliveries | Die letzten hundert Zustellungen, ohne Bodys |
POST /webhooks/{id}/deliveries/{deliveryId}/replay | Dasselbe Ereignis noch einmal als neue Zustellung, jetzt gesendet |
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…e2a9Ereignisse. 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.
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.
| Endpoint | Hinweise |
|---|---|
POST /sandbox/bookings | instructorId (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-proposal | Die Antwort des Kunden auf einen vorgeschlagenen Termin |
POST /sandbox/bookings/{id}/cancel | Der Kunde storniert, nach denselben Regeln wie auf der Website |
POST /sandbox/bookings/{id}/complete | Was die Bestätigung des Kunden oder der Durchlauf 48 Stunden nach dem Unterricht tut: completed und dein Anteil freigegeben |
POST /sandbox/bookings/{id}/expire | Was der Durchlauf nach Ablauf der Frist tut: expired und erstattet |
POST /sandbox/reset | Löscht die Buchungen, Chats und Guthabenbewegungen des Zwillings; Schlüssel, Webhooks, Kalender und das Zustellprotokoll bleiben |
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).