Teculiar POSTs a signed JSON body to your server when something happens in the business it runs for you — an invoice is paid, a service goes live, a ticket is opened. It is how you wire a CRM, an accounting system or a chat channel without polling the API.
Configure endpoints at Admin → Settings → Webhooks.
The request
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"
}
}
Answer 2xx and we consider it delivered. Anything else is a failure and gets retried.
Answer fast. We wait 10 seconds by default (a per-tenant setting, capped by the platform). If your handler does real work, queue it and return 200 first — a slow handler turns into retries, and retries turn into duplicates.
Verify the signature — always
The signature is the only thing separating a real event from anyone who has learned your URL. A receiver that skips it will happily provision a service because a stranger asked.
Teculiar-Signature is t=<unix seconds>,v1=<hmac-sha256 hex>. The HMAC is computed over "{t}.{raw request body}" with your endpoint's whsec_… secret, which you can read at any time in the admin UI.
Three rules that matter:
- Use the RAW body. Re-serializing the parsed JSON can change key order or number formatting, and you will compute a different digest for an identical event.
- Check the timestamp. Reject anything more than 5 minutes from your clock, in either direction. Without this, a captured delivery can be replayed forever.
- Compare in constant time.
===on an HMAC is a timing oracle.
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
Most tenants on this platform run WordPress, so this one is not an afterthought.
<?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
Delivery, retries and duplicates
Delivery is at-least-once, deliberately. Given a choice between losing an event and sending it twice, we send twice: a lost invoice.paid means a customer paid and nothing provisioned, while a duplicate is something your handler can trivially ignore.
So make your handler idempotent. Teculiar-Event-Id is stable across every retry of the same event — store it, and drop anything you have already processed.
A failed delivery is retried on a growing schedule (default: 1m, 5m, 30m, 2h, 6h, 24h) up to the configured attempt limit, then marked FAILED and flagged in the delivery log for a manual replay. Attempt count, backoff profile and timeout are per-tenant settings under Admin → Settings → Integrations → Webhooks, each capped by a platform ceiling.
The delivery log at Admin → Settings → Webhooks shows every attempt with your server's response code and the first lines of its body — which is usually enough to see your own stack trace without leaving the page.
Endpoint requirements
- https only. These are business events about real customers; they do not cross the internet in clear text.
- A public address. Private, loopback, link-local and CGNAT addresses are refused, and so is any hostname that resolves to one. This is re-checked on every attempt, not only when you save it — a hostname that later points at
127.0.0.1stops being delivered to. - Port 443 or 8443.
- No redirects. A
3xxis a failed delivery, not something we follow.
Events
Subscribe to a subset, or leave the selection empty to receive everything.
<!-- events:start --> <!-- Generated by scripts/webhooks-doc.ts from the event registry. Do not edit by hand. -->
Billing
| Event | When | Payload fields |
|---|---|---|
order.created | An order was placed. | orderId, orderNumber, customerId, totalCents, currency, status |
order.paid | An order's invoice was paid in full. | orderId, orderNumber, invoiceId, customerId, totalCents, currency |
order.canceled | An order was canceled. | orderId, orderNumber, customerId, status |
invoice.created | An invoice was issued. | invoiceId, invoiceNumber, customerId, totalCents, currency, dueAt, status |
invoice.paid | An invoice was paid. | invoiceId, invoiceNumber, customerId, totalCents, currency, paidAt, source |
invoice.overdue | An invoice passed its due date unpaid. | invoiceId, invoiceNumber, customerId, totalCents, currency, dueAt, daysOverdue |
payment.succeeded | A payment was captured. | invoiceId, customerId, amountCents, currency, method |
payment.failed | A payment attempt failed. | invoiceId, customerId, amountCents, currency, method, reason |
funds.added | Account credit was added to a customer's balance. | customerId, amountCents, currency, invoiceId |
Services and domains
| Event | When | Payload fields |
|---|---|---|
service.provisioned | A service finished provisioning and is active. | serviceId, customerId, productId, productName, status, domain |
service.suspended | A service was suspended. | serviceId, customerId, productId, status |
service.cancelled | A service was cancelled — its contract ended and it stopped billing. | serviceId, customerId, productId, status |
service.unsuspended | A suspended service was made active again. | serviceId, customerId, productId, status |
cancellation.requested | A customer asked to cancel a service. Nothing has changed yet — an admin decides. | serviceId, customerId, requestId, timing, reason |
cancellation.approved | An admin approved a cancellation request. effectiveAt says when it takes effect. | serviceId, customerId, requestId, timing, effectiveAt |
cancellation.declined | An admin declined a cancellation request. The service continues unchanged. | serviceId, customerId, requestId, reason |
service.terminated | A service was terminated. | serviceId, customerId, productId, status |
service.deleted | A service record was deleted by an admin. Nothing was terminated at the provider. | serviceId, customerId, label, status |
service.upgraded | A service changed plan. | serviceId, customerId, productId, productName, status |
service.addon_added | Units of an add-on were added to a service. | serviceId, customerId, addOnId, quantity |
service.addon_cancelled | An add-on was cancelled. endsAt says when it stops; it is not renewed past it. | serviceId, customerId, addOnId, endsAt |
service.addon_ended | A cancelled add-on reached its end date and stopped. | serviceId, customerId, addOnId, endedAt |
domain.registered | A domain was registered at the registrar. | domainId, domain, customerId, serviceId, expiresAt |
domain.renewed | A domain was renewed. | domainId, domain, customerId, serviceId, expiresAt |
domain.transferred | A domain transfer was started. | domainId, domain, customerId, serviceId, expiresAt |
domain.transfer_pending | A paid domain transfer is waiting for its auth code; nothing was sent to the registrar. | domainId, domain, customerId, serviceId, reason |
domain.locked | A domain was locked against transfer at the registrar. | domainId, domain, customerId, serviceId, locked |
domain.unlocked | A domain was unlocked for transfer; the owner is preparing to move it away. | domainId, domain, customerId, serviceId, locked |
domain.expired | A domain passed its expiry date and is no longer active. | domainId, domain, customerId, serviceId, expiresAt |
domain.suspended | A domain was suspended because its renewal went unpaid; auto-renew is off. | domainId, domain, customerId, serviceId, expiresAt |
domain.restored | A suspended domain was restored after payment; the owner's auto-renew choice is back. | domainId, domain, customerId, serviceId, expiresAt |
domain.deleted | A domain record was deleted by an admin. The registration at the registrar is untouched. | domainRecordId, domain, customerId, status |
Customers
| Event | When | Payload fields |
|---|---|---|
customer.created | A customer account was created. | customerId, email, name, createdAt |
customer.updated | A customer updated their profile. | customerId, email, name |
Support
| Event | When | Payload fields |
|---|---|---|
ticket.created | A support ticket was opened. | ticketId, publicId, customerId, subject, status, priority, departmentId |
ticket.replied | A reply was added to a ticket. | ticketId, publicId, customerId, status, authorRole |
ticket.closed | A ticket was closed. | ticketId, publicId, customerId, status |
Website content
| Event | When | Payload fields |
|---|---|---|
content.published | A post or page was published. | contentId, type, locale, slug, publishedAt |
content.updated | A published post or page was changed. | contentId, type, locale, slug |
content.unpublished | A published post or page was unpublished or deleted. | contentId, type, locale, slug |
Catalogue
| Event | When | Payload fields |
|---|---|---|
catalog.updated | A product (with its prices), add-on or category was created, changed, deactivated or deleted. | kind, id |
Settings
| Event | When | Payload fields |
|---|---|---|
settings.updated | Languages, currencies, tax countries or the company details were saved. | keys |
<!-- events:end -->
Notes on individual events
The table above is generated from the registry. These notes explain how the events relate; they are hand written.
A request is not a cancellation.
cancellation.requestedis a churn signal you can act on while the customer can still be talked round — the service is fully live, and it may end as acancellation.declinedor a withdrawal that fires nothing at all.cancellation.approvedis the commitment;service.cancelledis the service actually ending, and for an end-of-term approval those two can be eleven months apart. Move a subscription to "ending" onapproved, and remove it onservice.cancelled— not onrequested.
Cancelled is not terminated. A scheduled cancellation moves a service to
CANCELLEDand deliberately never toTERMINATED— billing stops, the account survives until an admin removes it by hand. Soservice.cancelledis the event that fires for an ordinary customer cancellation, andservice.terminatedfires only for the rarer admin termination. Subscribe to both if you are watching for "this service has ended"; subscribing only toservice.terminated(the shape of this registry before P14, whenservice.cancelleddid not exist) misses the common case entirely.
Suspended has two ends.
service.suspendedshipped without a counterpart, so an integration that muted a customer's monitoring on suspension had nothing telling it to switch monitoring back on.service.unsuspendedfires alongsideservice.provisionedrather than instead of it: coming back from suspension is a different fact from being provisioned, and a subscriber watching only for "is it live" should not have to distinguish them.**There is no
domain.expiringevent, on purpose.** The expiry reminder is a notification on a schedule, not a change to the record. Every domain event already carriesexpiresAt, so an integration can pick its own lead time — a second, daily source of the same truth would only be one more thing to keep in step.Deleted is not ended.
service.deletedanddomain.deletedsay the record is gone from Teculiar altogether — no later event will ever mention that id again, so an integration mirroring our data should drop its copy. They say nothing about the outside world: an admin deleting a service does not disable the Virtualmin account, and deleting a domain record does not cancel the registration or its renewal at the registrar. If you act on these events by tearing something down at a provider, you are acting on a claim they do not make.
What a payload contains — and does not
Payloads carry ids plus what an integration needs to act, never an internal record. No customer addresses, no VAT ids, no staff notes, no order snapshots, no ticket bodies. A webhook goes to a URL you chose, over the public internet, and is retried for a day; the smallest useful body is the right one.
When you need more, call the public API with the id, authenticated — you then get exactly the fields the published shape allows. See public-api.md.
The payload keys for every event are declared in apps/api/src/modules/webhooks/event-registry.ts, and the delivery path reduces every payload to that declared list. A field added to a database table cannot reach a webhook body by accident.
Testing
Send test on any endpoint delivers a real event through the real path — same signing, same headers, same delivery-log row — carrying an obvious "test": true sample payload. A test that took a shortcut would prove nothing about the thing being tested.