Webhooks API
Manage webhook endpoints with the Webhooks API: create an endpoint for a workflow, list and delete endpoints, and read the delivery details.
A webhook endpoint is a URL in your system that Loopfour calls when a workflow run starts, finishes, fails or is cancelled. Use it to update your own records without polling the Runs API.
This page covers outgoing webhooks. To start a workflow when another system calls Loopfour, see the Webhook trigger.
Every endpoint on this page needs the webhooks:manage scope.
Events
| Event | Sent when |
|---|---|
workflow.started | A run starts running |
workflow.completed | A run completes |
workflow.failed | A run fails |
workflow.cancelled | A run is cancelled |
You can also subscribe to workflow.timeout and workflow.checkpoint. The current runtime does not send either event.
Payload
{
"id": "evt_6f1c2b9e0d4a",
"type": "workflow.completed",
"timestamp": "2026-02-12T18:30:03.000Z",
"workflowId": "c3a1f7d2-58e4-4b9a-8f21-6d0e9b7c4a15",
"workflowName": "Invoice status to Slack",
"runId": "4e1f9c02-7b3d-4a1e-9c85-2f6d0b8e1a47",
"companyId": "0f8fad5b-d9cb-469f-a165-70867728950e",
"status": "completed",
"triggerType": "api",
"startedAt": "2026-02-12T18:30:01.000Z",
"completedAt": "2026-02-12T18:30:03.000Z",
"durationMs": 2000,
"input": { "invoiceId": "1042" },
"output": {}
}A workflow.failed payload also has error with code and message.
Verify the signature
Each delivery is a POST with these headers, plus any custom headers you set on the endpoint:
| Header | Value |
|---|---|
X-Webhook-Signature | t={timestamp},v1={signature} |
X-Webhook-ID | The event id |
X-Webhook-Timestamp | Unix time in seconds |
User-Agent | Loopfour-Workflows/1.0 |
signature is the hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with the endpoint secret. Compute it over the raw request body and compare in constant time. Reject old timestamps to block replays.
import crypto from 'node:crypto';
function isValidLoopfourWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const received = Buffer.from(parts.v1 ?? '', 'hex');
return received.length === expected.length / 2 &&
crypto.timingSafeEqual(received, Buffer.from(expected, 'hex'));
}Retries and disabling
Answer with a 2xx status within 30 seconds. Any other result is a failed attempt. A failed delivery is retried with exponential backoff, starting at about 10 seconds, up to 8 attempts in total. The delivery's nextRetryAt shows the expected time of the next attempt. After 10 failed attempts in a row across deliveries, the endpoint's status becomes disabled and it receives nothing until you set it back to active. A successful delivery resets the count.
Deliveries can arrive more than once. Use X-Webhook-ID to ignore duplicates.
Endpoint object
| Field | Type | Meaning |
|---|---|---|
id | string (UUID) | |
name | string | 1 to 255 characters |
url | string | Up to 2,048 characters |
events | string[] or null | Subscribed events; null means all |
workflowIds | string[] or null | Only these workflows; null means all |
headers | object or null | Custom headers. Get only |
status | string | active, paused or disabled |
lastDeliveredAt | string or null | |
consecutiveFailures | integer | Failed attempts in a row |
description | string or null | Up to 1,000 characters |
createdAt, updatedAt | string (ISO 8601) |
List endpoints
GET /api/v1/webhook-endpointsTakes status (active, paused or disabled), limit (default 20, maximum 100) and cursor.
Create an endpoint
POST /api/v1/webhook-endpoints| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | Yes | 1 to 255 characters |
url | string | Yes | URL that receives events, up to 2,048 characters. Use HTTPS |
events | string[] | No | At least one event. Leave out or null for all |
workflowIds | string[] | No | Workflow UUIDs in your company. Leave out or null for all. An unknown ID returns 400 |
headers | object | No | Extra headers to send. Each value up to 1,000 characters |
secret | string | No | Your own signing secret, 32 to 255 characters. Generated when left out |
description | string | No |
curl -X POST 'https://workflow.loopfour.ai/api/v1/webhook-endpoints' \
-H 'x-api-key: wfk_live_your_api_key' \
-H 'Content-Type: application/json' \
-d '{
"name": "ERP sync on completed close runs",
"url": "https://erp.example.com/hooks/loopfour",
"events": ["workflow.completed", "workflow.failed"],
"workflowIds": ["c3a1f7d2-58e4-4b9a-8f21-6d0e9b7c4a15"]
}'The response is 201 with the endpoint and its secret. Store the secret now: it is returned only on creation and on rotation.
Get, update and delete
GET /api/v1/webhook-endpoints/{id}
PUT /api/v1/webhook-endpoints/{id}
DELETE /api/v1/webhook-endpoints/{id}Update takes any of name, url, events, workflowIds, headers, description and status. status can be active or paused; send active to turn a disabled endpoint back on. Send null for events, workflowIds, headers or description to clear them.
Delete removes the endpoint and returns { "deleted": true }.
Rotate the secret
POST /api/v1/webhook-endpoints/{id}/rotate-secretGenerates a new secret and returns { "secret": "..." }. Deliveries signed after the call use the new secret.
Send a test event
POST /api/v1/webhook-endpoints/{id}/test| Field | Type | Default | Meaning |
|---|---|---|---|
eventType | string | workflow.completed | The event to simulate |
Sends one signed sample payload to the URL right away, with triggerType: "test" and zero UUIDs for the workflow and run. It waits up to 10 seconds and does not retry.
{
"success": true,
"data": {
"success": true,
"httpStatus": 200,
"responseBody": "ok",
"eventId": "evt_test_9b2f4c1a7e3d"
}
}When the request fails, data.success is false and data.error says why.
Deliveries
GET /api/v1/webhook-endpoints/{id}/deliveries
GET /api/v1/webhook-endpoints/{id}/deliveries/{deliveryId}
POST /api/v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retryThe list takes status (pending, delivered, failed or retrying), eventType, runId, limit (default 20, maximum 100) and cursor. Each entry has id, eventType, eventId, runId, workflowId, status, httpStatus, attemptCount, lastError, deliveredAt and createdAt.
The detail also returns payload, responseBody, responseHeaders, nextRetryAt, durationMs and updatedAt.
Retry queues the delivery again and returns { "queued": true, "deliveryId": "...", "taskId": "..." }. A delivery that already succeeded, or an endpoint that is disabled, returns 400.
Errors
| Status | Code | Cause |
|---|---|---|
400 | BAD_REQUEST | Unknown workflow ID, retry of a delivered webhook or a disabled endpoint |
400 | ZodError in error.name | Body or query does not match the schema |
401 | UNAUTHORIZED | Missing or invalid API key |
403 | FORBIDDEN | Key lacks webhooks:manage |
404 | NOT_FOUND | Endpoint or delivery not found |
429 | RATE_LIMITED | Rate limit exceeded |
Loopfour