Zum Inhalt springen

Webhooks empfangen

Statt die API in einer Schleife abzufragen, registrieren 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.

Die URL muss https:// verwenden und eine öffentliche Adresse bezeichnen: Eine URL, die eine interne oder lokale Adresse nennt, wird abgewiesen.

Jeder Endpunkt erhält ein Signaturgeheimnis, das bei seiner Erstellung ein einziges Mal angezeigt wird: Bewahren Sie es in diesem Moment auf, es dient zur Prüfung jeder Zustellung.

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,
"paymentMethod": "qrcode",
"iban": "CH93 0076 2011 6238 5295 7",
"fileUrl": "https://files.example.com/invoices/F-2026-0042.pdf?token=test",
"appendixUrls": [],
"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, in ISO 8601, in UTC. Nicht der Zeitpunkt des Versands dieses Versuchs: 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 nur eine Form pro Ressource, gleich ob Sie sie über ein GET erhalten oder per Webhook zugestellt bekommen haben. Alle Zeitstempel, auf der Hülle wie in der Ressource, sind ISO-8601-Zeichenketten.

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

Ereignisnamen sind in camelCase geschrieben und verwenden das Vokabular der Benachrichtigungen 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 maßgeblich ist: Ein Name, der dort nicht steht, wird mit einem 400 abgewiesen, das die gültigen Namen aufführt.

POST /test/webhooks/send sendet mit einem tay_test_…-Schlüssel das Ereignis Ihrer Wahl an einen Ihrer Test-Endpunkte:

Fenêtre de terminal
curl -X POST https://app.tayo.pro/api/v1/test/webhooks/send \
-H "Authorization: Bearer tay_test_ihr_schluessel" \
-H "Content-Type: application/json" \
-d '{ "endpointId": "…", "event": "invoiceSent" }'

Das Ereignis nimmt genau denselben Weg wie ein echtes Ereignis: dieselbe Warteschlange, dieselbe Signatur, dieselben Wiederholungen. Das macht es zu einem echten Test Ihres Endpunkts.

Ein tay_live_…-Schlüssel wird abgewiesen (403 test_key_required), ebenso ein im Live-Modus registrierter Endpunkt (409 endpoint_not_in_test_mode). Die Einzelheiten der Antworten stehen in der Referenz.

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 das Verfahren von Stripe. 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, vor jedem JSON-Parser.
  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 wiederholt: drei Versuche insgesamt, im Abstand von einigen Sekunden, danach wird sie als fehlgeschlagen markiert. Ein Versuch ohne Antwort nach 10 Sekunden zählt als Fehlschlag. Einer Weiterleitung (3xx) wird nie gefolgt: Sie zählt ebenfalls als Fehlschlag.

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 ändert sich, weil es den Versuch datiert, nicht das Ereignis.

Sie müssen daher:

  1. die bereits verarbeiteten ids in Ihrer 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 Kennung stabil ist — damit können Sie die fehlende Garantie selbst herstellen.

Eine fehlgeschlagene Zustellung lässt sich von Hand erneut anstoßen, mit POST /webhook-deliveries/{deliveryId}/retry: Sie wird wieder eingereiht, mit derselben Ereignis-id, und die Antwort ist ein 202. Dieselbe Zustellung lässt sich höchstens 10-mal erneut anstoßen; darüber hinaus lautet die Antwort 409 retry_limit_reached. Die übrigen Ablehnungen stehen unter Erste Schritte mit der API.

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, in weniger als 10 Sekunden. Empfang bestätigen, Arbeit einreihen, danach verarbeiten — eine lange 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 unnötige Wiederholungen aus.
  • Niemals einer Nutzlast vertrauen, deren Signatur nicht geprüft wurde.