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:
- 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.
- 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.
- 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.1zeigt, wird nicht mehr beliefert. - Port 443 oder 8443.
- Keine Weiterleitungen. Ein
3xxist eine fehlgeschlagene Zustellung, der wir nicht folgen.
Ereignisse
Abonniere eine Auswahl, oder lass die Auswahl leer, um alles zu erhalten.
<!-- events:start -->
Abrechnung
| Ereignis | Wann | Payload-Felder |
|---|---|---|
order.created | Eine Bestellung wurde aufgegeben. | orderId, orderNumber, customerId, totalCents, currency, status |
order.paid | Die Rechnung einer Bestellung wurde vollständig bezahlt. | orderId, orderNumber, invoiceId, customerId, totalCents, currency |
order.canceled | Eine Bestellung wurde storniert. | orderId, orderNumber, customerId, status |
invoice.created | Eine Rechnung wurde ausgestellt. | invoiceId, invoiceNumber, customerId, totalCents, currency, dueAt, status |
invoice.paid | Eine Rechnung wurde bezahlt. | invoiceId, invoiceNumber, customerId, totalCents, currency, paidAt, source |
invoice.overdue | Eine Rechnung ist über das Fälligkeitsdatum hinaus unbezahlt. | invoiceId, invoiceNumber, customerId, totalCents, currency, dueAt, daysOverdue |
payment.succeeded | Eine Zahlung wurde eingezogen. | invoiceId, customerId, amountCents, currency, method |
payment.failed | Ein Zahlungsversuch ist fehlgeschlagen. | invoiceId, customerId, amountCents, currency, method, reason |
funds.added | Dem Guthaben eines Kunden wurde Kontoguthaben gutgeschrieben. | customerId, amountCents, currency, invoiceId |
Dienste und Domains
| Ereignis | Wann | Payload-Felder |
|---|---|---|
service.provisioned | Ein Dienst wurde fertig eingerichtet und ist aktiv. | serviceId, customerId, productId, productName, status, domain |
service.suspended | Ein Dienst wurde gesperrt. | serviceId, customerId, productId, status |
service.cancelled | Ein Dienst wurde gekündigt: Sein Vertrag ist beendet und er wird nicht mehr abgerechnet. | serviceId, customerId, productId, status |
service.unsuspended | Ein gesperrter Dienst wurde wieder aktiviert. | serviceId, customerId, productId, status |
cancellation.requested | Ein Kunde hat darum gebeten, einen Dienst zu kündigen. Noch hat sich nichts geändert, ein Admin entscheidet. | serviceId, customerId, requestId, timing, reason |
cancellation.approved | Ein Admin hat eine Kündigungsanfrage genehmigt. effectiveAt sagt, wann sie wirksam wird. | serviceId, customerId, requestId, timing, effectiveAt |
cancellation.declined | Ein Admin hat eine Kündigungsanfrage abgelehnt. Der Dienst läuft unverändert weiter. | serviceId, customerId, requestId, reason |
service.terminated | Ein Dienst wurde beendet. | serviceId, customerId, productId, status |
service.deleted | Ein Admin hat einen Dienst-Datensatz gelöscht. Beim Anbieter wurde nichts beendet. | serviceId, customerId, label, status |
service.upgraded | Ein Dienst hat den Tarif gewechselt. | serviceId, customerId, productId, productName, status |
service.addon_added | Einheiten einer Zusatzoption wurden zu einem Dienst hinzugefügt. | serviceId, customerId, addOnId, quantity |
service.addon_cancelled | Eine Zusatzoption wurde gekündigt. endsAt sagt, wann sie endet; darüber hinaus wird sie nicht verlängert. | serviceId, customerId, addOnId, endsAt |
service.addon_ended | Eine gekündigte Zusatzoption hat ihr Enddatum erreicht und ist beendet. | serviceId, customerId, addOnId, endedAt |
domain.registered | Eine Domain wurde bei der Registrierungsstelle registriert. | domainId, domain, customerId, serviceId, expiresAt |
domain.renewed | Eine Domain wurde verlängert. | domainId, domain, customerId, serviceId, expiresAt |
domain.transferred | Ein Domain-Transfer wurde gestartet. | domainId, domain, customerId, serviceId, expiresAt |
domain.transfer_pending | Ein bezahlter Domain-Transfer wartet auf seinen Auth-Code; an die Registrierungsstelle wurde nichts gesendet. | domainId, domain, customerId, serviceId, reason |
domain.locked | Eine Domain wurde bei der Registrierungsstelle gegen Transfer gesperrt. | domainId, domain, customerId, serviceId, locked |
domain.unlocked | Eine Domain wurde für den Transfer freigegeben; der Inhaber bereitet den Umzug vor. | domainId, domain, customerId, serviceId, locked |
domain.expired | Eine Domain hat ihr Ablaufdatum überschritten und ist nicht mehr aktiv. | domainId, domain, customerId, serviceId, expiresAt |
domain.suspended | Eine Domain wurde gesperrt, weil ihre Verlängerung unbezahlt blieb; die automatische Verlängerung ist aus. | domainId, domain, customerId, serviceId, expiresAt |
domain.restored | Eine gesperrte Domain wurde nach der Zahlung wiederhergestellt; die Wahl des Inhabers zur automatischen Verlängerung gilt wieder. | domainId, domain, customerId, serviceId, expiresAt |
domain.deleted | Ein Admin hat einen Domain-Datensatz gelöscht. Die Registrierung bei der Registrierungsstelle bleibt unberührt. | domainRecordId, domain, customerId, status |
Kunden
| Ereignis | Wann | Payload-Felder |
|---|---|---|
customer.created | Ein Kundenkonto wurde angelegt. | customerId, email, name, createdAt |
customer.updated | Ein Kunde hat sein Profil aktualisiert. | customerId, email, name |
Support
| Ereignis | Wann | Payload-Felder |
|---|---|---|
ticket.created | Ein Support-Ticket wurde eröffnet. | ticketId, publicId, customerId, subject, status, priority, departmentId |
ticket.replied | Zu einem Ticket wurde eine Antwort hinzugefügt; bei einem geschlossenen Ticket öffnet sie es wieder. | ticketId, publicId, customerId, status, authorRole, reopened |
ticket.closed | Ein Ticket wurde geschlossen. | ticketId, publicId, customerId, status |
Website-Inhalte
| Ereignis | Wann | Payload-Felder |
|---|---|---|
content.published | Ein Beitrag oder eine Seite wurde veröffentlicht. | contentId, type, locale, slug, publishedAt |
content.updated | Ein veröffentlichter Beitrag oder eine veröffentlichte Seite wurde geändert. | contentId, type, locale, slug |
content.unpublished | Ein veröffentlichter Beitrag oder eine veröffentlichte Seite wurde zurückgezogen oder gelöscht. | contentId, type, locale, slug |
Katalog
| Ereignis | Wann | Payload-Felder |
|---|---|---|
catalog.updated | Ein Produkt (mit seinen Preisen), eine Zusatzoption oder eine Kategorie wurde angelegt, geändert, deaktiviert oder gelöscht. | kind, id |
Einstellungen
| Ereignis | Wann | Payload-Felder |
|---|---|---|
settings.updated | Sprachen, 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.requestedist ein Abwanderungssignal, auf das du reagieren kannst, solange sich der Kunde noch umstimmen lässt: Der Dienst ist voll aktiv und kann alscancellation.declinedenden oder durch einen Widerruf, der gar nichts auslöst.cancellation.approvedist die Zusage;service.cancelledist das tatsächliche Ende des Dienstes, und bei einer Genehmigung zum Laufzeitende können die beiden elf Monate auseinanderliegen. Setze ein Abo beiapprovedauf „endet“ und entferne es beiservice.cancelled, nicht beirequested.
Gekündigt ist nicht beendet. Eine geplante Kündigung setzt einen Dienst auf
CANCELLEDund bewusst nie aufTERMINATED: Die Abrechnung endet, das Konto bleibt bestehen, bis ein Admin es von Hand entfernt.service.cancelledist also das Ereignis bei einer gewöhnlichen Kündigung durch den Kunden, undservice.terminatedkommt nur bei der selteneren Beendigung durch einen Admin. Abonniere beide, wenn du wissen willst, dass ein Dienst zu Ende ist. Wer nurservice.terminatedabonniert (so sah dieses Register vor P14 aus, als esservice.cancellednoch nicht gab), verpasst den häufigen Fall vollständig.
Gesperrt hat zwei Enden.
service.suspendedkam 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.unsuspendedwird zusammen mitservice.provisionedausgelö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.expiringgibt es mit Absicht nicht.** Die Ablauferinnerung ist eine Benachrichtigung nach Zeitplan, keine Änderung am Datensatz. Jedes Domain-Ereignis trägt bereitsexpiresAt, 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.deletedunddomain.deletedsagen, 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.