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:

  1. 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.
  2. Check the timestamp. Reject anything more than 5 minutes from your clock, in either direction. Without this, a captured delivery can be replayed forever.
  3. 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.1 stops being delivered to.
  • Port 443 or 8443.
  • No redirects. A 3xx is 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

EventWhenPayload fields
order.createdAn order was placed.orderId, orderNumber, customerId, totalCents, currency, status
order.paidAn order's invoice was paid in full.orderId, orderNumber, invoiceId, customerId, totalCents, currency
order.canceledAn order was canceled.orderId, orderNumber, customerId, status
invoice.createdAn invoice was issued.invoiceId, invoiceNumber, customerId, totalCents, currency, dueAt, status
invoice.paidAn invoice was paid.invoiceId, invoiceNumber, customerId, totalCents, currency, paidAt, source
invoice.overdueAn invoice passed its due date unpaid.invoiceId, invoiceNumber, customerId, totalCents, currency, dueAt, daysOverdue
payment.succeededA payment was captured.invoiceId, customerId, amountCents, currency, method
payment.failedA payment attempt failed.invoiceId, customerId, amountCents, currency, method, reason
funds.addedAccount credit was added to a customer's balance.customerId, amountCents, currency, invoiceId

Services and domains

EventWhenPayload fields
service.provisionedA service finished provisioning and is active.serviceId, customerId, productId, productName, status, domain
service.suspendedA service was suspended.serviceId, customerId, productId, status
service.cancelledA service was cancelled — its contract ended and it stopped billing.serviceId, customerId, productId, status
service.unsuspendedA suspended service was made active again.serviceId, customerId, productId, status
cancellation.requestedA customer asked to cancel a service. Nothing has changed yet — an admin decides.serviceId, customerId, requestId, timing, reason
cancellation.approvedAn admin approved a cancellation request. effectiveAt says when it takes effect.serviceId, customerId, requestId, timing, effectiveAt
cancellation.declinedAn admin declined a cancellation request. The service continues unchanged.serviceId, customerId, requestId, reason
service.terminatedA service was terminated.serviceId, customerId, productId, status
service.deletedA service record was deleted by an admin. Nothing was terminated at the provider.serviceId, customerId, label, status
service.upgradedA service changed plan.serviceId, customerId, productId, productName, status
service.addon_addedUnits of an add-on were added to a service.serviceId, customerId, addOnId, quantity
service.addon_cancelledAn add-on was cancelled. endsAt says when it stops; it is not renewed past it.serviceId, customerId, addOnId, endsAt
service.addon_endedA cancelled add-on reached its end date and stopped.serviceId, customerId, addOnId, endedAt
domain.registeredA domain was registered at the registrar.domainId, domain, customerId, serviceId, expiresAt
domain.renewedA domain was renewed.domainId, domain, customerId, serviceId, expiresAt
domain.transferredA domain transfer was started.domainId, domain, customerId, serviceId, expiresAt
domain.transfer_pendingA paid domain transfer is waiting for its auth code; nothing was sent to the registrar.domainId, domain, customerId, serviceId, reason
domain.lockedA domain was locked against transfer at the registrar.domainId, domain, customerId, serviceId, locked
domain.unlockedA domain was unlocked for transfer; the owner is preparing to move it away.domainId, domain, customerId, serviceId, locked
domain.expiredA domain passed its expiry date and is no longer active.domainId, domain, customerId, serviceId, expiresAt
domain.suspendedA domain was suspended because its renewal went unpaid; auto-renew is off.domainId, domain, customerId, serviceId, expiresAt
domain.restoredA suspended domain was restored after payment; the owner's auto-renew choice is back.domainId, domain, customerId, serviceId, expiresAt
domain.deletedA domain record was deleted by an admin. The registration at the registrar is untouched.domainRecordId, domain, customerId, status

Customers

EventWhenPayload fields
customer.createdA customer account was created.customerId, email, name, createdAt
customer.updatedA customer updated their profile.customerId, email, name

Support

EventWhenPayload fields
ticket.createdA support ticket was opened.ticketId, publicId, customerId, subject, status, priority, departmentId
ticket.repliedA reply was added to a ticket.ticketId, publicId, customerId, status, authorRole
ticket.closedA ticket was closed.ticketId, publicId, customerId, status

Website content

EventWhenPayload fields
content.publishedA post or page was published.contentId, type, locale, slug, publishedAt
content.updatedA published post or page was changed.contentId, type, locale, slug
content.unpublishedA published post or page was unpublished or deleted.contentId, type, locale, slug

Catalogue

EventWhenPayload fields
catalog.updatedA product (with its prices), add-on or category was created, changed, deactivated or deleted.kind, id

Settings

EventWhenPayload fields
settings.updatedLanguages, 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.requested is 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 a cancellation.declined or a withdrawal that fires nothing at all. cancellation.approved is the commitment; service.cancelled is the service actually ending, and for an end-of-term approval those two can be eleven months apart. Move a subscription to "ending" on approved, and remove it on service.cancelled — not on requested.

Cancelled is not terminated. A scheduled cancellation moves a service to CANCELLED and deliberately never to TERMINATED — billing stops, the account survives until an admin removes it by hand. So service.cancelled is the event that fires for an ordinary customer cancellation, and service.terminated fires only for the rarer admin termination. Subscribe to both if you are watching for "this service has ended"; subscribing only to service.terminated (the shape of this registry before P14, when service.cancelled did not exist) misses the common case entirely.

Suspended has two ends. service.suspended shipped without a counterpart, so an integration that muted a customer's monitoring on suspension had nothing telling it to switch monitoring back on. service.unsuspended fires alongside service.provisioned rather 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.expiring event, on purpose.** The expiry reminder is a notification on a schedule, not a change to the record. Every domain event already carries expiresAt, 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.deleted and domain.deleted say 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.