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.
Die Form eines Ereignisses
Abschnitt betitelt „Die Form eines Ereignisses“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
Abschnitt betitelt „Ereignisnamen“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.
Ein Testereignis senden
Abschnitt betitelt „Ein Testereignis senden“POST /test/webhooks/send sendet mit einem tay_test_…-Schlüssel das Ereignis
Ihrer Wahl an einen Ihrer Test-Endpunkte:
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.
Die Signatur prüfen
Abschnitt betitelt „Die Signatur prüfen“Jede Zustellung trägt einen Tay-Signature-Header:
Tay-Signature: t=1758186764,v1=5257a869e7b2cd0e2c...tist der Unix-Zeitstempel der Signatur.v1ist 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:
- Lesen Sie den Rohrumpf, vor jedem JSON-Parser.
- Lösen Sie
tundv1aus dem Header heraus. - Berechnen Sie den HMAC über
${t}.${rawBody}neu und vergleichen Sie ihn in konstanter Zeit mitv1. - Weisen Sie ab, wenn
tmehr 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.
Anhand der Ereignis-ID entduplizieren
Abschnitt betitelt „Anhand der Ereignis-ID entduplizieren“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:
- die bereits verarbeiteten
ids in Ihrer Datenbank festhalten; - beim Empfang prüfen, ob die
iddort schon steht; - falls ja, mit
200antworten 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.
Was Ihr Endpunkt tun sollte
Abschnitt betitelt „Was Ihr Endpunkt tun sollte“- Schnell mit
2xxantworten, 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
typemit2xxantworten. 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.