API-Zugriff auf deine PandaCards, Formulare, Leads, Statistiken und PandaTime-Termine – Karten und Formulare auch schreibend.
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.
https://api.pandacards.de/v1
Die API ist ein Team-Plan-Feature. Tokens verwaltest du im Dashboard unter Entwicklung → API-Token.
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
pc_.Jeder Endpunkt verlangt einen bestimmten Scope. Fehlt er, antwortet die API mit 403.
| Scope | Erlaubt |
|---|---|
| cards:read | Eigene PandaCards lesen |
| cards:write | Eigene PandaCards anlegen, ändern, löschen |
| forms:read | Eigene Formulare lesen |
| forms:write | Eigene Formulare anlegen, ändern, löschen |
| submissions:read | Formular-Einsendungen (Leads) lesen |
| analytics:read | Aggregierte Karten-Statistik lesen |
| booking-pages:read | PandaTime-Buchungsseiten, Terminarten & Verfügbarkeiten lesen |
| bookings:read | Buchungen lesen – Kundendaten maskiert |
| bookings:pii | Zusatz-Scope: Kundendaten in Buchungen im Klartext (nur zusammen mit bookings:read) |
| Methode & Pfad | Scope | Beschreibung |
|---|---|---|
| GET /me | – | Token-Besitzer & aktiver Plan |
| GET /cards | cards:read | Eigene PandaCards (paginiert) |
| POST /cards | cards:write | Neue PandaCard anlegen (Zufalls-Slug). Respektiert das Plan-Kartenlimit – ist es erreicht, antwortet die API mit 422 statt 201. |
| PATCH /cards/{id} | cards:write | Eigene PandaCard teilweise aktualisieren (nur gesendete Felder ändern sich) |
| DELETE /cards/{id} | cards:write | Eigene PandaCard löschen (Soft-Delete – wiederherstellbar, keine 204-Antwort enthält Inhalt) |
| GET /forms | forms:read | Eigene Formulare (paginiert) |
| POST /forms | forms:write | Neues Formular anlegen (Zufalls-Slug, Default-Felder). Respektiert das Plan-Formularlimit – ist es erreicht, antwortet die API mit 422 statt 201. |
| PATCH /forms/{id} | forms:write | Eigenes Formular teilweise aktualisieren (nur gesendete Felder ändern sich) |
| DELETE /forms/{id} | forms:write | Eigenes Formular löschen (Soft-Delete – wiederherstellbar, keine 204-Antwort enthält Inhalt) |
| GET /forms/{id}/submissions | submissions:read | Einsendungen (Leads) eines Formulars (paginiert) |
| GET /analytics | analytics:read | Aggregierte Statistik über alle eigenen Karten |
| GET /booking-pages | booking-pages:read | PandaTime-Buchungsseiten (paginiert) |
| GET /booking-pages/{id}/event-types | booking-pages:read | Terminarten einer Seite |
| GET /booking-pages/{id}/availabilities | booking-pages:read | Verfügbarkeiten einer Seite |
| GET /bookings | bookings:read | Buchungen (Kundendaten maskiert; Klartext mit bookings:pii) |
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.
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.
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.
Alle Antworten sind JSON; Ressourcen liegen unter data. Datums-/Zeitwerte sind ISO-8601 mit Zeitzonen-Offset.
{
"data": {
"id": 1,
"name": "Max Mustermann",
"email": "max@firma.de",
"plan": "team",
"created_at": "2026-03-18T22:07:00+01:00"
}
}
{
"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 }
}
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." }
{
"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.
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." }
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üssel | Pflicht | Bedeutung |
|---|---|---|
id | ja | Schlüssel der späteren Antwort, max. 50 Zeichen (z. B. email) |
type | ja | einer der Typen unten |
label | – | Beschriftung, max. 200 Zeichen |
placeholder | – | Platzhaltertext, max. 200 Zeichen |
required | – | true / false |
options | – | Array von Strings – nur für select und radio |
Erlaubte Werte für type:
| Gruppe | Typen |
|---|---|
| Texteingabe | text email textarea tel number |
| Datum & Zeit | date time |
| Auswahl | select radio checkbox toggle |
| Bewertung | scale stars |
| Layout (ohne Eingabe) | heading spacer |
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": "…" }
{
"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.
{
"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 }
]
}
}
{
"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"
}
]
}
{
"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
}
]
}
{
"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).
{
"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"
}
]
}
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" }
}
| Feld | Maskiert | Klartext (bookings:pii) |
|---|---|---|
| customer_name | Erika B. | Erika Beispiel |
| customer_email | e***@example.de | erika@example.de |
| customer_phone | +49 *** **67 | +49 170 1234567 |
| answers | nicht enthalten | vollständig |
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 kommen immer als JSON mit einem message-Feld. Statuscodes:
| Status | Bedeutung |
|---|---|
401 | Kein oder ungültiger Token |
403 | Token fehlt der nötige Scope – oder der Plan enthält keine API-Verwaltung |
404 | Ressource nicht gefunden oder gehört nicht zu deinem Konto |
422 | Validierungsfehler – oder (bei POST /cards/POST /forms) das Karten-/Formular-Limit des Plans ist erreicht |
429 | Rate-Limit überschritten |
{ "message": "Unauthenticated." }
{ "message": "Invalid ability provided." }
{ "message": "Für diesen Zugriff ist ein Plan mit API-Verwaltung (Team) erforderlich." }
{ "message": "Nicht gefunden." }
{ "message": "Karten-Limit deines Plans erreicht." }
{ "message": "Formular-Limit deines Plans erreicht." }
{
"message": "The display name field is required.",
"errors": { "display_name": ["The display name field is required."] }
}
{ "message": "Too Many Attempts." }
Ein offizielles SDK gibt es aktuell nicht – die API ist ein schlanker HTTP-Dienst, jeder HTTP-Client genügt.
curl -H "Authorization: Bearer pc_xxxxxxxxxxxxxxxxxxxx" \
-H "Accept: application/json" \
https://api.pandacards.de/v1/cards
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);
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"])
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