Browse the library
Documentation libraryIntegration
Webhooks
Webhooks push an app's events (licenses, devices, sessions, users, Protect 24/7, releases) to your own server as an HMAC-signed HTTP `POST`, a few seconds after they happen. Use them to sync a CRM, email customers, open tickets or drive automation without polling the API.
10 min read
How webhooks work
- Each webhook is an endpoint that belongs to one app and receives only the events you pick. An app can have several endpoints; an event that matches more than one is delivered to each of them separately.
- Every delivery is signed with the endpoint's own signing secret, retried for about 3 days when it fails, and kept in a delivery log you can inspect and resend from.
- Every workspace member can view the webhooks page. Only Admins and Owners can create, toggle, test, rotate secrets, delete and resend.
Creating an endpoint
- Open the app and go to Integration › Webhooks.
- Click Add webhook. The New webhook panel opens.
- Enter the Endpoint URL: the full address, up to 500 characters, for example
https://example.com/clavis. - Optionally add a Note (up to 200 characters), such as "CRM sync". It is shown under the URL in the list.
- Under Events, tick what you want to receive. Events are grouped by prefix (
license,device,session…), andlicense.createdandsecurity.incidentare preselected. At least one event is required. - Click Create webhook. The Signing secret dialog opens right away (see below).
What the endpoint URL must meet:
- It uses
https://. At send time the host must resolve to a public IP address: localhost and private ranges such as10.0.0.0/8,172.16.0.0/12and192.168.0.0/16are refused. - It answers with a 2xx status within 10 seconds. Redirects (3xx) are not followed and count as a failure.
The dashboard has no way to edit an existing endpoint's URL, note or events yet. To change them, delete the endpoint (trash icon Delete, confirm "Delete this webhook?") and create it again. The new endpoint gets a new signing secret, and the old endpoint's delivery log is deleted with it.
Event catalog
| Event | When | Main fields in data |
|---|---|---|
license.created | Licenses are created from the dashboard, the Seller API or the reseller portal; one event per batch | targetType, targetId, count |
license.activated | A license is activated for the first time from your software | licenseId, deviceId |
license.expiring | A license enters the expiry notice window of its license plan, once per term | licenseId, expiresAt |
license.expired | A license passes its end date plus the grace days | licenseId, expiresAt |
license.suspended | A license is suspended, one at a time or in bulk | targetType, targetId |
license.revoked | A license is revoked, one at a time or in bulk | targetType, targetId |
license.extended | A license is extended, one at a time or in bulk | targetType, targetId |
validation.failed | The server refuses a request from your software | reason (error code), event, licenseId, ip |
device.added | A new machine is bound to a license | licenseId, deviceId, platform |
device.removed | A machine is removed from a license in the dashboard or the Seller API | targetType, targetId |
device.blocked | A machine is blocked | targetType, targetId |
device.transferred | A customer moves their license to another machine, in your software or the customer portal | licenseId, deviceId, by |
session.started | Your software signs in successfully and a session opens | licenseId, deviceId, sessionId, endUserId |
session.ended | Your software calls logout() | sessionId, licenseId, deviceId |
session.revoked | A session is revoked in the dashboard or the Seller API | targetType, targetId |
user.created | An end-user account is created in the dashboard or the Seller API | targetType, targetId |
user.banned | An end-user account is banned | targetType, targetId |
security.incident | Protect 24/7 opens a new incident | incidentId, signal, severity, subjectType, subjectId |
security.action | Protect 24/7 acts on its own under Graduated Enforcement | actionId, action, subjectType, subjectId, reason |
release.published | A release is published | targetType, targetId |
reseller.credit_low | A reseller's credit drops below the price of 5 keys of the dearest plan they sell, once per crossing | resellerId, balance |
webhook.test | You click Send test | message |
Good to know:
- Actions taken in the dashboard or the Seller API always carry
targetType,targetIdand the details of that action; the Seller API addsapiKeyIdandapiKeyName. Readdatadefensively and tolerate missing or new fields. - Events raised by your software (
license.activated,validation.failed,device.added,device.transferredfrom the software,session.started,session.ended) are only recorded while the app has at least one enabled webhook subscribed to them. The server caches that list for about 30 seconds, so events in the first moments after you create or re-enable an endpoint can be missed. - In the current version, accounts that your software registers through
register()do not raiseuser.created. - License dates are checked every hour, so
license.expiringandlicense.expiredcan arrive up to about an hour late.
The signing secret
Right after creation, the Signing secret dialog shows a whsec_… secret with the line "Shown once, store it somewhere safe now", along with a ready-made Node snippet for checking signatures. Copy the secret into your receiving server's secret store, then click Saved it. ClavisAUH stores the secret encrypted and can never show it again.
To rotate it, click New secret on the endpoint's card. There is no confirmation step: the new secret replaces the old one at once and appears in the same Signing secret dialog. Every delivery from then on, including retries of older events, is signed with the new secret.
Payload and headers
Each delivery is a POST with a JSON body:
{
"id": "evt_…",
"type": "license.activated",
"createdAt": "2026-11-02T08:15:30.000Z",
"appId": "app_…",
"data": { "licenseId": "lic_…", "deviceId": "dev_…" }
}
| Field | Meaning |
|---|---|
id | Event id. It stays the same across retries and resends, and across endpoints. Use it to drop duplicates. Test events look like evt_test_… |
type | The event name from the catalog |
createdAt | When the event happened, ISO 8601 in UTC |
appId | The app the event belongs to |
data | Event details, which vary by event and source |
Headers sent with it:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | ClavisAUH-Webhooks/1 |
Clavis-Signature | t=<unix>,v1=<hex> |
Clavis-Event | The event name, same as type |
Clavis-Delivery | The delivery's id in the log. It stays the same across automatic retries and changes when you click Resend |
Verifying Clavis-Signature
v1 is the hex HMAC-SHA256 of <t>.<body>, where t is the signing time in Unix seconds, body is the raw request body exactly as received, and the key is the full whsec_… string. t is computed fresh on every attempt, so a 5-minute tolerance check also works for retries.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
// Clavis-Signature: t=<unix>,v1=<hex>
export function verifyClavis(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 minutes of tolerance
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
const received = Buffer.from(parts.v1 ?? "", "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
const app = express();
// Keep the raw body so the signature is checked before JSON parsing
app.post("/clavis", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyClavis(raw, req.get("clavis-signature") ?? "", process.env.CLAVIS_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
await saveOnce(event.id, event); // store by event.id, skip if already there; do heavy work in a background job
res.sendStatus(204);
});
Rules for your receiver:
- Check the signature on the raw body, before parsing. Parsing and re-serializing the JSON breaks the signature.
- Answer 2xx quickly and do slow work in the background. Anything over 10 seconds counts as a failure.
- Deduplicate on
id: the same event can arrive more than once through retries or resends. - Do not rely on arrival order. Deliveries run in parallel and a retry can land after a newer event; sort by
createdAtwhen order matters. - Answer 2xx even for events you deliberately ignore. Every other status, 4xx included, is retried.
Retries and backoff
A delivery succeeds when your endpoint answers with a 2xx status. Any other status, a timeout past 10 seconds, a network error or a refused address is a failure, and ClavisAUH retries on its own:
- Up to 15 attempts in total: the first try plus 14 retries.
- The wait doubles after each failure: 1 minute, 2, 4, 8… up to a ceiling of 12 hours between attempts. All told, about 3 days.
- While attempts remain, the log shows the delivery as Pending with the latest error and the attempt count. Failed only appears after the last attempt.
Pausing after repeated failures
When a delivery fails its final attempt, the endpoint is paused: the switch on its card turns off, the line "Paused after repeated failures, switch it back on once fixed" appears, and every Owner and Admin of the workspace gets an email with a button that opens the webhooks page.
While an endpoint is paused:
- New events are not recorded for it, and they cannot be sent later.
- Deliveries still waiting for a retry turn Failed with the error
webhook_disabled.
To resume, fix your endpoint and flip the switch back on. This also resets the failure counter. Then open the log and Resend the deliveries that failed. Turning the switch off yourself has the same effect as a pause, minus the email.
Sending a test event
Click Send test on the endpoint's card. The "Test event sent" toast means the event was queued, not that it arrived; the result shows up in the log a few seconds later. The webhook.test event goes through the same signed, logged path as real events, whether or not you ticked webhook.test, and its data is { "message": "Signed and delivered by ClavisAUH" }.
The delivery log and resending
Each endpoint card shows the last 24 hours at a glance: "… delivered", "… failed", and "… pending" when there are any. Click Delivery log to open that endpoint's Delivery log panel. It refreshes every 5 seconds and shows the 50 most recent deliveries, each with the event name, status (Pending, Delivered, Failed), HTTP status, response time, attempt count (×3) and age.
Click a row to see the last error, the payload, and the first 1,000 characters of your server's response. Resend creates a new delivery with the exact same payload (same id); the toast "Queued again" confirms it, and the original row stays in the log. If the endpoint is paused, switch it back on first, or the resend fails straight away with webhook_disabled.
| Error in the log | Cause |
|---|---|
HTTP 500 (or another status) | Your endpoint answered with a non-2xx status |
webhook_disabled | The endpoint was off or paused at send time |
endpoint resolves to a private address | The host resolves to an internal address |
only https endpoints are allowed | The URL does not use https |
unreadable_secret | The server cannot decrypt the stored secret; retrying will not help, so click New secret and update your receiver |
Delivery log entries are cleared according to your plan's log retention, except deliveries that are still pending.
Plan limits
The webhook count covers the whole workspace, all apps together. By default Basic allows 1 webhook and Premium 50; the number that applies to you is in Account & organization › Plan & usage. Once an app's list reaches the limit, Add webhook is disabled and the page says "Your plan allows N webhooks", with N your limit. If the remaining allowance is already used by other apps, creating fails with plan_limit (HTTP 402). See Basic, Premium and limits and pricing.
Managing webhooks with the Seller API
A Seller API key with the webhooks:read or webhooks:write scope can manage webhooks from your own systems:
GET /apps/{appId}/webhooks: list the app's endpoints.POST /apps/{appId}/webhooks: create an endpoint withurl,eventsand an optionaldescription. The secret is returned once, indata.secret. Pass"events": ["*"]to receive every event, including ones added later; the dashboard does not offer this.DELETE /webhooks/{id}: delete an endpoint.POST /webhooks/{id}/test: send awebhook.testevent.
Rotating the secret, toggling, reading the log and resending are dashboard-only for now. Plan limits apply the same way. Details are in the Seller API reference.