API Webhooks
Current outbound webhook delivery contract for Leenops modules.
Leenops webhooks are module-owned outbound HTTP callbacks. They are not a single global API surface: availability, configuration, scope, and event names depend on the module.
Current model
When a module emits an event, Leenops:
- Finds enabled webhook subscriptions in the same workspace whose
eventsarray contains the emitted event. - Applies module scoping when the module configured a scope column, such as
project_id,contract_id, orbooking_link_id. - Queues one delivery per matching subscription.
- A background dispatcher processes due deliveries.
- Each delivery is sent as an HTTP
POSTto the subscription URL.
Webhook enqueue is additive. In the audited modules, business actions generally continue even if webhook enqueue fails.
Shipped module bindings
| Module | Supported scope |
|---|---|
| Projects | Workspace or project |
| Time | Workspace |
| Support | Workspace |
| Scheduling | Workspace or booking link |
| Contracts | Workspace or contract |
| Procurement | Workspace |
| Planning | Workspace |
| Goals | Workspace |
| Surveys | Workspace |
| Subscriptions | Workspace |
| Marketing | Workspace |
Some modules have richer user-facing webhook settings than others. Treat webhook availability as module-specific and verify it in the module guide or workspace settings.
Delivery payload
Outbound deliveries are JSON.
{
"id": "delivery_123",
"event": "task.updated",
"workspace_id": "00000000-0000-0000-0000-000000000000",
"created_at": "2026-06-27T12:00:00.000Z",
"payload": {
"id": "task_123"
}
}| Field | Description |
|---|---|
id | Delivery row id. Use this for idempotency. |
event | Module event name that matched the subscription. |
workspace_id | Workspace that emitted the event. |
created_at | Delivery row creation time. |
payload | Module-specific event payload. |
The payload shape is event-specific. Do not assume the generic CRM/recruiting/finance examples from older docs apply to every module.
Request headers
Every delivery includes:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Leenops-Signature | sha256=<hmac_hex> |
X-Leenops-Event | Event name |
X-Leenops-Delivery-Id | Delivery row id |
X-Leenops-Timestamp | Unix timestamp in seconds |
X-Leenops-Module | Module binding name, such as projects or support |
Signature verification
Leenops signs this string:
{timestamp_seconds}.{raw_json_body}The signature is HMAC-SHA256 using the subscription secret, encoded as:
sha256=<hex>Recommended receiver checks:
- Read the raw request body before JSON parsing.
- Reject timestamps older than about five minutes.
- Compute HMAC-SHA256 over
{timestamp}.{body}with the subscription secret. - Constant-time compare the computed
sha256=<hex>value withX-Leenops-Signature. - Store
X-Leenops-Delivery-Idor the payloadidto make processing idempotent.
import crypto from "node:crypto";
function verifyLeenopsWebhook(opts: {
body: string;
timestamp: string;
signature: string | null;
secret: string;
}): boolean {
if (!opts.signature?.startsWith("sha256=")) return false;
const signed = `${opts.timestamp}.${opts.body}`;
const expected =
"sha256=" +
crypto.createHmac("sha256", opts.secret).update(signed).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(opts.signature),
);
}Retry behavior
The shared engine currently uses this policy:
| Outcome | Behavior |
|---|---|
HTTP 2xx | Mark delivery delivered; clear next_attempt_at. |
HTTP 429 | Retry. |
HTTP 5xx | Retry. |
| Network error or timeout | Retry. |
Other HTTP 4xx | Permanent failure. |
| Webhook disabled before delivery | Mark failed with webhook disabled. |
Attempts are capped at four total tries: the first attempt plus three retries. Retry delays are approximately:
| Failed attempt | Next delay |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | Permanent failure |
Each request times out after 10 seconds by default.
Delivery cadence
Delivery is asynchronous and can take up to 24 hours on the current service tier. Retry delays are minimum delays; the next dispatcher run determines the actual attempt time.
Receivers must deduplicate by X-Leenops-Delivery-Id. Leenops coordinates
concurrent delivery processing, but network timeouts can still make the same
event visible more than once.
Event examples by module
These event names are documented in code comments or emitted by current module actions. Module availability can differ by workspace and rollout status.
| Module | Example events |
|---|---|
| Projects | task.updated from bulk issue updates; project-scoped or workspace-wide subscriptions. |
| Time | time_entry.created, time_entry.updated, time_entry.deleted, timer.started, timer.stopped, timesheet.submitted, timesheet.approved, timesheet.rejected, calendar_import.converted, invoice_lines.created. |
| Support | Support ticket events emitted by support services and inbound email handling, including ticket creation/customer reply flows. |
| Scheduling | Booking lifecycle events such as created, approved, declined, cancelled, rescheduled, no-show, and payment-related events. |
| Contracts | Contract lifecycle and public-view events emitted from contract actions and renewal cron. |
| Procurement | purchase_order.approved, vendor_bill.paid. |
| Planning | allocation.created, allocation.updated, allocation.deleted, allocation.confirmed, role_placeholder.created, role_placeholder.staffed, capacity.alert. |
| Goals | goal.created, goal.status_changed, goal.closed, cycle.opened, cycle.closed. |
| Surveys | survey.response_submitted, survey.completed. |
| Subscriptions | subscription.created, subscription.updated, subscription.cancelled, subscription.payment_failed, subscription.churned, subscription.plan_changed. |
| Marketing | campaign.scheduled, campaign.sending, campaign.sent, campaign.bounce_threshold_breached, send.opened, send.clicked, send.unsubscribed, send.bounced, segment.resolved. |
What not to assume
- Do not assume webhooks are configured from a single Settings > API > Webhooks page.
- Do not assume static Leenops source IP ranges are published.
- Do not assume five retries; the shared engine caps at four total attempts.
- Do not assume failed webhooks automatically disable the subscription.
- Do not assume every event in older docs exists on the current
/api/v1surface.
Receiver checklist
- Respond with a
2xxstatus only after the delivery is durably accepted. - Return
429or5xxfor transient failures you want retried. - Return other
4xxstatuses only for permanent failures. - Verify signatures against the raw body and timestamp.
- Deduplicate by delivery id.
- Keep handlers below the 10-second timeout; process long work asynchronously.

