Zum Inhalt springen

Webhooks empfangen

Statt die API in einer Schleife abzufragen, hinterlegen Sie eine URL, und wir rufen Sie auf, wenn sich etwas ändert.

Ein Endpunkt wird für einen Modus registriert — live oder test — und für eine Liste von Ereignisnamen abonniert. Ein Test-Endpunkt erhält genau dieselbe Nutzlastform, gleich signiert, wie ein Live-Endpunkt.

Das ist, bis auf die id Byte für Byte, der Rumpf, den ein Test-Endpunkt für invoiceSent erhält — jede Test-Zustellung trägt dieselbe Beispielressource, sodass Sie das Eintreffende mit dieser Seite vergleichen können:

{
"id": "evt_3f9c2b7d1e8a4c6f9b0d2e5a7c1f4b8d",
"type": "invoiceSent",
"createdAt": "2026-01-15T09:30:00.000Z",
"mode": "test",
"data": {
"object": {
"id": "inv_0000000000000001",
"projectId": "prj_0000000000000001",
"status": "sent",
"refNumber": "F-2026-0042",
"isFinalInvoice": true,
"amount": 1850.5,
"clientRef": {
"id": "cli_0000000000000001",
"name": "Acme AG",
"email": "billing@example.com"
},
"sentAt": "2026-01-15T09:30:00.000Z",
"createdAt": "2026-01-15T09:30:00.000Z",
"updatedAt": "2026-01-15T09:30:00.000Z"
}
}
}
Feld Bedeutung
id Die Kennung des Ereignisses, evt_ gefolgt von 32 Hexadezimalzeichen. Ihr Schlüssel zum Entduplizieren — siehe unten.
type Der Ereignisname — siehe Ereignisnamen.
createdAt Der Zeitpunkt, an dem das Geschäftsereignis eingetreten ist, als ISO-8601-Zeichenkette in UTC. Nicht der Zeitpunkt dieses Zustellversuchs: eine Wiederholung trägt denselben Wert.
mode live oder test, der Modus des Endpunkts, an den das Ereignis zugestellt wurde.
data.object Die Ressource, um die es in dem Ereignis geht.

data.object wird aus denselben Schemata gebaut wie die Leseantworten der API. Sie verarbeiten also eine Form pro Ressource, gleich ob Sie sie über ein GET erhalten oder per Webhook zugestellt bekommen haben. Jeder Zeitstempel, auf dem Umschlag wie in der Ressource, ist eine ISO-8601-Zeichenkette.

Lesen Sie die Ressource aus data.object, nicht aus data: Diese Verschachtelung erlaubt es uns, später ein Geschwisterfeld hinzuzufügen — etwa die vorherigen Werte der geänderten Felder —, ohne zu verschieben, was Sie bereits lesen.

Ereignisnamen sind in camelCase geschrieben und verwenden dasselbe Vokabular wie die Benachrichtigungen in der App: invoiceSent, quoteAccepted, jobRescheduled. Ein Endpunkt kann jedes dieser Ereignisse abonnieren:

Ressource Ereignisse
Projekt projectCreated, projectUpdated, projectShared, projectClientValidationRequested, projectAccepted, projectDeclined, projectCanceled, projectDone, projectRated, projectReminderCreated, projectReminderUpdated, projectReminderDeleted, projectReminderSent
Einsatz jobCreated, jobUpdated, jobScheduled, jobRescheduled, jobDone, jobNoShow, jobCanceled
Nachricht messageCreated
Offerte quoteCreated, quoteUpdated, quoteDeleted, quoteSent, quoteAccepted, quoteDeclined
Rechnung invoiceCreated, invoiceUpdated, invoiceDeleted, invoiceSent, invoiceAccepted, invoiceDeclined, invoiceAutoMarkPaidFailed, invoiceReminderCreated, invoiceReminderUpdated, invoiceReminderDeleted, invoiceReminderSent, invoiceReminderFailed

Dieselbe Liste ist die event-Aufzählung von POST /test/webhooks/send in der OpenAPI-Beschreibung der API, die massgeblich ist: Ein Name, der dort nicht steht, wird mit einem 400 abgewiesen, das die gültigen Namen aufführt.

Jede Zustellung trägt einen Tay-Signature-Header:

Tay-Signature: t=1758186764,v1=5257a869e7b2cd0e2c...
  • t ist der Unix-Zeitstempel der Signatur.
  • v1 ist ein HMAC-SHA256 in Hexadezimal über die Zeichenkette ${t}.${rawBody}, berechnet mit dem Signaturgeheimnis des Endpunkts.

Das ist Stripes Verfahren. Wer schon einmal einen Stripe-Webhook geprüft hat, hat diesen Code bereits geschrieben.

Die Prüfung besteht aus vier Schritten:

  1. Lesen Sie den Rohrumpf, bevor ein JSON-Parser ihn berührt.
  2. Lösen Sie t und v1 aus dem Header heraus.
  3. Berechnen Sie den HMAC über ${t}.${rawBody} neu und vergleichen Sie ihn in konstanter Zeit mit v1.
  4. Weisen Sie ab, wenn t mehr als 5 Minuten von der aktuellen Zeit abweicht.
import { createHmac, timingSafeEqual } from 'node:crypto';
/** Das Toleranzfenster: fünf Minuten, in beide Richtungen. */
const TOLERANCE_SECONDS = 300;
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((part) => part.split('=')),
);
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const received = Buffer.from(parts.v1, 'hex');
const computed = Buffer.from(expected, 'hex');
// `timingSafeEqual` verlangt zwei Puffer gleicher Länge.
if (received.length !== computed.length) return false;
if (!timingSafeEqual(received, computed)) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
return age <= TOLERANCE_SECONDS;
}

Der Zeitstempel ist gerade deshalb Teil der signierten Zeichenkette, damit Sie eine Wiedereinspielung erkennen können: Ohne ihn bliebe eine abgefangene Anfrage unbegrenzt gültig.

Das ist der wichtigste Punkt dieser Seite.

Antwortet Ihr Endpunkt nicht mit 2xx — weil er langsam oder nicht erreichbar ist oder einen Fehler geliefert hat — wird die Zustellung eine begrenzte Anzahl Male wiederholt und danach als fehlgeschlagen markiert.

Ein Wiederholungsversuch trägt dieselbe id und dasselbe createdAt wie der erste. Er ist kein neues Ereignis: Es ist dasselbe, erneut vorgelegt. Nur das t im Signatur-Header ist neu, weil es den Versuch datiert, nicht das Ereignis.

Sie müssen daher:

  1. die bereits verarbeiteten ids in Ihrer eigenen Datenbank festhalten;
  2. beim Empfang prüfen, ob die id dort schon steht;
  3. falls ja, mit 200 antworten und sonst nichts tun.

Andernfalls verrechnet ein kurzzeitig langsamer Endpunkt zweimal, verschickt zwei E-Mails oder legt zwei Zeilen an. Kein Netz garantiert eine Genau-einmal-Zustellung, und wir tun es auch nicht: Wir garantieren, dass die ID stabil ist — damit können Sie die fehlende Garantie selbst herstellen.

Behandeln Sie auch die Reihenfolge als nicht garantiert. Zwei zeitlich nahe Ereignisse können Sie in vertauschter Reihenfolge erreichen; verlassen Sie sich auf createdAt, nicht auf die Ankunftsreihenfolge.

  • Schnell mit 2xx antworten. Empfang bestätigen, Arbeit einreihen, danach verarbeiten — langsame Verarbeitung führt zu einer Zeitüberschreitung, also zu einer Wiederholung, also zu einem Duplikat.
  • Auch bei einem unbekannten type mit 2xx antworten. Es können neue Ereignisse hinzukommen, und sie abzuweisen löst nur sinnlose Wiederholungen aus.
  • Niemals einer Nutzlast vertrauen, deren Signatur Sie nicht geprüft haben.