Ein Durchlauf von Anfang bis Ende

Elf Schritte, alle gegen Ihre eigene Testpraxis, alle mit echten Rümpfen. Sie brauchen dafür nichts von uns außer den Partner-Zugangsdaten.

Hinweis

Kopieren Sie die Aufrufe, nicht die Werte. Was zählt, ist die Reihenfolge und die Form der Rümpfe.

⚠️ Die Ids unten sind gekürzt, damit die Blöcke lesbar bleiben. Echte Werte sind länger, und das ist der eine Punkt, an dem das Abschreiben teuer wird: appointmentId, clinicId, connectionId, pairingCodeId, petlaBookerId und petlaPetId sind UUIDs mit 36 Zeichen, das connectionToken hat 64. Nur evt_… trägt wirklich ein Präfix. Legen Sie Ihre Spalten danach an, nicht nach der Breite hier.

Basis-URL im Testsystem: https://partner-sandbox.petla.app/api/partner/v1. Zwei Zugangsdaten kommen vor, und sie sind nicht austauschbar — welche wann, steht in Erste Schritte.

1. Kopplung anstoßen Core

Mit den Partner-Zugangsdaten: eine Verbindung gibt es noch nicht.

curl -X POST https://partner-sandbox.petla.app/api/partner/v1/connections/pairing-codes \
  -H "Authorization: Bearer $PARTNER_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "mandant-4711",
    "practice": {
      "name": "Tierarztpraxis Sonnenhof",
      "street": "Musterweg 3",
      "postalCode": "10115",
      "city": "Berlin"
    }
  }'

HTTP/2 201
{
  "pairingCodeId": "pc_8f2a1c",
  "pairingCode": "K7M2-QP4X",
  "onboardingUrl": ".../onboarding?partner=pc_8f2a1c",
  "expiresAt": "2026-09-03T21:14:00Z"
}

pairingCode zeigen Sie der Praxis. pairingCodeId ist kein Geheimnis und darf in Ihre Logs.

Hinweis

onboardingUrl ist für Praxen ohne Petla-Konto. Einlösen setzt eine angemeldete Praxis voraus — wer noch kein Konto hat, kommt über diesen Link dorthin und tippt den Code dann wie alle anderen ein. Zeigen Sie ihn neben dem Code.

⚠️ In der URL steht die pairingCodeId, nicht der Code. Das ist Absicht: der Code ist das Geheimnis, und eine URL landet in Chatfenstern.

⚠️ Der Host ist oben gekürzt, weil er von der Umgebung abhängt — nehmen Sie den Wert aus der Antwort, statt ihn zusammenzusetzen.

2. Den Code einlösen — im Testsystem selbst

Sonst tippt ihn die Praxis in ihrem Petla-Konto ein. In der Sandbox übernehmen Sie das:

curl -X POST https://partner-sandbox.petla.app/api/partner/v1/sandbox/redeem \
  -H "Authorization: Bearer $PARTNER_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{ "code": "K7M2-QP4X" }'

HTTP/2 201
{
  "connectionId": "conn_3d91",
  "resourceId": "kal-1",
  "resourceIds": ["kal-1", "kal-2"],
  "vendor": { "key": "vetera", "name": "Vetera" },
  "sandbox": "partner-sandbox-vetera",
  "nextStep": "Poll GET /connections/pairing-codes/{pairingCodeId} …",
  "rehearsalOnly": "This stands in for a practice signing in and typing the code. …"
}

resourceIds nennt die Kalender, die für Sie zugeordnet sind — bei einer echten Praxis richtet das ein Mensch beim Setup ein (§6.3). Genau diese Werte gehen in Schritt 5 als resourceId hinein; die Testpraxis hat zwei, damit sich ein ignoriertes Feld überhaupt bemerkbar machen kann.

Achtung

Das ist eine Probe, kein Beweis. Dieselbe Einlöse-Logik, aber ohne Anmeldung der Praxis, ohne clinic:manage und ohne den Menschen, der den Code abtippt. Kein Token im Rumpf — das holen Sie im nächsten Schritt ab, genau wie im Ernstfall.

3. Das Verbindungs-Token abholen Core

curl https://partner-sandbox.petla.app/api/partner/v1/connections/pairing-codes/pc_8f2a1c \
  -H "Authorization: Bearer $PARTNER_CREDENTIAL"

HTTP/2 200
{
  "status": "connected",
  "connectionId": "conn_3d91",
  "clinicId": "clinic_a71b",
  "connectionToken": "pct_9c4e…"
}
Nicht tun

Das Token steht genau einmal in einer Antwort. Ein zweiter Abruf antwortet 410 token_already_collected — nie wieder das Geheimnis. Speichern Sie es beim ersten Lesen, zusammen mit clinicId: die geht ab jetzt als Petla-Clinic-Id auf jedem Aufruf mit.

4. Fähigkeiten deklarieren Core

curl -X PUT https://partner-sandbox.petla.app/api/partner/v1/capabilities \
  -H "Authorization: Bearer $CONNECTION_TOKEN" \
  -H "Petla-Clinic-Id: clinic_a71b" \
  -H "Content-Type: application/json" \
  -d '{
    "specVersion": "1.0",
    "capabilities": [
      "availability.publish",
      "appointments.receive",
      "appointments.acknowledge",
      "clients.match",
      "appointments.rescheduleInbound",
      "appointments.cancelInbound"
    ],
    "transport": "poll",
    "availabilityShape": "windows"
  }'

HTTP/2 200

Ohne appointments.acknowledge gibt es kein 60-Sekunden-Fenster, und Schritt 7 hat nichts zu bestätigen.

Achtung

Die letzten beiden Zeilen sind neu, und ohne sie antworten/reschedule und /cancel mit 400 capability_not_declared. Beide Endpunkte prüfen ihre Fähigkeit, bevor sie den Rumpf lesen — Sie bekommen also denselben Fehler, wie sorgfältig der Rumpf auch ist. Diese Liste hatte sie bis zum 4. September 2026 nicht, und ein Durchlauf nach diesem Text scheiterte in Schritt 9 an einer Fähigkeit, die nirgends auf dieser Seite stand.

5. Verfügbarkeit veröffentlichen — mit einem geschlossenen Tag Core

curl -X PUT https://partner-sandbox.petla.app/api/partner/v1/availability \
  -H "Authorization: Bearer $CONNECTION_TOKEN" \
  -H "Petla-Clinic-Id: clinic_a71b" \
  -H "Idempotency-Key: 6f1a0b7c-…" \
  -H "Content-Type: application/json" \
  -d '{
    "resourceId": "kal-1",
    "from": "2026-09-07",
    "to": "2026-09-08",
    "timeZone": "Europe/Berlin",
    "windows": [
      { "date": "2026-09-07", "start": "08:00", "end": "12:00" }
    ]
  }'

HTTP/2 202
{ "resourceId": "kal-1", "from": "2026-09-07", "to": "2026-09-08",
  "receivedAt": "2026-09-03T20:58:11Z", "accepted": 1, "dropped": 0 }
Nicht tun

Der 8. fehlt in windows — und ist damit geschlossen. Eine Veröffentlichung ersetzt den genannten Zeitraum, sie ergänzt ihn nicht. Genau dieser Punkt steht auf der Go-live-Checkliste, weil ein leeres Array und eine ausgebliebene Veröffentlichung sonst gleich aussehen.

⛔ Und busy: [] heißt das Gegenteil von windows: []: nichts belegt, also ganz offen.

6. Einen Termin entstehen lassen

Sonst bucht ein Tierhalter. In der Sandbox lösen Sie es aus — und bekommen einen echten Datensatz, ein echtes Ereignis und ein echtes 60-Sekunden-Fenster:

curl -X POST https://partner-sandbox.petla.app/api/partner/v1/sandbox/bookings \
  -H "Authorization: Bearer $PARTNER_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{ "persona": "known", "inMinutes": 120, "durationMin": 30 }'

HTTP/2 201
{
  "appointmentId": "apt_5b2f",
  "persona": "known",
  "start": "2026-09-03T23:05:00",
  "end": "2026-09-03T23:35:00",
  "timeZone": "Europe/Berlin",
  "held": true,
  "expect": { "event": "appointment.created", "on": "GET /events" }
}

persona ist known, new oder ambiguous. Alle drei brauchen Sie: die Checkliste verlangt je einen matched, created und ambiguous.

Hinweis

ambiguous trifft auf ein Paar in der Testpraxis, das denselben Namen und dieselben letzten neun Telefonziffern führt und sich sonst durch nichts unterscheidet. Ohne so ein Paar kann Ihr Abgleich nie mehr als einen Treffer haben — und der Zweig, den Sie geschrieben haben, liefe nie.

7. Abrufen und bestätigen Core

curl "https://partner-sandbox.petla.app/api/partner/v1/events?limit=50" \
  -H "Authorization: Bearer $CONNECTION_TOKEN" \
  -H "Petla-Clinic-Id: clinic_a71b"

HTTP/2 200
{
  "events": [
    {
      "id": "evt_c81d",
      "type": "appointment.created",
      "occurredAt": "2026-09-03T20:59:02Z",
      "data": {
        "id": "apt_5b2f", "version": 1, "status": "CONFIRMED",
        "clinicId": "clinic_a71b", "externalId": null, "resourceId": "kal-1",
        "start": "2026-09-03T23:05:00", "end": "2026-09-03T23:35:00",
        "timeZone": "Europe/Berlin", "modality": "PRAXIS",
        "client": { "petlaBookerId": "bk_22a9", "lastName": "Sommer" },
        "patients": [ { "petlaPetId": "pet_71c3", "name": "Rex", "species": "HUND" } ]
      }
    }
  ],
  "nextCursor": "eyJ2IjoxLCJjIjoi…",
  "hasMore": false
}

Dann bestätigen, innerhalb des Fensters:

curl -X POST https://partner-sandbox.petla.app/api/partner/v1/appointments/apt_5b2f/ack \
  -H "Authorization: Bearer $CONNECTION_TOKEN" \
  -H "Petla-Clinic-Id: clinic_a71b" \
  -H "Content-Type: application/json" \
  -d '{
    "accepted": true,
    "externalId": "PMS-2026-0091",
    "clientMatch": { "outcome": "matched", "externalClientId": "K-4711" }
  }'

HTTP/2 200
Hinweis

Schicken Sie denselben Rumpf sofort noch einmal. Er antwortet wieder 200. Ein widersprechender Rumpf — anderer externalId, oder accepted: false — antwortet 409 already_acknowledged. Das ist genau der Aufruf, den Ihr HTTP-Client nach einem Timeout von selbst wiederholt.

⛔ occurredAt ist Anzeige. Sortiert wird nach data.version, und die ist monoton, nicht lückenlos: 3 → 7 heißt bitte neu lesen, nie Ihnen fehlen vier.

8. Eine Ablehnung durchspielen Core

Neuen Termin erzeugen, und diesmal ablehnen:

curl -X POST https://partner-sandbox.petla.app/api/partner/v1/appointments/apt_9e04/ack \
  -H "Authorization: Bearer $CONNECTION_TOKEN" \
  -H "Petla-Clinic-Id: clinic_a71b" \
  -H "Content-Type: application/json" \
  -d '{ "accepted": false, "reason": "SLOT_TAKEN" }'

HTTP/2 200

Petla sagt dem Tierhalter ab und schickt ihm eine Mail. Deshalb ist reason Pflicht und maschinenlesbar: die Formulierung hängt daran.

Hinweis

SLOT_TAKEN ist ein reason, kein Fehlercode. Es taucht nie in Error.code auf — zwei Namensräume, und der eine ist GROSS geschrieben, der andere klein. Siehe Fehlercodes.

9. Einen Termin verschieben und absagen Extended

Beides setzt die beiden Fähigkeiten aus Schritt 4 voraus. Der Termin muss bestätigt sein — verschieben Sie den aus Schritt 7.

curl -X POST https://partner-sandbox.petla.app/api/partner/v1/appointments/apt_5b2f/reschedule \
  -H "Authorization: Bearer $CONNECTION_TOKEN" \
  -H "Petla-Clinic-Id: clinic_a71b" \
  -H "Idempotency-Key: 2c7d41ae-…" \
  -H "Content-Type: application/json" \
  -d '{
    "start": "2026-09-09T09:00:00",
    "end":   "2026-09-09T09:30:00",
    "timeZone": "Europe/Berlin",
    "notifyOwner": false
  }'

HTTP/2 200
{ "id": "apt_5b2f", "version": 3, "status": "RESCHEDULED",
  "start": "2026-09-09T09:00:00", "end": "2026-09-09T09:30:00" }

curl -X POST https://partner-sandbox.petla.app/api/partner/v1/appointments/apt_5b2f/cancel \
  -H "Authorization: Bearer $CONNECTION_TOKEN" \
  -H "Petla-Clinic-Id: clinic_a71b" \
  -H "Idempotency-Key: 91b0c5df-…" \
  -H "Content-Type: application/json" \
  -d '{ "notifyOwner": false }'

HTTP/2 200
{ "id": "apt_5b2f", "version": 4, "status": "CANCELLED" }
Achtung

start und end, nie durationMin — und beide sind Wanduhrzeiten ohne Z, zusammen mit der timeZone, die sie liest (§10.1). Ein …T09:00:00Z ist ein Zeitpunkt, den Ihr Parser in die Zone Ihres Servers umrechnet: das sieht ein halbes Jahr lang richtig aus und ist im anderen halben Jahr um eine Stunde daneben. Ein unbekanntes Feld beantwortet der Endpunkt mit 400 unknown_field und nennt es beim Namen.

version steigt bei jedem Schritt. Sie ist monoton, aber nicht lückenlos — prüfen Sie auf > zuletzt gesehen, nie auf === zuletzt + 1.

10. Einen abgelaufenen Cursor behandeln Core

Ein echter Cursor läuft nach mindestens 30 Tagen ab — heute sind es 90. Verlassen Sie sich auf die zugesicherten 30, nicht auf den aktuellen Wert. Warten müssen Sie darauf nicht:

curl https://partner-sandbox.petla.app/api/partner/v1/sandbox/stale-cursor \
  -H "Authorization: Bearer $CONNECTION_TOKEN" \
  -H "Petla-Clinic-Id: clinic_a71b"

HTTP/2 200
{ "cursor": "eyJ2IjoxLCJ0Ijox…", "issuedAt": "2026-08-03T20:59:00Z",
  "expectedResponse": { "status": 410, "code": "cursor_expired",
                        "meta": { "reason": "expired" } } }

curl "https://partner-sandbox.petla.app/api/partner/v1/events?cursor=eyJ2IjoxLCJ0Ijox…" \
  -H "Authorization: Bearer $CONNECTION_TOKEN" \
  -H "Petla-Clinic-Id: clinic_a71b"

HTTP/2 410
{ "code": "cursor_expired", "status": 410, "meta": { "reason": "expired" } }
Nicht tun

Antwort darauf ist GET /events ohne Cursor — ein anderer Aufruf, keine Wiederholung. Wird der abgelaufene Cursor erneut geschickt, antwortet Petla wieder mit 410 und es wird nichts synchronisiert. Der Fehler bleibt dabei unbemerkt: es kommen einfach keine Ereignisse mehr an.

11. Trennung, und zurück auf Anfang

Zuletzt die Richtung, die Sie von sich aus nicht auslösen können — die Praxis beendet die Verbindung:

curl -X DELETE https://partner-sandbox.petla.app/api/partner/v1/sandbox/connection \
  -H "Authorization: Bearer $PARTNER_CREDENTIAL"

HTTP/2 200
{ "connectionId": "conn_3d91", "revokedBy": "CLINIC", "alreadyRevoked": false }

curl https://partner-sandbox.petla.app/api/partner/v1/capabilities \
  -H "Authorization: Bearer $CONNECTION_TOKEN" \
  -H "Petla-Clinic-Id: clinic_a71b"

HTTP/2 401
{ "code": "connection_revoked", "status": 401 }
Nicht tun

Dieser 401 ist die Zustellung von connection.revoked. Auf dem Poll-Transport kann es nicht anders sein: eine getrennte Verbindung darf sich nicht mehr anmelden, kann ihren eigenen Ereignisstrom also nicht mehr lesen. Ein Client, der den Code als vorübergehenden Anmeldefehler behandelt, wiederholt den Aufruf endlos.

Und wieder von vorn:

curl -X POST https://partner-sandbox.petla.app/api/partner/v1/sandbox/reset \
  -H "Authorization: Bearer $PARTNER_CREDENTIAL"

HTTP/2 200
{ "removed": { … }, "seeded": { "clients": 6, "pets": 10, "appointments": 11 },
  "cursorsInvalidated": true }

cursorsInvalidated ist keine Höflichkeit: nach einem Reset antwortet jeder alte Cursor 410 — mit meta.reason: "re-paired" statt "expired". Zwei Gründe, eine Reaktion.

Zweimal durchlaufen

Achtung

Machen Sie Schritt 11 und dann 1 bis 7 ein zweites Mal — mit demselben externalId. Dieser Fall tritt in der Regel früh im Betrieb auf: Ihre Ids sind stabil, die Zuordnung dahinter ist es nach einem Reset nicht mehr. Ein Fehler an dieser Stelle tritt beim Kunden genauso auf.

Danach steht die Go-live-Checkliste — die restlichen Punkte sind Wiederholungen dieser elf mit anderen Werten.