Für Entwickler:innen und KI-Agenten

Ballbuddy für Agenten.

Der Ballkalender von Ballbuddy ist als öffentliche, nur lesende API erreichbar: Lesen ohne Schlüssel, mit offenem CORS. Diese Seite beschreibt, welche Daten du bekommst, wie du sie abfragst, wo die maschinenlesbaren Beschreibungen liegen, und den einen Schreibpfad: eine Kontaktanfrage, mit Token.

Was Ballbuddy ist und welche Daten es gibt

Ballbuddy ist die Plattform für Maturaball-Komitees in Österreich, ein Projekt von Framed aus Graz. Öffentlich sichtbar ist der Ballkalender mit einer eigenen Seite je Ball. Das Komitee arbeitet dahinter in einer eigenen App. Die ist für Agenten nicht erreichbar und soll es auch nicht sein.

Die API führt drei Arten von Daten: Bälle (Motto, Termin, Schule, Location, Stadt, Bundesland, Ticket-Hinweis, Cover), Schulen und Locations. Alle drei sind öffentlich und redaktionell gepflegt: Komitees tragen ihren Ball selbst ein, wir prüfen und ergänzen. Wie viele Bälle gerade drin sind, sagt dir das Feld total in jeder Listenantwort. Der Bestand ändert sich täglich, deshalb steht hier keine Zahl.

Die Discovery-Dateien und die API gibt es nur auf ballbuddy.at; die anderen Flächen (app., partner., admin.) antworten darauf mit 404.

Grundregeln für alle Endpunkte

  • Nur GET. Der einzige Schreibpfad ist die Kontaktanfrage mit Token. Antworten sind JSON in UTF-8 (der Kalendereintrag ist text/calendar), CORS ist offen: Access-Control-Allow-Origin: *.
  • Origin-Limit: 600 je Stunde und IP für /api/public/v1/*, /api/ics/* und /api/mcp, gezählt am Origin hinter dem Cache. Wiederholte Aufrufe derselben Adresse zählen nicht. Darüber 429 mit { "error": "rate_limited" } und Retry-After; mit Token 6000 je Stunde je Client (unten).
  • Zeiten sind ISO-8601 in UTC (starts_at, ends_at, doors_at, updated_at). Für die Anzeige gehört alles nach Europe/Vienna.
  • Eine Saison heißt nach ihrem Endjahr: season=2027 ist die Saison 2026/27.
  • Fehler kommen als JSON: 400 mit { "error": "invalid_params", "issues": [{ "path", "message" }] } bei ungültigen Parametern, 404 mit { "error": "not_found" } bei unbekanntem Slug.
  • Der Slug eines Balls ist ein Vertrag: Alte Slugs leiten mit 301 auf den aktuellen weiter, in der API wie auf der Ball-Seite unter https://ballbuddy.at/ball/<slug>.

Die sieben Endpunkte

Die Namen in Klammern sind die operationIds aus der OpenAPI-Beschreibung. Dieselben stehen als capabilities im ai-catalog.

Bälle suchen GET /api/public/v1/balls (searchBalls)

Alle Parameter sind optional: season (Endjahr; ohne from, to und q gilt die laufende Saison), state (Bundesland, exakt einer von Burgenland, Kärnten, Niederösterreich, Oberösterreich, Salzburg, Steiermark, Tirol, Vorarlberg, Wien), city, school (Slug), venue (Slug), from und to (JJJJ-MM-TT als Wiener Kalendertag, from inklusive, to exklusive), q (Freitext über Motto, Schule und Stadt, mindestens zwei Zeichen), page (ab 1) und per (1–100, Standard 48). Sortiert wird nach starts_at aufsteigend. filters in der Antwort nennt die gültigen Werte für den nächsten Aufruf: Saisonen, Bundesländer, Städte, Schulen und Locations mit Zählern.

curl -s "https://ballbuddy.at/api/public/v1/balls?season=2027&state=Steiermark&per=10"

Antwort, gekürzt. Beispielwerte, kein Live-Abruf:

{
  "items": [
    {
      "id": "…",
      "slug": "hak-weiz-2027",
      "url": "https://ballbuddy.at/ball/hak-weiz-2027",
      "title": "Casino Royale",
      "starts_at": "2027-01-16T19:00:00+00:00",
      "ends_at": null,
      "doors_at": null,
      "season": 2027,
      "status": "scheduled",
      "city": "Weiz",
      "state": "Steiermark",
      "school": { "name": "HAK Weiz", "slug": "hak-weiz" },
      "venue": { "name": "Stadthalle Weiz", "slug": "stadthalle-weiz", "address": "…", "zip": "8160", "city": "Weiz" },
      "venue_text": null,
      "instagram": "hakweiz.ball",
      "tickets": { "mode": "link", "url": "https://…" },
      "cover": { "url640": "https://…/640.webp", "url1280": "https://…/1280.webp", "blur": "data:image/webp;base64,…" },
      "is_claimed": false,
      "updated_at": "2026-09-05T08:00:00+00:00",
      "has_motto": true,
      "time_known": true,
      "schools": [{ "name": "HAK Weiz", "slug": "hak-weiz" }]
    }
  ],
  "total": …,
  "page": 1,
  "per": 10,
  "filters": {
    "seasons": [2025, 2026, 2027, 2028, 2029],
    "states": ["Burgenland", "…"],
    "cities": ["Graz", "…"],
    "schools": [{ "slug": "hak-weiz", "name": "HAK Weiz", "count": 1 }],
    "venues": [{ "slug": "stadthalle-weiz", "name": "Stadthalle Weiz", "count": 2 }]
  }
}

status ist scheduled, cancelled oder postponed. tickets ist null, wenn das Komitee keine Ticket-Infos gesetzt hat; mode ist link, instagram oder info. Bei instagram und info ist url null, die Details stehen auf der Ball-Seite. cover ist null, solange kein Bild da ist.

Ein Ball im Detail GET /api/public/v1/balls/{slug} (getBall)

Wie ein Listeneintrag, zusätzlich description, links ([{ type, label?, url }]), tanzschule, disco, band und ticket_types ([{ id, name, price_presale_cents, price_door_cents, sort }], nur öffentliche Kategorien, Preise in Cent). Unbekannte Slugs antworten 404, alte Slugs leiten mit 301 auf die aktuelle Adresse weiter.

curl -s "https://ballbuddy.at/api/public/v1/balls/hak-weiz-2027"

Antwort, gekürzt. Beispielwerte, kein Live-Abruf:

{
  "slug": "hak-weiz-2027",
  "title": "Casino Royale",
  "starts_at": "2027-01-16T19:00:00+00:00",
  … (alle Felder des Listeneintrags), dazu:
  "description": "…",
  "links": [{ "type": "…", "label": "…", "url": "https://…" }],
  "tanzschule": "…",
  "disco": null,
  "band": null,
  "ticket_types": [{ "id": "…", "name": "…", "price_presale_cents": …, "price_door_cents": …, "sort": 0 }]
}

Schulen GET /api/public/v1/schools (listSchools)

Alle Schulen mit Slug, Schultyp, Stadt, Bundesland, Website, Instagram und ball_count (zählt nur veröffentlichte Bälle), sortiert nach Name. Der slug ist der Wert für den Filter school= bei der Ballsuche.

curl -s "https://ballbuddy.at/api/public/v1/schools"

Antwort, gekürzt. Beispielwerte, kein Live-Abruf:

{
  "items": [
    { "id": "…", "name": "HAK Weiz", "slug": "hak-weiz", "school_type": "…", "city": "Weiz", "state": "Steiermark", "website": "https://…", "instagram": null, "ball_count": 1 }
  ],
  "total": …
}

Locations GET /api/public/v1/venues (listVenues)

Alle Locations mit Adresse, Postleitzahl, Stadt, Bundesland, Website, Koordinaten (lat, lng) und ball_count. Der slug ist der Wert für den Filter venue=.

curl -s "https://ballbuddy.at/api/public/v1/venues"

Antwort, gekürzt. Beispielwerte, kein Live-Abruf:

{
  "items": [
    { "id": "…", "name": "Stadthalle Weiz", "slug": "stadthalle-weiz", "address": "…", "zip": "8160", "city": "Weiz", "state": "Steiermark", "website": null, "lat": …, "lng": …, "ball_count": 2 }
  ],
  "total": …
}

Alle Ball-Seiten GET /api/public/v1/sitemap (listBallUrls)

Die Adressen aller veröffentlichten Ball-Seiten mit lastModified. Wenn du Ball-Seiten verlinken oder gezielt abrufen willst, ist das die vollständige Liste, kürzer als die Ballsuche und ohne Paginierung.

curl -s "https://ballbuddy.at/api/public/v1/sitemap"

Antwort, gekürzt. Beispielwerte, kein Live-Abruf:

{
  "items": [
    { "url": "https://ballbuddy.at/ball/hak-weiz-2027", "lastModified": "2026-09-05T08:00:00+00:00" }
  ],
  "total": …
}

Kalendereintrag GET /api/ics/{slug} (getBallIcs)

Ein iCalendar-Eintrag (text/calendar) je Ball mit zwei Erinnerungen (eine Woche und ein Tag vorher), eine Stunde cachebar, 404 bei unbekanntem Slug. Bei has_motto: false heißt der Eintrag „Maturaball <Schule>“, bei time_known: false ist er ganztägig. Du musst dafür nichts selbst rechnen.

curl -s "https://ballbuddy.at/api/ics/hak-weiz-2027"

Antwort, gekürzt. Beispielwerte, kein Live-Abruf:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Ballbuddy//Ballkalender//DE
BEGIN:VEVENT
UID:…@ballbuddy.at
DTSTART:20270116T190000Z
DTEND:…
SUMMARY:Maturaball HAK Weiz: Casino Royale
LOCATION:Stadthalle Weiz\, 8160 Weiz
URL:https://ballbuddy.at/ball/hak-weiz-2027
BEGIN:VALARM
TRIGGER:-P7D
…
END:VEVENT
END:VCALENDAR

Status GET /api/health (getHealth)

Sagt nur, ob der Dienst läuft: Cache-Control: no-store, keine Daten. Zum Prüfen, ob sich Daten geändert haben, ist das der falsche Weg; dafür gibt es den ETag (unten).

curl -s "https://ballbuddy.at/api/health"

Antwort, gekürzt. Beispielwerte, kein Live-Abruf:

{ "ok": true, "service": "ballbuddy", "ts": "2026-09-05T08:00:00.000Z" }

Cache und ETag: höflich pollen

Alle Antworten der Public-API tragen Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400 und einen schwachen ETag. Merk dir den ETag und schick ihn beim nächsten Abruf als If-None-Match mit. Hat sich nichts geändert, bekommst du 304 Not Modified ohne Body. Einmal pro Stunde je Abfrage reicht; die Daten ändern sich täglich, nicht minütlich. Ein sprechender User-Agent mit Kontaktmöglichkeit ist erwünscht, zum Beispiel MeinAgent/1.0 (+https://example.org/bot).

curl -s -i "https://ballbuddy.at/api/public/v1/balls/hak-weiz-2027" | grep -i '^etag'
# ETag: W/"…"
curl -s -o /dev/null -w '%{http_code}' -H 'If-None-Match: W/"…"' "https://ballbuddy.at/api/public/v1/balls/hak-weiz-2027"
# 304

Datenlücken: has_motto, time_known und schools

Drei Felder verhindern die typischen Fehler beim Anzeigen. Dieselbe Erklärung steht im Skill ballbuddy-ballkalender, weil ein Agent selten beides liest:

  • has_motto: false ⇒ title ist generisch („HLW Weiz Maturaball“, „Maturaball HLW Weiz 2027“) und kein Motto. Zeig dann die Schule (schools[0].name, bei mehreren „A × B“) als Titel und „Motto folgt“ als Untertitel, so macht es ballbuddy.at selbst. title bleibt unverändert, url und slug gelten weiter.
  • time_known: false ⇒ die Uhrzeit ist unbekannt (starts_at trägt einen Platzhalter). Nur das Datum von starts_at in Europe/Vienna verwenden, keine Uhrzeit anzeigen, Kalender-Einträge ganztägig anlegen; ends_at und doors_at dann ignorieren.
  • schools: alle beteiligten Schulen, Hauptschule zuerst (nie leer, wenn school gesetzt ist). Gemeinsame Bälle (HTL × Modeschule) haben mehr als einen Eintrag; school bleibt die Hauptschule. Der Filter school=<slug> trifft jede beteiligte Schule.

Was die API nicht kann

  • Schreiben nur eine Kontaktanfrage, mit Token. Keine Bälle anlegen oder ändern, keine Erinnerungen, keine Übernahme-Anfragen. Das machen Menschen auf der Ball-Seite. Der einzige Schreibpfad ist die Kontaktanfrage, und die braucht einen Token.
  • Keine Ticketkäufe. tickets.url und ticket_types sind Hinweise; verkauft wird beim Komitee.
  • Keine personenbezogenen Daten. Keine Mitglieder, Kontakte oder E-Mail-Adressen. Die Komitee-App liegt hinter einem Login und hat keine öffentliche Schnittstelle.
  • Keine internen Felder. Prüf- und Importfelder (Quelle, Referenzen, Prüf-Flags) sind nicht Teil der API.
  • Kein Token fürs Lesen. Lesen ist frei bis 600 je Stunde und IP am Origin. Ein Token kauft genau zwei Dinge: die Kontaktanfrage und 6000 je Stunde je Client, keine zusätzlichen Daten. auth.md sagt genau das.

Schreiben mit Token: eine Kontaktanfrage

Registrierte Agenten dürfen eine Kontaktanfrage stellen, auf demselben Weg, den sonst nur das Kontaktformular kennt: dieselbe Prüfung, dieselbe Mail ans Team, dieselbe Bestätigung an die Person. Der Zugang ist Machine-to-Machine (OAuth 2.0 client_credentials, RFC 6749 §4.4), ohne User-Login. Ohne Token antwortet POST https://ballbuddy.at/api/agents/v1/kontakt mit 401 und WWW-Authenticate: Bearer resource_metadata="https://ballbuddy.at/.well-known/oauth-protected-resource".

1. Client registrieren (einmalig, RFC 7591)

client_name (3–200 Zeichen) und contact (eine E-Mail-Adresse, damit wir uns bei Problemen melden können) sind Pflicht. Das client_secret kommt genau einmal im Klartext. Wir speichern nur einen Hash. Limit: 3 je Tag und IP.

curl -X POST https://ballbuddy.at/api/oauth/register \
  -H "Content-Type: application/json" \
  -d '{"client_name": "mein-agent", "contact": "[email protected]"}'
# 201 { "client_id": "…", "client_secret": "…", "token_endpoint": "https://ballbuddy.at/api/oauth/token", … }

2. Token holen (gültig 1 h)

Credentials per HTTP Basic (client_secret_basic) oder als client_id/client_secret im Body (client_secret_post), optional scope (Standard: alle Scopes). Fehler nach RFC 6749: 400 unsupported_grant_type | invalid_request | invalid_scope, 401 invalid_client. Limit: 30 je Stunde und IP.

curl -X POST https://ballbuddy.at/api/oauth/token \
  -u "CLIENT_ID:CLIENT_SECRET" \
  -d "grant_type=client_credentials"
# 200 { "access_token": "…", "token_type": "Bearer", "expires_in": 3600, "scope": "kontakt.write baelle.read" }

3. Kontaktanfrage stellen

JSON-Felder: thema (allgemein | support | partner | presse), vorname, nachname, email, schule (optional), message (10–4000 Zeichen), consent (muss true sein: die Person hat der Datenschutzerklärung zugestimmt), app (optional, Slug einer Leistungsseite). Honeypot und Zeitfalle des Formulars ersetzt der Token. Antworten: 200 { "ok": true, "message": "…" }, 400 invalid_request (kein JSON), 422 validation_failed mit issues[{ path, message }], 429 rate_limited. Bitte nur mit Wissen der Person. Sie bekommt eine Bestätigungsmail.

curl -X POST https://ballbuddy.at/api/agents/v1/kontakt \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"thema": "allgemein", "vorname": "Anna", "nachname": "Muster", "email": "[email protected]",
       "message": "Wir sind das Komitee der HLW Weiz und hätten eine Frage zur Ball-Seite.", "consent": true}'
# 200 { "ok": true, "message": "…" }

Scopes und Kontingente

  • kontakt.write: die Kontaktanfrage, 20 je Stunde und Client, dazu die Grenzen des Formulars auf IP und E-Mail-Adresse.
  • baelle.read: Lesen wie ohne Token, aber 6000 je Stunde und Client statt 600 je Stunde und IP. Ein ungültiger oder abgelaufener Token wird nie still zu „anonym“. Er antwortet 401, damit du es merkst.

Was der Token nicht kauft: keine zusätzlichen Daten, keine anderen Felder (Antworten der Public-API sind mit und ohne Token byte-gleich, auch der ETag), keinen anderen Cache, keinen Bulk-Export. Antworten die vier OAuth-Dokumente mit 404 und /api/oauth/* mit 503, hat diese Instanz gerade keinen Signaturschlüssel. Dann gibt es vorübergehend keinen Token, Lesen geht wie immer.

MCP: dieselben Daten als Werkzeuge

Wer lieber Werkzeuge aufruft, statt Adressen zu bauen, verbindet sich mit dem MCP-Server von Ballbuddy (Model Context Protocol): Endpunkt https://ballbuddy.at/api/mcp, Transport streamable-http, kein Token nötig. Die Werkzeuge rufen dieselben Funktionen wie die REST-Endpunkte oben: gleiche Daten, gleiche Reihenfolge, gleiche Regeln zu has_motto und time_known. Geschrieben wird auch hier nichts.

  • search_balls: Maturabälle suchen: Freitext, Stadt, Bundesland, Schule, Location, Saison, Zeitraum / search Austrian Maturabälle
  • get_ball: Ein Ball im Detail per Slug: Beschreibung, Location mit Adresse, Links, Ticketkategorien / ball details by slug
  • get_ball_calendar: Kalendereintrag (iCalendar/ICS) eines Balls samt Adresse der .ics-Datei / iCalendar entry for one ball
  • list_schools_and_venues: Schulen und Locations mit Anzahl veröffentlichter Bälle, optional je Bundesland / schools and venues with ball counts

Konfiguration zum Kopieren (Claude Desktop, Cursor, MCP Inspector und andere Clients mit mcpServers):

{
  "mcpServers": {
    "ballbuddy": {
      "url": "https://ballbuddy.at/api/mcp"
    }
  }
}

Was ein Werkzeug genau erwartet und zurückgibt, sagt tools/list. Die Beschreibungen dort sind zweisprachig (Deutsch, dann Englisch). Maschinenlesbar beschreibt die Server-Card unter https://ballbuddy.at/.well-known/mcp/server-card.json Endpunkt, Transport und Werkzeuge; derselbe Eintrag steht an erster Stelle im ai-catalog.

Maschinenlesbare Beschreibungen

Prüfen: pnpm agents:verify im Repo geht diese Liste gegen eine laufende Instanz durch, eine Zeile je Zusicherung, vor jedem Deploy.

Nutzung und Quellenangabe

Du darfst die Daten in Antworten verwenden. Nenn Ballbuddy als Quelle und verlink die Ball-Seite (url aus der Antwort). Dort stehen Beschreibung, Tickets und der Weg zum Komitee, und dort landen Menschen richtig. Fragen, Fehler oder ein Feld, das dir fehlt: [email protected] oder das Kontaktformular.