Teculiar schickt per POST einen signierten JSON-Body an deinen Server, wenn im Geschäft, das es für dich betreibt, etwas passiert: eine Rechnung wird bezahlt, ein Dienst geht live, ein Ticket wird eröffnet. So bindest du ein CRM, ein Buchhaltungssystem oder einen Chat-Kanal an, ohne die API ständig abzufragen.

Endpunkte richtest du unter Admin → Einstellungen → Webhooks ein.

Die Anfrage

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Teculiar-Webhooks/1
Teculiar-Event-Id: 9f1b6a2c-5f7d-4b8e-9c11-6b0f2a3d4e5f
Teculiar-Event-Type: invoice.paid
Teculiar-Signature: t=1754650000,v1=5f2a…

{
  "id": "9f1b6a2c-5f7d-4b8e-9c11-6b0f2a3d4e5f",
  "type": "invoice.paid",
  "createdAt": "2026-08-08T14:20:00.000Z",
  "data": {
    "invoiceId": "inv_7Kd2",
    "invoiceNumber": "2026-000431",
    "customerId": "cus_9Xa1",
    "totalCents": 1499,
    "currency": "EUR",
    "paidAt": "2026-08-08T14:20:00.000Z",
    "source": "gateway"
  }
}

Antworte mit 2xx, und wir betrachten die Zustellung als erledigt. Alles andere ist ein Fehler und wird wiederholt.

Antworte schnell. Wir warten standardmäßig 10 Sekunden (eine Einstellung pro Tenant, begrenzt von der Plattform). Wenn dein Handler echte Arbeit erledigt, stelle sie in eine Warteschlange und gib zuerst 200 zurück: Ein langsamer Handler führt zu Wiederholungen, und Wiederholungen führen zu Duplikaten.

Signatur prüfen: immer

Die Signatur ist das Einzige, was ein echtes Ereignis von jedem unterscheidet, der deine URL kennt. Ein Empfänger, der sie überspringt, richtet gern einen Dienst ein, nur weil ein Fremder darum gebeten hat.

Teculiar-Signature hat die Form t=<Unix-Sekunden>,v1=<hmac-sha256 hex>. Der HMAC wird über "{t}.{roher Request-Body}" mit dem whsec_…-Geheimnis deines Endpunkts berechnet, das du jederzeit im Admin einsehen kannst.

Drei Regeln, auf die es ankommt:

  1. Nimm den ROHEN Body. Wenn du das geparste JSON neu serialisierst, können sich Schlüssel- reihenfolge oder Zahlenformat ändern, und du berechnest für ein identisches Ereignis einen anderen Digest.
  2. Prüfe den Zeitstempel. Lehne alles ab, was mehr als 5 Minuten von deiner Uhr abweicht, in beide Richtungen. Ohne das lässt sich eine abgefangene Zustellung beliebig lange wieder einspielen.
  3. Vergleiche in konstanter Zeit. === auf einem HMAC ist ein Timing-Orakel.

Node

const crypto = require("node:crypto");

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    return false;
  }
  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const a = Buffer.from(parts.v1 ?? "", "utf8");
  const b = Buffer.from(expected, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: capture the raw body BEFORE any JSON parser touches it.
app.post("/hooks/teculiar", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body.toString("utf8"), req.get("Teculiar-Signature") ?? "", process.env.TECULIAR_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200);          // acknowledge first
  void handle(event);            // then do the work
});

PHP

Die meisten Tenants auf dieser Plattform betreiben WordPress, deshalb ist PHP hier kein Nachgedanke.

<?php
$raw    = file_get_contents('php://input');
$header = $_SERVER['HTTP_TECULIAR_SIGNATURE'] ?? '';
$secret = getenv('TECULIAR_WEBHOOK_SECRET');

$parts = [];
foreach (explode(',', $header) as $part) {
    [$k, $v] = array_pad(explode('=', $part, 2), 2, '');
    $parts[trim($k)] = trim($v);
}

$timestamp = isset($parts['t']) ? (int) $parts['t'] : 0;
if ($timestamp === 0 || abs(time() - $timestamp) > 300) {
    http_response_code(400);
    exit;
}

$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
if (!hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);
http_response_code(200);
// …handle $event

Zustellung, Wiederholungen und Duplikate

Die Zustellung erfolgt mindestens einmal, mit Absicht. Wenn wir zwischen einem verlorenen und einem doppelt gesendeten Ereignis wählen müssen, senden wir doppelt: Ein verlorenes invoice.paid bedeutet, dass ein Kunde bezahlt hat und nichts eingerichtet wurde. Ein Duplikat dagegen kann dein Handler mühelos ignorieren.

Mach deinen Handler daher idempotent. Teculiar-Event-Id bleibt bei jeder Wiederholung desselben Ereignisses gleich. Speichere sie und verwirf alles, was du schon verarbeitet hast.

Eine fehlgeschlagene Zustellung wird nach einem wachsenden Zeitplan wiederholt (Standard: 1 Min., 5 Min., 30 Min., 2 Std., 6 Std., 24 Std.), bis zum eingestellten Limit an Versuchen. Danach wird sie als FAILED markiert und im Zustellprotokoll für eine manuelle Wiederholung gekennzeichnet. Anzahl der Versuche, Backoff-Profil und Timeout sind Einstellungen pro Tenant unter Admin → Einstellungen → Integrationen → Webhooks, jeweils von einer Obergrenze der Plattform begrenzt.

Das Zustellprotokoll unter Admin → Einstellungen → Webhooks zeigt jeden Versuch mit dem Antwortcode deines Servers und den ersten Zeilen seiner Antwort. Das reicht meist, um den Stacktrace deiner eigenen Anwendung zu sehen, ohne die Seite zu verlassen.

Anforderungen an den Endpunkt

  • Nur https. Das sind Geschäftsereignisse über echte Kunden; sie laufen nicht im Klartext durchs Internet.
  • Eine öffentliche Adresse. Private, Loopback-, Link-Local- und CGNAT-Adressen werden abgelehnt, ebenso jeder Hostname, der auf eine davon aufgelöst wird. Das wird bei jedem Versuch neu geprüft, nicht nur beim Speichern: Ein Hostname, der später auf 127.0.0.1 zeigt, wird nicht mehr beliefert.
  • Port 443 oder 8443.
  • Keine Weiterleitungen. Ein 3xx ist eine fehlgeschlagene Zustellung, der wir nicht folgen.

Ereignisse

Abonniere eine Auswahl, oder lass die Auswahl leer, um alles zu erhalten.

<!-- events:start -->

Abrechnung

EreignisWannPayload-Felder
order.createdEine Bestellung wurde aufgegeben.orderId, orderNumber, customerId, totalCents, currency, status
order.paidDie Rechnung einer Bestellung wurde vollständig bezahlt.orderId, orderNumber, invoiceId, customerId, totalCents, currency
order.canceledEine Bestellung wurde storniert.orderId, orderNumber, customerId, status
invoice.createdEine Rechnung wurde ausgestellt.invoiceId, invoiceNumber, customerId, totalCents, currency, dueAt, status
invoice.paidEine Rechnung wurde bezahlt.invoiceId, invoiceNumber, customerId, totalCents, currency, paidAt, source
invoice.overdueEine Rechnung ist über das Fälligkeitsdatum hinaus unbezahlt.invoiceId, invoiceNumber, customerId, totalCents, currency, dueAt, daysOverdue
payment.succeededEine Zahlung wurde eingezogen.invoiceId, customerId, amountCents, currency, method
payment.failedEin Zahlungsversuch ist fehlgeschlagen.invoiceId, customerId, amountCents, currency, method, reason
funds.addedDem Guthaben eines Kunden wurde Kontoguthaben gutgeschrieben.customerId, amountCents, currency, invoiceId

Dienste und Domains

EreignisWannPayload-Felder
service.provisionedEin Dienst wurde fertig eingerichtet und ist aktiv.serviceId, customerId, productId, productName, status, domain
service.suspendedEin Dienst wurde gesperrt.serviceId, customerId, productId, status
service.cancelledEin Dienst wurde gekündigt: Sein Vertrag ist beendet und er wird nicht mehr abgerechnet.serviceId, customerId, productId, status
service.unsuspendedEin gesperrter Dienst wurde wieder aktiviert.serviceId, customerId, productId, status
cancellation.requestedEin Kunde hat darum gebeten, einen Dienst zu kündigen. Noch hat sich nichts geändert, ein Admin entscheidet.serviceId, customerId, requestId, timing, reason
cancellation.approvedEin Admin hat eine Kündigungsanfrage genehmigt. effectiveAt sagt, wann sie wirksam wird.serviceId, customerId, requestId, timing, effectiveAt
cancellation.declinedEin Admin hat eine Kündigungsanfrage abgelehnt. Der Dienst läuft unverändert weiter.serviceId, customerId, requestId, reason
service.terminatedEin Dienst wurde beendet.serviceId, customerId, productId, status
service.deletedEin Admin hat einen Dienst-Datensatz gelöscht. Beim Anbieter wurde nichts beendet.serviceId, customerId, label, status
service.upgradedEin Dienst hat den Tarif gewechselt.serviceId, customerId, productId, productName, status
service.addon_addedEinheiten einer Zusatzoption wurden zu einem Dienst hinzugefügt.serviceId, customerId, addOnId, quantity
service.addon_cancelledEine Zusatzoption wurde gekündigt. endsAt sagt, wann sie endet; darüber hinaus wird sie nicht verlängert.serviceId, customerId, addOnId, endsAt
service.addon_endedEine gekündigte Zusatzoption hat ihr Enddatum erreicht und ist beendet.serviceId, customerId, addOnId, endedAt
domain.registeredEine Domain wurde bei der Registrierungsstelle registriert.domainId, domain, customerId, serviceId, expiresAt
domain.renewedEine Domain wurde verlängert.domainId, domain, customerId, serviceId, expiresAt
domain.transferredEin Domain-Transfer wurde gestartet.domainId, domain, customerId, serviceId, expiresAt
domain.transfer_pendingEin bezahlter Domain-Transfer wartet auf seinen Auth-Code; an die Registrierungsstelle wurde nichts gesendet.domainId, domain, customerId, serviceId, reason
domain.lockedEine Domain wurde bei der Registrierungsstelle gegen Transfer gesperrt.domainId, domain, customerId, serviceId, locked
domain.unlockedEine Domain wurde für den Transfer freigegeben; der Inhaber bereitet den Umzug vor.domainId, domain, customerId, serviceId, locked
domain.expiredEine Domain hat ihr Ablaufdatum überschritten und ist nicht mehr aktiv.domainId, domain, customerId, serviceId, expiresAt
domain.suspendedEine Domain wurde gesperrt, weil ihre Verlängerung unbezahlt blieb; die automatische Verlängerung ist aus.domainId, domain, customerId, serviceId, expiresAt
domain.restoredEine gesperrte Domain wurde nach der Zahlung wiederhergestellt; die Wahl des Inhabers zur automatischen Verlängerung gilt wieder.domainId, domain, customerId, serviceId, expiresAt
domain.deletedEin Admin hat einen Domain-Datensatz gelöscht. Die Registrierung bei der Registrierungsstelle bleibt unberührt.domainRecordId, domain, customerId, status

Kunden

EreignisWannPayload-Felder
customer.createdEin Kundenkonto wurde angelegt.customerId, email, name, createdAt
customer.updatedEin Kunde hat sein Profil aktualisiert.customerId, email, name

Support

EreignisWannPayload-Felder
ticket.createdEin Support-Ticket wurde eröffnet.ticketId, publicId, customerId, subject, status, priority, departmentId
ticket.repliedZu einem Ticket wurde eine Antwort hinzugefügt; bei einem geschlossenen Ticket öffnet sie es wieder.ticketId, publicId, customerId, status, authorRole, reopened
ticket.closedEin Ticket wurde geschlossen.ticketId, publicId, customerId, status

Website-Inhalte

EreignisWannPayload-Felder
content.publishedEin Beitrag oder eine Seite wurde veröffentlicht.contentId, type, locale, slug, publishedAt
content.updatedEin veröffentlichter Beitrag oder eine veröffentlichte Seite wurde geändert.contentId, type, locale, slug
content.unpublishedEin veröffentlichter Beitrag oder eine veröffentlichte Seite wurde zurückgezogen oder gelöscht.contentId, type, locale, slug

Katalog

EreignisWannPayload-Felder
catalog.updatedEin Produkt (mit seinen Preisen), eine Zusatzoption oder eine Kategorie wurde angelegt, geändert, deaktiviert oder gelöscht.kind, id

Einstellungen

EreignisWannPayload-Felder
settings.updatedSprachen, Währungen, Steuerländer oder die Firmendaten wurden gespeichert.keys

<!-- events:end -->

Hinweise zu einzelnen Ereignissen

Die Tabelle oben wird aus dem Ereignisregister erzeugt. Diese Hinweise erklären, wie die Ereignisse zusammenhängen; sie sind von Hand geschrieben.

Eine Anfrage ist keine Kündigung. cancellation.requested ist ein Abwanderungssignal, auf das du reagieren kannst, solange sich der Kunde noch umstimmen lässt: Der Dienst ist voll aktiv und kann als cancellation.declined enden oder durch einen Widerruf, der gar nichts auslöst. cancellation.approved ist die Zusage; service.cancelled ist das tatsächliche Ende des Dienstes, und bei einer Genehmigung zum Laufzeitende können die beiden elf Monate auseinanderliegen. Setze ein Abo bei approved auf „endet“ und entferne es bei service.cancelled, nicht bei requested.

Gekündigt ist nicht beendet. Eine geplante Kündigung setzt einen Dienst auf CANCELLED und bewusst nie auf TERMINATED: Die Abrechnung endet, das Konto bleibt bestehen, bis ein Admin es von Hand entfernt. service.cancelled ist also das Ereignis bei einer gewöhnlichen Kündigung durch den Kunden, und service.terminated kommt nur bei der selteneren Beendigung durch einen Admin. Abonniere beide, wenn du wissen willst, dass ein Dienst zu Ende ist. Wer nur service.terminated abonniert (so sah dieses Register vor P14 aus, als es service.cancelled noch nicht gab), verpasst den häufigen Fall vollständig.

Gesperrt hat zwei Enden. service.suspended kam ohne Gegenstück auf den Markt. Eine Integration, die bei einer Sperrung das Monitoring eines Kunden stummschaltete, erfuhr deshalb nie, dass sie es wieder einschalten soll. service.unsuspended wird zusammen mit service.provisioned ausgelöst, nicht stattdessen: Die Rückkehr aus der Sperrung ist eine andere Tatsache als die Einrichtung, und wer nur wissen will, ob der Dienst läuft, soll beides nicht unterscheiden müssen.

**Ein Ereignis domain.expiring gibt es mit Absicht nicht.** Die Ablauferinnerung ist eine Benachrichtigung nach Zeitplan, keine Änderung am Datensatz. Jedes Domain-Ereignis trägt bereits expiresAt, sodass eine Integration ihre eigene Vorlaufzeit wählen kann. Eine zweite, tägliche Quelle derselben Wahrheit wäre nur eine weitere Sache, die man abgleichen müsste.

Gelöscht ist nicht beendet. service.deleted und domain.deleted sagen, dass der Datensatz in Teculiar überhaupt nicht mehr existiert: Kein späteres Ereignis wird diese ID je wieder erwähnen, eine Integration, die unsere Daten spiegelt, sollte ihre Kopie also verwerfen. Über die Außenwelt sagen sie nichts: Löscht ein Admin einen Dienst, wird der Virtualmin-Account nicht deaktiviert, und das Löschen eines Domain-Datensatzes kündigt weder die Registrierung noch ihre Verlängerung bei der Registrierungsstelle. Wenn du auf diese Ereignisse hin etwas bei einem Anbieter abbaust, handelst du auf Grund einer Aussage, die sie nicht treffen.

Was ein Payload enthält und was nicht

Payloads enthalten IDs und das, was eine Integration zum Handeln braucht, nie einen internen Datensatz. Keine Kundenadressen, keine USt-IDs, keine Mitarbeiternotizen, keine Bestell-Snapshots, keine Ticket-Texte. Ein Webhook geht an eine URL, die du gewählt hast, über das öffentliche Internet, und wird einen Tag lang wiederholt; der kleinste nützliche Body ist der richtige.

Brauchst du mehr, rufst du die öffentliche API mit der ID auf, authentifiziert. Dann bekommst du genau die Felder, die die veröffentlichte Form erlaubt. Siehe public-api.md.

Die Payload-Schlüssel jedes Ereignisses sind in apps/api/src/modules/webhooks/event-registry.ts deklariert, und der Zustellweg reduziert jeden Payload auf genau diese Liste. Ein Feld, das einer Datenbanktabelle hinzugefügt wird, kann nicht versehentlich in einen Webhook-Body gelangen.

Testen

Test senden an einem Endpunkt stellt ein echtes Ereignis über den echten Weg zu: dieselbe Signatur, dieselben Header, dieselbe Zeile im Zustellprotokoll, mit einem offensichtlichen Beispiel-Payload mit "test": true. Ein Test, der eine Abkürzung nähme, würde nichts über das beweisen, was getestet werden soll.