Loopfour
API reference

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

EventSent when
workflow.startedA run starts running
workflow.completedA run completes
workflow.failedA run fails
workflow.cancelledA 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:

HeaderValue
X-Webhook-Signaturet={timestamp},v1={signature}
X-Webhook-IDThe event id
X-Webhook-TimestampUnix time in seconds
User-AgentLoopfour-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

FieldTypeMeaning
idstring (UUID)
namestring1 to 255 characters
urlstringUp to 2,048 characters
eventsstring[] or nullSubscribed events; null means all
workflowIdsstring[] or nullOnly these workflows; null means all
headersobject or nullCustom headers. Get only
statusstringactive, paused or disabled
lastDeliveredAtstring or null
consecutiveFailuresintegerFailed attempts in a row
descriptionstring or nullUp to 1,000 characters
createdAt, updatedAtstring (ISO 8601)

List endpoints

GET /api/v1/webhook-endpoints

Takes status (active, paused or disabled), limit (default 20, maximum 100) and cursor.

Create an endpoint

POST /api/v1/webhook-endpoints
FieldTypeRequiredMeaning
namestringYes1 to 255 characters
urlstringYesURL that receives events, up to 2,048 characters. Use HTTPS
eventsstring[]NoAt least one event. Leave out or null for all
workflowIdsstring[]NoWorkflow UUIDs in your company. Leave out or null for all. An unknown ID returns 400
headersobjectNoExtra headers to send. Each value up to 1,000 characters
secretstringNoYour own signing secret, 32 to 255 characters. Generated when left out
descriptionstringNo
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-secret

Generates 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
FieldTypeDefaultMeaning
eventTypestringworkflow.completedThe 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}/retry

The 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

StatusCodeCause
400BAD_REQUESTUnknown workflow ID, retry of a delivered webhook or a disabled endpoint
400ZodError in error.nameBody or query does not match the schema
401UNAUTHORIZEDMissing or invalid API key
403FORBIDDENKey lacks webhooks:manage
404NOT_FOUNDEndpoint or delivery not found
429RATE_LIMITEDRate limit exceeded

On this page