Zum Inhalt springen

Webhooks und Kalender

Neben der lesenden REST-API stellen Integrationen einen Kalenderfeed und ausgehende Webhooks bereit. Beides wird unter Integrationen von Owner und Admin verwaltet. Der Stand ist Beta.

Owner und Admins erzeugen eine einmalig sichtbare URL:

https://api.orbinaut.ccl-dev.com/api/calendar/v1/YOUR_CALENDAR_TOKEN.ics

Der Token beginnt mit orb_cal_. Die URL ist mit Google Calendar, Apple Calendar und Outlook abonnierbar. Sie enthält nur veröffentlichte, geplante Kurstermine des Workspaces.

Enthalten Nicht enthalten
Kurstitel, Beschreibung, Ort Buchungen
Beginn und Ende über DTSTART/DTEND Teilnehmername und E-Mail
text/calendar; charset=utf-8 interne Online-Zugangsdaten

Alle Zeitstempel stehen in UTC und enden auf Z. Der Feed liefert keine VTIMEZONE-Komponente und keine Zeitzone je Termin. Kalenderprogramme rechnen selbst in die Anzeigezeitzone um. Die Zeitzone der Durchführung steht dagegen im REST-Feld timeZone der Sitzung (Referenz).

Der Feed enthält höchstens 1.000 Termine. Berücksichtigt werden Termine, deren Start zwischen 30 Tagen vor und ausschließlich 365 Tagen nach dem Abrufzeitpunkt liegt. Bei mehr Treffern liefert der Feed die frühesten 1.000 Termine, stabil nach Startzeit und Termin-ID sortiert.

Das Polling-Limit gilt über alle Serverinstanzen hinweg:

Grenze Budget
Je Feed 60 Abrufe in 15 Minuten
Je Client 240 Abrufe in 15 Minuten

GET und HEAD verbrauchen dasselbe Budget. Bei Überschreitung antwortet der Feed mit 429 / calendar_rate_limit_exceeded. Der Header Retry-After nennt die verbleibenden Sekunden des betroffenen Fensters. Der im Dashboard sichtbare Wert Zuletzt abgerufen wird höchstens einmal pro Stunde aktualisiert.

Antworten setzen Cache-Control: private, no-store und Content-Disposition: inline; filename="orbinaut-calendar.ics". Unbekannte, widerrufene oder rotierte Tokens sowie Pfade ohne .ics liefern 404 / calendar_not_found.

Enthält der Plan des Workspace keine Kalenderfeeds, antwortet der Feed mit 403 / capability_not_in_plan. Ein neuer Token ändert daran nichts; nötig ist ein Planwechsel (Plan und Abrechnung).

Der Feed ist kein REST-JSON-Endpunkt und steht nicht in public-v1.yaml. Google-Kalender-Synchronisation bleibt Roadmap; Version 1 liefert nur den iCal-Export.

Ausgehende Webhooks sind Beta. Der Katalog ist ein lesender Spiegel der Workspace-bezogenen Datenänderungen. Jedes Event entsteht in derselben Datenbanktransaktion wie die beschriebene Änderung. Für jeden aktiven Endpunkt, der den Typ oder * abonniert, entsteht ein eigener Outbox-Datensatz mit derselben Event-ID.

Endpunkte wählst du unter Webhooks. Alle Events (auch künftige) speichert ["*"] und empfängt alle aktuellen und zukünftigen Typen. Eine explizite Liste bleibt möglich. booking.created bleibt für bestehende Empfänger kompatibel: type, data.booking und data.course bleiben; neue Envelope- und Snapshot-Felder kommen additiv hinzu.

Nicht Teil des Katalogs: Plattform-Newsletter, Orbi-Gespräche, SaaS-Abo, Widget-Analytics, Audit-Events, Integrations-Geheimnisse und Mail-Jobzustände. Eingehende Webhooks, eine native Zapier-App und Google-Kalender-Sync bleiben Roadmap.

Typ Auslöser Nur mit Teilnehmerdaten mitsenden
contact.created Kontakt angelegt name, email, phone, postalCode, city, countryCode
contact.updated Kontakt geändert; data.changes listet geänderte Felder dieselben Felder
contact.deleted Kontakt gelöscht keine; Payload enthält nur id
course.created Kursdurchführung angelegt keine
course.updated Kurs geändert ohne Statuswechsel keine
course.published Kurs veröffentlicht keine
course.ended Kurs beendet keine
course.cancelled Kurs abgesagt keine
course.deleted Kurs gelöscht keine
session.created Termin angelegt keine
session.updated Termin geändert keine
session.cancelled Termin abgesagt keine
session.reactivated Termin reaktiviert keine
session.deleted Termin gelöscht keine
session_trainer.assigned Trainer:in einem Termin zugewiesen keine
session_trainer.unassigned Trainer:in von einem Termin entfernt keine
booking.created Buchung angelegt, auch mit Status waitlisted oder pending participant, billing
booking.confirmed Buchung bestätigt participant, billing
booking.cancelled Buchung storniert participant, billing
booking.expired Reservierung oder anonyme Warteliste abgelaufen participant, billing
booking.waitlisted zusätzlich zu booking.created, wenn der Status waitlisted ist participant, billing
booking.waitlist_promoted Nachrücken von der Warteliste participant, billing
booking.updated Buchung geändert ohne Statuswechsel participant, billing
attendance.recorded Anwesenheit erfasst keine
participation_confirmation.issued Teilnahmebestätigung ausgestellt participant
participation_confirmation.revoked Teilnahmebestätigung widerrufen participant
package.created Paket angelegt keine
package.updated Paket geändert keine
package.published Paket veröffentlicht keine
package.ended Paket beendet keine
package.cancelled Paket abgesagt keine
package.deleted Paket gelöscht keine
package_booking.created Paketbuchung angelegt participant
package_booking.confirmed Paketbuchung bestätigt participant
package_booking.cancelled Paketbuchung storniert participant
package_booking.expired Paketbuchung abgelaufen participant
payment.created Zahlung angelegt keine
payment.succeeded Zahlung erfolgreich keine
payment.failed Zahlung fehlgeschlagen keine
payment.refunded Zahlung vollständig erstattet keine
payment.partially_refunded Zahlung teilweise erstattet keine
payment.disputed Zahlung beanstandet keine
payment.cancelled Zahlung abgebrochen keine
refund.requested Erstattung angefordert keine
refund.succeeded Erstattung erfolgreich keine
refund.failed Erstattung fehlgeschlagen keine
refund.cancelled Erstattung abgebrochen keine
invoice.issued Rechnung ausgestellt buyer
credit_note.issued Gutschrift ausgestellt buyer
venue.created Filiale angelegt contactName, contactEmail, contactPhone
venue.updated Filiale geändert dieselben Felder
venue.default_changed Standard-Filiale gewechselt dieselben Felder
venue.deleted Filiale gelöscht keine
trainer.created Trainer:in angelegt email, phone
trainer.updated Trainer:in geändert email, phone
trainer.archived Trainer:in archiviert email, phone
trainer.restored Trainer:in wiederhergestellt email, phone
trainer_absence.created Abwesenheit angelegt keine; note wird nie gesendet
trainer_absence.deleted Abwesenheit gelöscht keine
holiday.created Feiertag angelegt keine
holiday.deleted Feiertag gelöscht keine
member.joined Mitglied beigetreten name, email
member.role_changed Rolle geändert name, email
member.removed Mitglied entfernt name, email
member.left Mitglied hat den Arbeitsbereich verlassen name, email
member.ownership_transferred Eigentümerschaft übertragen name, email
invitation.created Einladung erstellt email
invitation.resent Einladung erneut gesendet email
invitation.cancelled Einladung widerrufen email
invitation.accepted Einladung angenommen email
media.created Medium angelegt keine; storageKey wird nie gesendet
media.updated Medium geändert keine
media.reordered Medienreihenfolge geändert keine
media.deleted Medium gelöscht keine
staff_message.sent Nachricht an alle gesendet keine; der Nachrichtentext fehlt
workspace.profile_updated Unternehmensprofil geändert keine
ping Testevent senden im Dashboard keine; data ist {}

Ein Statuswechsel erzeugt nur das Lebenszyklus-Event, kein zusätzliches updated. Feldänderungen ohne Statuswechsel erzeugen updated mit data.changes. Leere Diffs erzeugen kein Event. *.deleted trägt data.<resource>: { "id": "…" } und data.deletedAt.

Unveränderte äußere Form, drei additive Felder:

{
"id": "event-…",
"type": "course.published",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "course", "id": "course-…" },
"data": {
"course": { "id": "course-…", "title": "Aquarellkurs", "status": "published" }
}
}
Feld Typ Bedeutung
id string Event-ID, identisch mit x-orbinaut-event-id
type string Katalogtyp, zum Beispiel booking.created oder ping
occurredAt string Zeitpunkt der Änderung, ISO 8601 in UTC
workspaceId string ID des Arbeitsbereichs
resource.type string Primäre Ressource, zum Beispiel booking
resource.id string ID der Primärressource
data.<resource> object Vollständiger Snapshot nach der Änderung

Jeder Snapshot enthält id sowie, wo vorhanden, createdAt und updatedAt. Empfänger werten bei mehreren Schreibvorgängen den neuesten Wert von updatedAt aus. Die Zustellreihenfolge über mehrere Events hinweg ist nicht garantiert.

booking.created bleibt kompatibel. Additive Felder: workspaceId, resource und weitere Snapshot-Felder. data.participant nur mit Teilnehmerdaten mitsenden.

{
"id": "event-…",
"type": "booking.created",
"occurredAt": "2026-08-17T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "booking", "id": "booking-…" },
"data": {
"booking": {
"id": "booking-…",
"source": "widget",
"status": "confirmed",
"courseId": "course-…",
"priceInCents": 0,
"currency": "EUR"
},
"course": { "id": "course-…", "title": "Aquarellkurs" }
}
}
{
"id": "event-…",
"type": "contact.created",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "contact", "id": "participant-…" },
"data": { "contact": { "id": "participant-…", "createdAt": "2026-09-01T12:00:00.000Z" } }
}
{
"id": "event-…",
"type": "course.published",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "course", "id": "course-…" },
"data": { "course": { "id": "course-…", "title": "Aquarellkurs", "status": "published" } }
}
{
"id": "event-…",
"type": "session.updated",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "session", "id": "session-…" },
"data": {
"session": { "id": "session-…", "courseId": "course-…", "startsAt": "2026-09-08T17:00:00.000Z" },
"changes": ["startsAt"]
}
}
{
"id": "event-…",
"type": "session_trainer.assigned",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "session_trainer", "id": "session-…" },
"data": { "session": { "id": "session-…" }, "trainer": { "id": "trainer-…" } }
}
{
"id": "event-…",
"type": "attendance.recorded",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "attendance", "id": "attendance-…" },
"data": { "attendance": { "id": "attendance-…", "bookingId": "booking-…", "sessionId": "session-…", "status": "present" } }
}
{
"id": "event-…",
"type": "participation_confirmation.issued",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "participation_confirmation", "id": "pc-…" },
"data": { "participation_confirmation": { "id": "pc-…", "bookingId": "booking-…", "downloadAvailable": true } }
}
{
"id": "event-…",
"type": "package.published",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "package", "id": "package-…" },
"data": { "package": { "id": "package-…", "title": "10er-Karte", "status": "published" } }
}
{
"id": "event-…",
"type": "package_booking.confirmed",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "package_booking", "id": "pkg-booking-…" },
"data": { "package_booking": { "id": "pkg-booking-…", "status": "confirmed", "packageId": "package-…" } }
}
{
"id": "event-…",
"type": "payment.succeeded",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "payment", "id": "pay-…" },
"data": { "payment": { "id": "pay-…", "status": "paid", "amountInCents": 4900, "currency": "EUR" } }
}
{
"id": "event-…",
"type": "refund.succeeded",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "refund", "id": "refund-…" },
"data": { "refund": { "id": "refund-…", "paymentId": "pay-…", "status": "succeeded", "amountInCents": 4900 } }
}
{
"id": "event-…",
"type": "invoice.issued",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "invoice", "id": "inv-…" },
"data": { "invoice": { "id": "inv-…", "invoiceNumber": "RE-2026-0001", "bookingId": "booking-…", "totalGrossMinor": 4900 } }
}
{
"id": "event-…",
"type": "credit_note.issued",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "credit_note", "id": "cn-…" },
"data": { "credit_note": { "id": "cn-…", "creditNoteNumber": "GS-2026-0001", "invoiceId": "inv-…", "refundId": "refund-…" } }
}
{
"id": "event-…",
"type": "venue.created",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "venue", "id": "venue-…" },
"data": { "venue": { "id": "venue-…", "name": "Atelier Nord", "city": "Hamburg" } }
}
{
"id": "event-…",
"type": "trainer.created",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "trainer", "id": "trainer-…" },
"data": { "trainer": { "id": "trainer-…", "name": "Kim Beispiel", "kind": "external" } }
}
{
"id": "event-…",
"type": "trainer_absence.created",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "trainer_absence", "id": "absence-…" },
"data": { "trainer_absence": { "id": "absence-…", "trainerId": "trainer-…", "startsAt": "2026-09-10T08:00:00.000Z" } }
}
{
"id": "event-…",
"type": "holiday.created",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "holiday", "id": "holiday-…" },
"data": { "holiday": { "id": "holiday-…", "observedOn": "2026-12-25", "name": "1. Weihnachtstag" } }
}
{
"id": "event-…",
"type": "member.joined",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "member", "id": "user-…" },
"data": { "member": { "userId": "user-…", "role": "trainer" } }
}
{
"id": "event-…",
"type": "invitation.created",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "invitation", "id": "invitation-…" },
"data": { "invitation": { "id": "invitation-…", "role": "trainer", "status": "pending" } }
}
{
"id": "event-…",
"type": "media.created",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "media", "id": "media-…" },
"data": { "media": { "id": "media-…", "courseId": "course-…", "kind": "image", "visibility": "public" } }
}

media.reordered sendet statt eines Snapshots data.mediaIds in der neuen Reihenfolge.

{
"id": "event-…",
"type": "staff_message.sent",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "staff_message", "id": "msg-…" },
"data": { "staff_message": { "id": "msg-…", "courseId": "course-…", "subject": "Treffpunkt", "recipientCount": 8 } }
}
{
"id": "event-…",
"type": "workspace.profile_updated",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "workspace", "id": "org-…" },
"data": { "workspace": { "id": "org-…", "slug": "atelier-nord", "name": "Atelier Nord" } }
}
{
"id": "event-…",
"type": "ping",
"occurredAt": "2026-09-01T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "ping", "id": "webhook-endpoint-…" },
"data": {}
}

Ein Worker prüft alle 5 Sekunden auf fällige Zustellungen. Das Event erreicht den Empfänger in der Regel wenige Sekunden nach der Änderung, nie synchron. Die Zustellung ist mindestens einmal: Timeout oder Abbruch wiederholt dasselbe Event mit derselben x-orbinaut-event-id.

Die Reihenfolge über mehrere Events hinweg ist nicht garantiert. Empfänger deduplizieren über id bzw. x-orbinaut-event-id und ordnen über occurredAt sowie Snapshot-updatedAt. Es gibt keinen historischen Replay älterer Events; der Anfangsstand kommt über die lesende REST-API.

Zugestellte Outbox-Zeilen werden nach 30 Tagen gelöscht, endgültig fehlgeschlagene nach 90 Tagen. Offene Zustellungen bleiben.

Eigenschaft Wert
Methode POST
Content-Type application/json
User-Agent Orbinaut-Webhooks/1.0
Timeout 10 Sekunden je Versuch
Erfolg HTTP-Status 200 bis 299
Versuche höchstens 8
Weiterleitungen Orbinaut folgt ihnen nicht; 3xx zählt als Fehler
Antwortkörper wird ignoriert
TLS Zertifikat muss zum Hostnamen der URL passen

Jeder Versuch ist ein eigener POST mit neuem x-orbinaut-timestamp und neuer Signatur. Event-ID und Body bleiben über alle Versuche gleich. Empfänger müssen anhand der Event-ID idempotent verarbeiten.

Als Fehler gelten ein Status außerhalb von 2xx, ein Timeout, ein Verbindungs- oder TLS-Fehler und eine URL, die zum Zeitpunkt der Zustellung auf ein privates Netz auflöst.

Nach einem fehlgeschlagenen Versuch wartet Orbinaut mit exponentiellem Backoff. Die Wartezeit verdoppelt sich ab 5 Sekunden; der Worker-Takt von 5 Sekunden kommt hinzu.

Versuch Frühestens nach dem vorherigen Versuch
1 sofort
2 5 Sekunden
3 10 Sekunden
4 20 Sekunden
5 40 Sekunden
6 80 Sekunden
7 160 Sekunden
8 320 Sekunden

Scheitert auch der achte Versuch, gilt das Event als Endgültig fehlgeschlagen. In Zustellungen kannst du Erneut senden wählen; Orbinaut setzt den Versuchszähler zurück. Testevent senden legt ein Event ping nur für diesen Endpunkt an. Insgesamt liegen zwischen dem ersten und dem letzten automatischen Versuch rund 11 Minuten.

Die URL wird beim Anlegen und vor jeder Zustellung geprüft; ein Endpunkt lässt sich nicht bearbeiten, ein anderes Ziel ist ein neuer Webhook.

Regel Details
Schema nur https://
Länge höchstens 2.048 Zeichen
Benutzerdaten keine user:password@ in der URL
Hostname nicht localhost und keine Endungen .localhost, .local, .internal, .home, .lan
IP-Adressen keine privaten oder Loopback-Adressen, auch nicht als IPv6-gemappte IPv4-Adresse
DNS der Hostname darf zu keinem Zeitpunkt auf eine private Adresse auflösen

Orbinaut speichert die normalisierte Form der URL. Löst der Hostname später auf eine private Adresse auf, schlägt die Zustellung fehl und wird wie ein Empfängerfehler wiederholt.

Header Bedeutung
x-orbinaut-event-id stabile Idempotenz-ID, für alle Versuche gleich
x-orbinaut-timestamp Unix-Zeit in Sekunden zum Zeitpunkt des Versuchs
x-orbinaut-signature v1=<hex-hmac>; es gibt nur die Version v1

Die Signatur ist HMAC-SHA256(secret, "<timestamp>.<raw-body>"), hex-kodiert in Kleinbuchstaben. Empfänger prüfen den Timestamp gegen ein enges Zeitfenster, etwa fünf Minuten, berechnen die Signatur über den unveränderten Request-Body und vergleichen sie in konstanter Zeit. Das Signing-Secret beginnt mit whsec_ und ist nur einmal sichtbar. Nach einer Rotation signiert Orbinaut alle weiteren Zustellungen mit dem neuen Secret, auch Wiederholungen bereits wartender Events.

Der Body ist die JSON-Serialisierung ohne Leerzeichen und Zeilenumbrüche. Die Signatur gilt für genau diese Bytes. Form und Felder stehen unter Envelope und Event-Katalog. booking.created behält data.booking.id, data.booking.source, data.booking.status und data.course; workspaceId und resource sind additiv.

Nur der explizite Webhook-Scope participants:read, im Dashboard das Häkchen Teilnehmerdaten mitsenden, ergänzt personenbezogene Blöcke. Bei booking.created bleibt data.participant mit name und email erhalten.

{
"id": "event-…",
"type": "booking.created",
"occurredAt": "2026-08-17T12:00:00.000Z",
"workspaceId": "org-…",
"resource": { "type": "booking", "id": "booking-…" },
"data": {
"booking": {
"id": "booking-…",
"source": "admin",
"status": "pending"
},
"course": {
"id": "course-…",
"title": "Aquarellkurs"
},
"participant": {
"name": "Vorname Nachname",
"email": "teilnehmer@example.com"
}
}
}
Feld Typ Bedeutung
data.participant.name string Name zum Buchungszeitpunkt
data.participant.email string E-Mail-Adresse; leerer String, wenn die Buchung ohne E-Mail angelegt wurde

Ohne den Scope fehlt das Objekt vollständig. Der Scope wird beim Anlegen des Events ausgewertet: Ein späteres Setzen des Häkchens ändert wartende Events nicht. internalNotes, Trainer-note, Rechnungs-PDFs und Secrets gehören nie in den Payload.

Zustand Wirkung
Aktiv Neue Änderungen erzeugen Events, fällige Zustellungen laufen.
Deaktiviert Neue Änderungen erzeugen für diesen Endpunkt kein Event. Bereits wartende Zustellungen pausieren und laufen nach Aktivieren weiter.
Plan ohne Webhooks Für den Workspace entstehen keine Events. Wartende Zustellungen gelten als endgültig fehlgeschlagen. Im Dashboard steht Webhooks sind derzeit gesperrt.

Endpunkte lassen sich nicht löschen. Deaktivieren ist der Endzustand.

import { createHmac, timingSafeEqual } from "node:crypto";
function verifyOrbinautSignature(secret, timestamp, rawBody, header) {
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;
const expected = `v1=${createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex")}`;
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}

Der Empfänger antwortet mit 2xx, sobald das Event gespeichert ist, und verarbeitet es danach. Lange Verarbeitung innerhalb der 10 Sekunden führt zu Timeouts und doppelten Zustellungen.

secret, YOUR_API_KEY und echte Zustell-URLs gehören nicht in diese Dokumentation. Verwende zum Testen einen eigenen HTTPS-Empfänger, der nicht in ein privates Netz auflöst. Incoming-Webhooks von Discord oder Slack sind ungeeignet: Sie erwarten ein Chat-Nachrichtenformat und lehnen das Orbinaut-Envelope mit HTTP 400 ab.

Eine native Zapier-App ist Roadmap. Version 1 liefert Zapier-kompatible, signierte Webhook-Events, keine Synchronisation mit externen Anbietern.

Die REST-Endpunkte bleiben in der Referenz.