Webhooks

Webhooks

Webhooks schicken dir Ereignisse aus PandaCards in Echtzeit zu – als HTTP-POST mit JSON-Payload an eine von dir hinterlegte URL. Kein Polling nötig.

EventBeschreibung
form.submission.createdEine neue, nicht als Spam erkannte Formular-Einsendung (Lead) ist eingegangen.
booking.createdEine PandaTime-Buchung wurde bestätigt.
booking.cancelledEine Buchung wurde storniert (per Kunden-Link oder im Dashboard).
booking.rescheduledStart- oder Endzeit einer nicht stornierten Buchung wurde geändert.

Webhooks sind ein Team-Plan-Feature und werden im Dashboard unter Entwicklung → Webhooks verwaltet.

Einrichten

Als Team-Manager legst du unter Entwicklung → Webhooks einen neuen Endpoint an: Ziel-URL plus die Events, die du abonnieren möchtest.

Die Ziel-URL muss https sein und öffentlich erreichbar. Interne/private Adressen (localhost, private IP-Bereiche, Link-Local etc.) werden abgelehnt – sowohl bei der Erstellung als auch erneut unmittelbar vor jeder Zustellung.

Beim Anlegen wird ein Signing-Secret erzeugt und einmalig im Klartext angezeigt (Format whsec_…). Es dient zur Signaturprüfung und wird danach nicht erneut angezeigt – sicher speichern.

Beispiel-Secret (nur einmal sichtbar)
whsec_5f3a9c2e1b8d47a6f0c9e2b4d6a8f1c3

Events

EventAusgelöst, wenn …
form.submission.created… eine Einsendung eines PandaForms-Formulars eingeht und nicht als Spam erkannt wurde. Als Spam markierte Einsendungen lösen keinen Webhook aus.
booking.created… eine Buchung über eine PandaTime-Buchungsseite den Status confirmed erreicht.
booking.cancelled… eine Buchung storniert wird — sowohl über den Storno-Link des Kunden als auch, wenn du sie selbst im Dashboard stornierst.
booking.rescheduled… sich Start- oder Endzeit einer nicht stornierten Buchung ändert (Verschiebung). Die Payload enthält zusätzlich die vorherige Start-/Endzeit.

Ein Endpoint erhält nur die Events, die bei seiner Erstellung ausgewählt wurden. Andere Buchungs-Änderungen (z. B. wenn der Erinnerungs-Versand intern einen Zeitstempel setzt) lösen keinen Webhook aus.

Payloads

Jede Zustellung ist ein JSON-Objekt mit event, created_at (ISO-8601) und dem eigentlichen data-Objekt.

form.submission.created

{
  "event": "form.submission.created",
  "created_at": "2026-07-20T12:34:56+02:00",
  "data": {
    "id": 210,
    "form": { "id": 5, "slug": "kontakt", "name": "Kontaktformular" },
    "submission": {
      "data": { "name": "Erika Beispiel", "email": "erika@example.de", "msg": "Bitte um Angebot." },
      "country_code": "DE",
      "consent_given": true,
      "created_at": "2026-07-20T12:34:56+02:00"
    }
  }
}

booking.created

{
  "event": "booking.created",
  "created_at": "2026-07-20T12:34:56+02:00",
  "data": {
    "id": 512,
    "booking_page": { "id": 3, "slug": "beratung", "name": "Erstberatung" },
    "event_type": "Erstgespräch",
    "start_at": "2026-07-22T10:00:00+02:00",
    "end_at": "2026-07-22T10:30:00+02:00",
    "timezone": "Europe/Berlin",
    "status": "confirmed",
    "customer": { "name": "Erika Beispiel", "email": "erika@example.de", "phone": "+49 170 1234567" },
    "answers": { "thema": "Angebot für Website" }
  }
}

booking.cancelled

Gleiche Form wie booking.created, mit "status": "cancelled".

{
  "event": "booking.cancelled",
  "created_at": "2026-07-20T12:34:56+02:00",
  "data": {
    "id": 512,
    "booking_page": { "id": 3, "slug": "beratung", "name": "Erstberatung" },
    "event_type": "Erstgespräch",
    "start_at": "2026-07-22T10:00:00+02:00",
    "end_at": "2026-07-22T10:30:00+02:00",
    "timezone": "Europe/Berlin",
    "status": "cancelled",
    "customer": { "name": "Erika Beispiel", "email": "erika@example.de", "phone": "+49 170 1234567" },
    "answers": { "thema": "Angebot für Website" }
  }
}

booking.rescheduled

Gleiche Form wie booking.created, plus previous_start_at/previous_end_at mit der vorherigen Zeit.

{
  "event": "booking.rescheduled",
  "created_at": "2026-07-20T12:34:56+02:00",
  "data": {
    "id": 512,
    "booking_page": { "id": 3, "slug": "beratung", "name": "Erstberatung" },
    "event_type": "Erstgespräch",
    "start_at": "2026-07-23T14:00:00+02:00",
    "end_at": "2026-07-23T14:30:00+02:00",
    "timezone": "Europe/Berlin",
    "status": "confirmed",
    "customer": { "name": "Erika Beispiel", "email": "erika@example.de", "phone": "+49 170 1234567" },
    "answers": { "thema": "Angebot für Website" },
    "previous_start_at": "2026-07-22T10:00:00+02:00",
    "previous_end_at": "2026-07-22T10:30:00+02:00"
  }
}

Header

Jede Zustellung trägt zusätzlich folgende HTTP-Header:

HeaderBedeutung
X-PandaCards-EventEvent-Name, z. B. booking.created
X-PandaCards-DeliveryEindeutige ID dieses Zustellversuchs
X-PandaCards-TimestampUnix-Zeitstempel (Sekunden) der Anfrage
X-PandaCards-Signaturesha256= + Hex-HMAC-SHA256 – siehe Signaturprüfung

Signaturprüfung

Die Signatur ist HMAC-SHA256(timestamp + "." + rawBody, secret), hex-kodiert und mit sha256= vorangestellt. Prüfe drei Dinge:

  • Die Signatur über den rohen (unveränderten) Request-Body berechnen – nicht über neu serialisiertes JSON.
  • Berechnete und empfangene Signatur zeitkonstant vergleichen (z. B. timingSafeEqual / hmac.compare_digest), nie mit ==.
  • Den Zeitstempel prüfen und bei zu großer Abweichung (z. B. > 5 Minuten) ablehnen – Replay-Schutz.
Signiere/verifiziere immer gegen den rohen Request-Body (die exakten empfangenen Bytes). Ein aus geparsten JSON-Daten neu erzeugter String führt fast immer zu einer anderen Byte-Repräsentation (Feldreihenfolge, Whitespace, Escaping) und damit zu einer falschen Signatur.

Node.js

const crypto = require("crypto");
function verify(rawBody, headers, secret) {
  const ts  = headers["x-pandacards-timestamp"];
  const sig = headers["x-pandacards-signature"];
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;       // Replay-Schutz (5 Min)
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Python

import hmac, hashlib, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
    ts  = headers["X-PandaCards-Timestamp"]
    sig = headers["X-PandaCards-Signature"]
    expected = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    if abs(time.time() - int(ts)) > 300:                                   # Replay-Schutz (5 Min)
        return False
    return hmac.compare_digest(sig, expected)

Zustellung & Retries

Ein HTTP-Status 2xx gilt als erfolgreiche Zustellung. Antwortet dein Endpoint mit einem anderen Status oder ist er nicht erreichbar, wird die Zustellung mit Backoff wiederholt:

VersuchVerzögerung ab vorherigem Versuch
1sofort
2+ 1 Minute
3+ 5 Minuten
4+ 30 Minuten
5+ 2 Stunden

Nach dem 5. erfolglosen Versuch wird die Zustellung als failed markiert und nicht weiter wiederholt.

Ein Endpoint, der 15 aufeinanderfolgende Zustellungen endgültig fehlschlägt (Status failed), wird automatisch deaktiviert. Du kannst ihn danach im Dashboard wieder aktivieren, sobald das Ziel wieder erreichbar ist.

Sicherheit

  • Nur https, nur öffentlich: Ziel-URLs müssen https sein; interne/private/Link-Local-Adressen werden abgelehnt – auch erneut direkt vor jeder Zustellung geprüft.
  • Signiert & mit Zeitstempel versehen: jede Zustellung trägt X-PandaCards-Signature und X-PandaCards-Timestamp – siehe Signaturprüfung.
  • Volle Personendaten: die Payloads enthalten die vollständigen Lead-/Buchungsdaten (u. a. Name, E-Mail, Telefon) – anders als in der API nicht maskiert, da sie an deinen eigenen Endpoint gehen. Behandle empfangene Daten entsprechend als personenbezogen (DSGVO-relevant): sicher speichern, Zugriff einschränken, Löschfristen einhalten.
  • Keine Geheimnisse enthalten: interne IDs, Storno-Token o. Ä. werden nie mitgeschickt.
  • Signatur immer prüfen, bevor du der Payload vertraust – ohne Prüfung kann jeder, der deine URL kennt, gefälschte Events senden.