Webhooks
Send an organization's events to your own endpoint as signed HTTP requests.
A webhook sends events from an organization to a URL you control. Choose the events, and each time one happens it is POSTed to your endpoint as JSON, signed with the webhook's secret.
A webhook is an automation of kind webhook whose only step delivers the event. Its deliveries are that automation's runs, so they get the same run history, retries and reruns as workflows.
Create a webhook
In the admin dashboard, go to Automations → Webhooks → Add webhook. Or use the SDK:
import { Automations } from "@headless-lms/sdk";
const webhook = await Automations.createWebhook({
url: "https://crm.example.com/hooks/lms",
events: ["entitlement.created", "progress.record.completed"],
description: "CRM sync",
});
webhook.secret; // whsec_… — keep it to verify deliveriesevents must be event types the system emits. GET /api/automations/triggers lists them, the same list the workflow editor offers. Automation events (automation.*) can't be subscribed to.
Signing secrets are encrypted at rest, so the API needs CREDENTIAL_STORE_KEY set. See Self-hosting.
What gets delivered
Each delivery is a POST whose body is the event:
{
"id": "evt_2QZ4mK…",
"type": "entitlement.created",
"version": 1,
"orgId": "org_2QZ4mK…",
"occurredAt": "2026-09-29T10:15:00.000Z",
"data": {}
}data is what the event is about, with the same field names and types as the API. For entitlement.created, it's the entitlement. Fields that only matter inside the system are never sent: an activity's settings (its content), a progress record's position, an invite's tokenHash, a connection's credentialRef, and externalId on users and organizations.
| Header | Value |
|---|---|
content-type | application/json |
webhook-id | The event id. It stays the same across retries and resends; use it to deduplicate. |
webhook-timestamp | Unix time, in seconds, when the attempt was signed. |
webhook-signature | v1, followed by the base64 HMAC-SHA256 signature. |
Verify a delivery
Signatures follow the Standard Webhooks spec, so any Standard Webhooks library can verify them. The signed content is {webhook-id}.{webhook-timestamp}.{body}, and the key is the base64-decoded part of the secret after whsec_.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(secret: string, headers: Headers, rawBody: string): boolean {
const id = headers.get("webhook-id") ?? "";
const timestamp = headers.get("webhook-timestamp") ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = Buffer.from(
createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"),
);
return (headers.get("webhook-signature") ?? "")
.split(" ")
.map((entry) => Buffer.from(entry.split(",")[1] ?? ""))
.some(
(signature) => signature.length === expected.length && timingSafeEqual(signature, expected),
);
}Verify against the raw request body, byte for byte, before parsing it. The timestamp check rejects replayed requests.
Retries
A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds. Any other status, a redirect (redirects are not followed), a timeout or a connection error is a failed attempt.
The number of attempts depends on the workflow engine. With the Hatchet adapter, a failed delivery is retried up to 3 times. The default in-process engine makes a single attempt. A delivery that fails every attempt is recorded as failed.
Answer with a 2xx as soon as the request is received, and do the processing afterwards.
Delivery history and resending
Every delivery is recorded with its event, status and time, plus the error when it failed. You can see them on the webhook's Deliveries tab, or with GET /api/automations/{id}/runs.
To resend a delivery, choose Resend on it, or:
await Automations.rerunAutomationRun({ id: webhook.id, runId });Resending sends the same delivery again: to the webhook's current URL, signed with its current secret, with the original webhook-id. The delivery's status and error are replaced by the new attempt's. A delivery that is still sending can't be resent. Deleting a webhook keeps its delivery history.
Rotating the secret
Rotate secret on the webhook's settings, or POST /api/automations/webhooks/{id}/secret/rotate, replaces the secret immediately. Every delivery after that is signed with the new secret. GET /api/automations/webhooks/{id}/secret reveals the current one.
API
| Endpoint | Does |
|---|---|
GET /api/automations/webhooks | List the organization's webhooks |
POST /api/automations/webhooks | Create a webhook; the response includes its secret |
GET /api/automations/webhooks/{id} | Get a webhook |
PATCH /api/automations/webhooks/{id} | Change url, events, description or enabled |
DELETE /api/automations/webhooks/{id} | Delete a webhook and its secret |
GET /api/automations/webhooks/{id}/secret | Reveal the signing secret |
POST /api/automations/webhooks/{id}/secret/rotate | Replace the signing secret |
GET /api/automations/{id}/runs | List deliveries, filterable by status |
POST /api/automations/{id}/runs/{runId}/rerun | Resend a delivery |
Webhooks also appear in GET /api/automations with kind: "webhook"; pass ?kind=workflow or ?kind=webhook to list one kind. A webhook changes only through the webhook endpoints: PATCH and DELETE /api/automations/{id} answer 409 for a webhook.