API (Entwicklung)

REST API v1

API-Zugriff auf deine PandaCards, Formulare, Leads, Statistiken und PandaTime-Termine – Karten und Formulare auch schreibend.

PandaCards und Formulare unterstützen Schreibzugriffe. Karten lassen sich per POST/PATCH/DELETE /cards (Scope cards:write) und Formulare per POST/PATCH/DELETE /forms (Scope forms:write) anlegen, ändern und löschen (siehe Scopes und Endpunkte). Alle anderen Ressourcen (Buchungen/PandaTime, Analytics, …) sind weiterhin rein lesend (nur GET) – Schreibzugriffe dafür sind für spätere Ausbaustufen geplant. Webhooks gibt es bereits – push-basiert statt Polling, siehe die Webhooks-Dokumentation.
Basis-URL
https://api.pandacards.de/v1

Die API ist ein Team-Plan-Feature. Tokens verwaltest du im Dashboard unter Entwicklung → API-Token.

Authentifizierung

Die API nutzt Bearer-Token. Einen Token erstellst du unter Entwicklung → API-Token (als Team-Manager). Der Klartext-Token wird nur einmal angezeigt – sicher speichern.

Authorization: Bearer pc_xxxxxxxxxxxxxxxxxxxx
Accept: application/json
  • Tokens beginnen mit dem Präfix pc_.
  • Gültigkeit: 60 Tage, danach ist ein neuer Token nötig.
  • Jeder Token trägt genau die Scopes, die bei der Erstellung ausgewählt wurden.

Scopes

Jeder Endpunkt verlangt einen bestimmten Scope. Fehlt er, antwortet die API mit 403.

ScopeErlaubt
cards:readEigene PandaCards lesen
cards:writeEigene PandaCards anlegen, ändern, löschen
forms:readEigene Formulare lesen
forms:writeEigene Formulare anlegen, ändern, löschen
submissions:readFormular-Einsendungen (Leads) lesen
analytics:readAggregierte Karten-Statistik lesen
booking-pages:readPandaTime-Buchungsseiten, Terminarten & Verfügbarkeiten lesen
bookings:readBuchungen lesen – Kundendaten maskiert
bookings:piiZusatz-Scope: Kundendaten in Buchungen im Klartext (nur zusammen mit bookings:read)

Endpunkte

Methode & PfadScopeBeschreibung
GET /meToken-Besitzer & aktiver Plan
GET /cardscards:readEigene PandaCards (paginiert)
POST /cardscards:writeNeue PandaCard anlegen (Zufalls-Slug). Respektiert das Plan-Kartenlimit – ist es erreicht, antwortet die API mit 422 statt 201.
PATCH /cards/{id}cards:writeEigene PandaCard teilweise aktualisieren (nur gesendete Felder ändern sich)
DELETE /cards/{id}cards:writeEigene PandaCard löschen (Soft-Delete – wiederherstellbar, keine 204-Antwort enthält Inhalt)
GET /formsforms:readEigene Formulare (paginiert)
POST /formsforms:writeNeues Formular anlegen (Zufalls-Slug, Default-Felder). Respektiert das Plan-Formularlimit – ist es erreicht, antwortet die API mit 422 statt 201.
PATCH /forms/{id}forms:writeEigenes Formular teilweise aktualisieren (nur gesendete Felder ändern sich)
DELETE /forms/{id}forms:writeEigenes Formular löschen (Soft-Delete – wiederherstellbar, keine 204-Antwort enthält Inhalt)
GET /forms/{id}/submissionssubmissions:readEinsendungen (Leads) eines Formulars (paginiert)
GET /analyticsanalytics:readAggregierte Statistik über alle eigenen Karten
GET /booking-pagesbooking-pages:readPandaTime-Buchungsseiten (paginiert)
GET /booking-pages/{id}/event-typesbooking-pages:readTerminarten einer Seite
GET /booking-pages/{id}/availabilitiesbooking-pages:readVerfügbarkeiten einer Seite
GET /bookingsbookings:readBuchungen (Kundendaten maskiert; Klartext mit bookings:pii)

Pagination

Listen-Endpunkte sind paginiert: ?page=N, 50 Einträge pro Seite. Die Antwort enthält neben data die Felder links (first/last/prev/next) und meta (current_page, last_page, total …). Endpunkte, die ganze Teilmengen liefern (event-types, availabilities, /analytics), sind nicht paginiert und enthalten nur data.

Schreibbare Felder bei POST/PATCH /cards

Nur die folgenden Felder lassen sich über die API setzen – alles andere (Slug, Zähler, Custom-Domain, PIN, interne IDs, …) wird serverseitig verwaltet und von der API ignoriert, selbst wenn es im Request-Body mitgeschickt wird:

display_name (bei POST Pflichtfeld, max. 100 Zeichen), job_title, company, bio, email, phone, website, location, cta_text, cta_url, is_active, is_public.

Slug, Design/Vorlage, Analytics-Zähler (view_count &.), Custom-Domain und der PIN werden beim Anlegen automatisch bzw. aus der Team-Vorlage gesetzt und sind über die API nicht änderbar.

Schreibbare Felder bei POST/PATCH /forms

Nur die folgenden Felder lassen sich über die API setzen – alles andere (Slug, verknüpfte Karte, Einsendungs-Zähler, interne IDs, …) wird serverseitig verwaltet und von der API ignoriert, selbst wenn es im Request-Body mitgeschickt wird:

name (bei POST Pflichtfeld, max. 100 Zeichen), button_label, fields, success_message, consent_text, privacy_link_url, privacy_link_name, notify_emails (max. 10 Adressen), is_active, honeypot_enabled, captcha_enabled, submission_retention_days (0–3650 Tage).

Jedes Element von fields wird streng geprüft (id, type aus einer festen Liste, label, placeholder, required, options), damit eine fehlerhafte Feld-Definition nie den öffentlichen Formular-Renderer bricht. Slug, die verknüpfte Karte (panda_card_id), der Einsendungs-Zähler und der Plan-Sperrstatus werden beim Anlegen automatisch bzw. aus der Team-Vorlage gesetzt und sind über die API nicht änderbar.

Beispiel-Antworten

Alle Antworten sind JSON; Ressourcen liegen unter data. Datums-/Zeitwerte sind ISO-8601 mit Zeitzonen-Offset.

GET /me

{
  "data": {
    "id": 1,
    "name": "Max Mustermann",
    "email": "max@firma.de",
    "plan": "team",
    "created_at": "2026-03-18T22:07:00+01:00"
  }
}

GET /cards (paginiert – links/meta gelten sinngemäß für alle Listen)

{
  "data": [
    {
      "id": 12,
      "slug": "max-mustermann",
      "display_name": "Max Mustermann",
      "job_title": "Geschäftsführer",
      "company": "Muster GmbH",
      "bio": "Nachhaltig vernetzen.",
      "email": "max@firma.de",
      "phone": "+49 170 1234567",
      "website": "https://firma.de",
      "location": "München",
      "is_active": true,
      "is_public": true,
      "view_count": 428,
      "public_url": "https://cards.pandacards.de/max-mustermann",
      "created_at": "2026-04-02T09:15:00+02:00",
      "updated_at": "2026-07-10T14:22:00+02:00"
    }
  ],
  "links": { "first": ".../cards?page=1", "last": ".../cards?page=1", "prev": null, "next": null },
  "meta":  { "current_page": 1, "from": 1, "last_page": 1, "per_page": 50, "to": 1, "total": 1 }
}

POST /cards

Legt eine neue PandaCard mit einem zufälligen 8-stelligen Slug an. Nur display_name ist Pflicht – alle anderen Felder sind optional (siehe schreibbare Felder). Antwort: 201 mit der neuen Karte.

Request

POST /v1/cards
Authorization: Bearer pc_xxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "display_name": "Erika Beispiel",
  "job_title": "Vertrieb",
  "company": "Muster GmbH",
  "email": "erika@firma.de"
}

Antwort 201 Created

{
  "data": {
    "id": 42,
    "slug": "a1b2c3d4",
    "display_name": "Erika Beispiel",
    "job_title": "Vertrieb",
    "company": "Muster GmbH",
    "bio": null,
    "email": "erika@firma.de",
    "phone": null,
    "website": null,
    "location": null,
    "is_active": true,
    "is_public": true,
    "view_count": 0,
    "public_url": "https://cards.pandacards.de/a1b2c3d4",
    "created_at": "2026-07-20T09:00:00+02:00",
    "updated_at": "2026-07-20T09:00:00+02:00"
  }
}

Ist das Karten-Limit des Plans bereits erreicht, antwortet die API stattdessen mit 422 und legt nichts an:

{ "message": "Karten-Limit deines Plans erreicht." }

GET /forms

{
  "data": [
    {
      "id": 5,
      "slug": "kontakt",
      "name": "Kontaktformular",
      "button_label": "Nachricht senden",
      "fields": [
        { "key": "name",  "label": "Name",     "type": "text",     "required": true },
        { "key": "email", "label": "E-Mail",   "type": "email",    "required": true },
        { "key": "msg",   "label": "Nachricht","type": "textarea", "required": true }
      ],
      "is_active": true,
      "submission_count": 37,
      "last_submission_at": "2026-07-18T11:05:00+02:00",
      "created_at": "2026-05-01T10:00:00+02:00"
    }
  ]
}

Das Feld fields spiegelt die Formular-Definition; die konkreten Keys hängen von deinem Formular ab.

POST /forms

Legt ein neues Formular mit einem zufälligen 8-stelligen Slug an. Nur name ist Pflicht – ohne eigene fields greift die Team-Vorlage bzw. das Standard-Feldset (Name/E-Mail/Nachricht, siehe schreibbare Felder). Antwort: 201 mit dem neuen Formular.

Request

POST /v1/forms
Authorization: Bearer pc_xxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "name": "Rückruf-Wunsch",
  "notify_emails": ["vertrieb@firma.de"]
}

Antwort 201 Created

{
  "data": {
    "id": 9,
    "slug": "b7c3f1a0",
    "name": "Rückruf-Wunsch",
    "button_label": null,
    "fields": [
      { "id": "name", "type": "text", "label": "Name", "placeholder": "", "required": true },
      { "id": "email", "type": "email", "label": "E-Mail", "placeholder": "", "required": true },
      { "id": "message", "type": "textarea", "label": "Nachricht", "placeholder": "", "required": true }
    ],
    "is_active": true,
    "submission_count": 0,
    "last_submission_at": null,
    "created_at": "2026-07-20T09:00:00+02:00"
  }
}

Ist das Formular-Limit des Plans bereits erreicht, antwortet die API stattdessen mit 422 und legt nichts an:

{ "message": "Formular-Limit deines Plans erreicht." }

Formularfelder (fields)

Jedes Element von fields ist ein Objekt. type ist ein String aus der Liste unten – keine Zahl und keine ID. id ist ein frei wählbarer String-Schlüssel: unter genau diesem Schlüssel erscheint die Antwort später im data-Objekt von GET /forms/{id}/submissions und im Webhook-Payload.

SchlüsselPflichtBedeutung
idjaSchlüssel der späteren Antwort, max. 50 Zeichen (z. B. email)
typejaeiner der Typen unten
labelBeschriftung, max. 200 Zeichen
placeholderPlatzhaltertext, max. 200 Zeichen
requiredtrue / false
optionsArray von Strings – nur für select und radio

Erlaubte Werte für type:

GruppeTypen
Texteingabetext email textarea tel number
Datum & Zeitdate time
Auswahlselect radio checkbox toggle
Bewertungscale stars
Layout (ohne Eingabe)heading spacer
Ein unbekannter type führt zu 422 und es wird nichts angelegt oder geändert – so kann eine fehlerhafte Feld-Definition den öffentlichen Formular-Renderer nie brechen.

Beispiel mit eigenen Feldern:

POST /v1/forms
Authorization: Bearer pc_xxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "name": "Beratungsanfrage",
  "button_label": "Anfrage senden",
  "fields": [
    { "id": "name",   "type": "text",     "label": "Name",      "required": true },
    { "id": "email",  "type": "email",    "label": "E-Mail",    "required": true },
    { "id": "thema",  "type": "select",   "label": "Thema",     "required": true,
      "options": ["Angebot", "Support", "Sonstiges"] },
    { "id": "budget", "type": "number",   "label": "Budget (EUR)" },
    { "id": "notiz",  "type": "textarea", "label": "Nachricht", "placeholder": "Worum geht es?" }
  ],
  "notify_emails": ["vertrieb@firma.de"]
}

Eine Einsendung zu diesem Formular kommt dann so zurück: "data": { "name": "…", "email": "…", "thema": "Angebot", "budget": "5000", "notiz": "…" }

GET /forms/{id}/submissions

{
  "data": [
    {
      "id": 210,
      "data": {
        "name": "Erika Beispiel",
        "email": "erika@example.de",
        "msg": "Bitte um ein Angebot."
      },
      "is_spam": false,
      "is_read": false,
      "country_code": "DE",
      "source_path": "/max-mustermann",
      "consent_given": true,
      "consent_given_at": "2026-07-18T11:05:00+02:00",
      "created_at": "2026-07-18T11:05:00+02:00"
    }
  ]
}

data enthält den vollständigen Lead-Inhalt (die abgesendeten Feldwerte). Interne Felder (IP, Formular-/User-IDs, Ablaufdatum) werden nie ausgegeben.

GET /analytics

{
  "data": {
    "totals": { "views": 1284, "contact_saves": 96, "link_clicks": 213, "cards": 3 },
    "per_card": [
      { "card_id": 12, "slug": "max-mustermann", "display_name": "Max Mustermann",
        "views": 428, "contact_saves": 41, "link_clicks": 88 }
    ]
  }
}

GET /booking-pages

{
  "data": [
    {
      "id": 3,
      "slug": "beratung",
      "name": "Erstberatung",
      "timezone": "Europe/Berlin",
      "company": "Muster GmbH",
      "welcome_text": "Buche dir einen Termin für ein Erstgespräch.",
      "is_active": true,
      "created_at": "2026-06-01T08:00:00+02:00"
    }
  ]
}

GET /booking-pages/{id}/event-types (nicht paginiert)

{
  "data": [
    {
      "id": 8,
      "slug": "erstgespraech",
      "name": "Erstgespräch",
      "description": "30 Minuten kostenloses Kennenlernen.",
      "duration_minutes": 30,
      "buffer_before": 0,
      "buffer_after": 10,
      "location_type": "video",
      "price_cents": 0,
      "booking_questions": [ { "key": "thema", "label": "Worum geht es?", "required": false } ],
      "is_active": true
    }
  ]
}

GET /booking-pages/{id}/availabilities (nicht paginiert)

{
  "data": [
    { "id": 40, "day_of_week": 1, "date": null, "is_blocked": false, "start_time": "09:00", "end_time": "17:00" }
  ]
}

day_of_week: 0 = Sonntag … 6 = Samstag. Ein Eintrag mit gesetztem date gilt für einen konkreten Tag (z. B. eine Blockierung).

GET /bookings (maskiert – ohne bookings:pii)

{
  "data": [
    {
      "id": 512,
      "booking_page_id": 3,
      "start_at": "2026-07-22T10:00:00+02:00",
      "end_at": "2026-07-22T10:30:00+02:00",
      "timezone": "Europe/Berlin",
      "status": "confirmed",
      "event_type": "Erstgespräch",
      "customer_name": "Erika B.",
      "customer_email": "e***@example.de",
      "customer_phone": "+49 *** **67",
      "utm_source": "newsletter",
      "utm_medium": "email",
      "utm_campaign": "sommer",
      "created_at": "2026-07-15T13:40:00+02:00"
    }
  ]
}

Datenschutz & Maskierung

Kundendaten in Buchungen sind standardmäßig maskiert. Mit bookings:read werden customer_name, customer_email und customer_phone reduziert ausgegeben. Vollständige Klartext-Daten (plus answers) liefert die API nur, wenn der Token zusätzlich den Scope bookings:pii trägt. Geheimnisse (Feed-Token, Storno-Token, interne IDs) werden grundsätzlich nie ausgegeben.

Dieselbe Buchung wie oben, mit bookings:pii:

{
  "customer_name": "Erika Beispiel",
  "customer_email": "erika@example.de",
  "customer_phone": "+49 170 1234567",
  "answers": { "thema": "Angebot für Website" }
}
FeldMaskiertKlartext (bookings:pii)
customer_nameErika B.Erika Beispiel
customer_emaile***@example.deerika@example.de
customer_phone+49 *** **67+49 170 1234567
answersnicht enthaltenvollständig

Rate-Limit

60 Anfragen pro Minute je Token. Bei Überschreitung antwortet die API mit 429 und einem Retry-After-Header (Sekunden bis zum nächsten erlaubten Request).

HTTP/1.1 429 Too Many Requests
Retry-After: 56

{ "message": "Too Many Attempts." }

Fehler

Fehler kommen immer als JSON mit einem message-Feld. Statuscodes:

StatusBedeutung
401Kein oder ungültiger Token
403Token fehlt der nötige Scope – oder der Plan enthält keine API-Verwaltung
404Ressource nicht gefunden oder gehört nicht zu deinem Konto
422Validierungsfehler – oder (bei POST /cards/POST /forms) das Karten-/Formular-Limit des Plans ist erreicht
429Rate-Limit überschritten

401 – nicht authentifiziert

{ "message": "Unauthenticated." }

403 – fehlender Scope

{ "message": "Invalid ability provided." }

403 – Plan ohne API-Verwaltung

{ "message": "Für diesen Zugriff ist ein Plan mit API-Verwaltung (Team) erforderlich." }

404 – nicht gefunden

{ "message": "Nicht gefunden." }

422 – Karten-Limit erreicht (POST /cards)

{ "message": "Karten-Limit deines Plans erreicht." }

422 – Formular-Limit erreicht (POST /forms)

{ "message": "Formular-Limit deines Plans erreicht." }

422 – Validierungsfehler

{
  "message": "The display name field is required.",
  "errors": { "display_name": ["The display name field is required."] }
}

429 – Rate-Limit

{ "message": "Too Many Attempts." }

Beispiele & Clients

Ein offizielles SDK gibt es aktuell nicht – die API ist ein schlanker HTTP-Dienst, jeder HTTP-Client genügt.

curl

curl -H "Authorization: Bearer pc_xxxxxxxxxxxxxxxxxxxx" \
     -H "Accept: application/json" \
     https://api.pandacards.de/v1/cards

Node.js (fetch)

const res = await fetch("https://api.pandacards.de/v1/cards", {
  headers: {
    Authorization: "Bearer pc_xxxxxxxxxxxxxxxxxxxx",
    Accept: "application/json",
  },
});
if (!res.ok) throw new Error(`API-Fehler ${res.status}`);
const { data, meta } = await res.json();
console.log(data, meta);

Python (requests)

import requests

resp = requests.get(
    "https://api.pandacards.de/v1/cards",
    headers={
        "Authorization": "Bearer pc_xxxxxxxxxxxxxxxxxxxx",
        "Accept": "application/json",
    },
)
resp.raise_for_status()
payload = resp.json()
print(payload["data"])

Alle Seiten durchlaufen (Python)

url = "https://api.pandacards.de/v1/forms/5/submissions"
headers = {"Authorization": "Bearer pc_xxx", "Accept": "application/json"}

leads = []
while url:
    r = requests.get(url, headers=headers)
    r.raise_for_status()
    body = r.json()
    leads += body["data"]
    url = body.get("links", {}).get("next")   # None = letzte Seite