Webhook-Payloads

Der maßgebliche Wire-Contract für ausgehende Webhooks: Envelope, Signatur-Header, Datenstrukturen je Ereignis und Payload-Versionierung.

Dies ist der maßgebliche Wire-Contract für die ausgehenden Webhooks, die Owlat an Ihre Endpunkte sendet. Um Abonnements zu konfigurieren, Signaturen zu prüfen und das Retry-Verhalten auf höherer Ebene zu verstehen, beginnen Sie mit der Webhooks-Referenz; diese Seite dokumentiert die exakte JSON-Struktur jedes Ereignisses.

Payload-Version 1 — eingefroren

Jedes nachfolgende Payload ist zum Stand payloadVersion 1 eingefroren. Ein Feld umzubenennen, einen Typ zu ändern, ein Feld zu entfernen oder ein Pflichtfeld hinzuzufügen, ist für Empfänger ein Breaking Change und erfordert eine Anhebung der Payload-Version. Einen völlig neuen Ereignistyp hinzuzufügen ist kein Breaking Change, sofern Ihr Handler unbekannte Ereignisse ignoriert.

Envelope

Jede Zustellung ist die JSON-serialisierte Form dieses Envelopes:

{
  "event": "<event-name>",
  "timestamp": "<ISO-8601 UTC>",
  "data": { /* event-specific, see below */ }
}
  • event — das Ereignis-Literal, z. B. email.delivered.
  • timestamp — Zeitpunkt, zu dem der Envelope erzeugt wurde, als ISO-8601-UTC-String.
  • data — das ereignisspezifische Payload. Der HTTP-Request-Body ist exakt die Zeichenkette JSON.stringify({ event, timestamp, data }), und die HMAC-Signatur wird über genau diese Zeichenkette berechnet.

Signatur-Header

Jede Zustellung trägt diese Header:

HeaderWert
X-SignatureHMAC-SHA256 des Request-Bodys, hex-kodiert, mit Ihrem Webhook-Secret als Schlüssel
X-TimestampUnix-Sekunden zum Zeitpunkt des Zustellversuchs
X-Webhook-IdConvex-ID des Webhook-Abonnements
User-AgentOwlat-Webhooks/1.0
Content-Typeapplication/json

Webhook-Secrets verwenden das Format whsec_... und werden beim Anlegen des Webhooks erzeugt. Prüfen Sie die Signatur, indem Sie den HMAC über den rohen Body neu berechnen und in konstanter Zeit vergleichen — siehe das Verifizierungsbeispiel in der Webhooks-Referenz.

Über den rohen Body signieren, nicht über ein neu serialisiertes Objekt

X-Signature wird über die wörtlichen Request-Bytes berechnet. Wenn Ihr Framework das JSON parst und neu serialisiert, bevor Sie es hashen, können sich Schlüsselreihenfolge oder Leerzeichen ändern und die Signatur stimmt nicht überein. Lesen Sie zuerst den rohen Body-String, verifizieren Sie ihn und parsen Sie erst danach.

Strukturvorgaben für data

In Payload-Version 1 ist data eine flache Map aus Primitiven: string, number, boolean oder null. Es gibt keine verschachtelten Objekte oder Arrays. Alles Strukturierte wird JSON-kodiert in ein String-Feld gelegt — siehe topic.unsubscribed.listsRemoved weiter unten. Eine künftige Payload-Version kann diese Vorgabe lockern.

E-Mail-Ereignisse

Diese werden ausgelöst, während eine Kampagnen- oder transaktionale E-Mail Versand, Zustellung und Interaktion durchläuft.

email.sent

Wird ausgelöst, wenn eine Kampagnen- oder transaktionale E-Mail an den Versanddienst übergeben wird. Genau eines von campaignId / transactionalEmailId ist gesetzt; das jeweils andere ist null.

{
  "email": "recipient@example.com",
  "campaignId": "<Id<'campaigns'> | null>",
  "transactionalEmailId": "<Id<'transactionalEmails'> | null>",
  "timestamp": "<ISO-8601>"
}

email.delivered

Wird ausgelöst, wenn der empfangende Mailserver die Zustellung annimmt.

{
  "email": "recipient@example.com",
  "timestamp": "<ISO-8601>"
}

email.opened

Wird beim ersten Öffnen ausgelöst, erfasst über ein 1×1-Pixel-Beacon.

{
  "email": "recipient@example.com",
  "timestamp": "<ISO-8601>"
}

email.clicked

Wird bei jedem Linkklick ausgelöst, über die Weiterleitung der getrackten URL.

{
  "email": "recipient@example.com",
  "url": "https://www.example.com/landing",
  "timestamp": "<ISO-8601>"
}

email.bounced

Wird bei einem harten oder weichen Bounce ausgelöst, den der Dienst meldet. message ist der vom Dienst gelieferte Grund oder ein leerer String "", wenn keiner angegeben wurde.

{
  "email": "recipient@example.com",
  "bounceType": "hard | soft",
  "message": "<provider reason or ''>",
  "timestamp": "<ISO-8601>"
}

email.complained

Wird ausgelöst, wenn der Empfänger die E-Mail als Spam markiert (Feedback-Loop des Dienstes).

{
  "email": "recipient@example.com",
  "timestamp": "<ISO-8601>"
}

Kontaktereignisse

contact.created

Wird ausgelöst, wenn ein neuer Kontakt hinzugefügt wird. source hält fest, wie der Kontakt ins System gelangt ist.

{
  "contactId": "<Id<'contacts'>>",
  "email": "person@example.com",
  "source": "api | import | form | transactional | inbound",
  "timestamp": "<ISO-8601>"
}
sourceBedeutung
apiÜber die Kontakte-API angelegt
importÜber einen CSV-Import hinzugefügt
formHat ein gehostetes Formular abgesendet
transactionalBeim Versand einer transaktionalen E-Mail automatisch angelegt
inboundAus einer eingehenden Nachricht erstellt

Themen-Ereignisse

topic.unsubscribed

Wird ausgelöst, wenn sich ein Kontakt von einem oder mehreren Themen abmeldet.

{
  "contactId": "<Id<'contacts'>>",
  "email": "person@example.com",
  "unsubscribedAt": 1710505200000,
  "listsRemoved": "[{\"topicId\":\"...\",\"topicName\":\"...\"}]"
}
listsRemoved ist ein JSON-kodierter String

Da Payload-Version 1 nur flache Primitive erlaubt, ist listsRemoved ein String, der JSON enthält, und kein Array. Sie müssen ihn mit JSON.parse einlesen, um die Einträge zu lesen. Jeder Eintrag hat die Form { topicId, topicName }. Beachten Sie außerdem, dass unsubscribedAt Epoch-Millisekunden enthält (eine Zahl), anders als das ISO-8601-timestamp der E-Mail-Ereignisse.

const removed: Array<{ topicId: string; topicName: string }> =
  JSON.parse(data.listsRemoved)

test

Nicht abonnierbar — wird nur gesendet, wenn Sie im Webhook-Dashboard den Testauslöser verwenden, um einen Empfänger-Endpunkt zu prüfen. Nutzen Sie ihn, um Erreichbarkeit und Signaturprüfung Ihres Endpunkts zu bestätigen, bevor Sie sich auf Live-Ereignisse verlassen.

{
  "message": "This is a test webhook from Owlat",
  "webhookId": "<Id<'webhooks'>>",
  "webhookName": "<your label>"
}

Payload-Version und Vorwärtskompatibilität

Der Envelope trägt auf der Leitung kein Versionsfeld, doch jede Zeile im Zustellprotokoll speichert die payloadVersion, mit der sie erzeugt wurde. Schreiben Sie Ihren Empfänger defensiv:

  • Unbekannte Ereignisse ignorieren. Neue Ereignis-Literale können ohne Versionsanhebung hinzukommen; Handler, die unbekannte event-Werte strikt ablehnen, brechen beim Upgrade.
  • Unbekannte Felder ignorieren. Zusätzliche Felder innerhalb einer Struktur sind möglich; scheitern Sie nicht an Extras.
  • Nach Typ verzweigen, nicht nach Position. Denken Sie daran: timestamp ist bei E-Mail-/Kontaktereignissen ISO-8601, unsubscribedAt bei topic.unsubscribed hingegen Epoch-Millisekunden.

Eine künftige Version 2 kann die Beschränkung auf flache Primitive lockern und verschachtelte Arrays/Objekte zulassen (womit die JSON-String-Kodierung von listsRemoved entfällt). Wenn es so weit ist, wird die Version angehoben und beide Strukturen werden dokumentiert.

Retry-Verhalten und Zustellprotokolle

Owlat behandelt jede Nicht-2xx-Antwort — ebenso wie einen Netzwerkfehler, ein Timeout oder eine URL, die auf ein privates/lokales Netzwerk auflöst — als fehlgeschlagene Zustellung und wiederholt sie mit Backoff auf insgesamt bis zu 3 Versuche:

Versuch 1

Wird sofort zugestellt, wenn das Ereignis ausgelöst wird.

Versuch 2

Wiederholung nach 1 Minute, falls Versuch 1 fehlgeschlagen ist.

Versuch 3

Wiederholung nach 5 Minuten, falls Versuch 2 fehlgeschlagen ist. Nach diesem letzten Versuch wird die Zustellung als fehlgeschlagen markiert und nicht erneut wiederholt.

Jede Anfrage hat ein 30-Sekunden-Timeout und folgt keinen Weiterleitungen. Jeder Versuch wird in einem Zustellprotokoll mit einem dieser Status festgehalten:

StatusBedeutung
pendingProtokolliert, eingeplant, noch nicht versucht
retryingEin Versuch ist fehlgeschlagen und ein weiterer ist eingeplant
successDer Endpunkt hat eine 2xx-Antwort geliefert
failedAlle Versuche aufgebraucht oder der Webhook war deaktiviert/nicht auffindbar

Protokolle erfassen den HTTP-Statuscode, einen gekürzten Response-Body (die ersten 1000 Zeichen), im Fehlerfall eine Fehlermeldung sowie die Dauer je Versuch. Da Owlat bei jeder Nicht-2xx-Antwort wiederholt, sollte Ihr Endpunkt mit einem 2xx quittieren, sobald er das Ereignis dauerhaft entgegengenommen hat, und asynchron verarbeiten — und er sollte idempotent sein, denn ein langsames oder unzuverlässiges 2xx kann dennoch zu einer doppelten Zustellung führen.

Verwandte Themen