Webhooks API
Receive provider events into Loopfour and deliver workflow events to your systems
Webhooks API
The Workflows API exposes two webhook surfaces:
- Inbound — endpoints under
/webhooks/*that receive events from external providers (Stripe, HubSpot, Nango, your own systems) and route them to matching workflow triggers. - Outbound — endpoints under
/api/v1/webhook-endpointsthat let you register URLs to receive notifications when workflow runs change state.
Inbound webhook endpoints do not accept an x-api-key. Each route authenticates the caller with the provider's own signature scheme (or by tenant-scoped path for custom webhooks). Outbound management endpoints use the standard API-key auth described in Authentication.
Inbound Webhooks
All inbound routes are mounted at the API root, not under /api/v1. Every inbound POST returns JSON and the same shared response shape:
{
"received": true,
"eventId": "evt_550e8400-e29b-41d4-a716-446655440000",
"workflowsTriggered": 2
}| Field | Type | Description |
|---|---|---|
received | boolean | Always true on success |
eventId | string | null | Internal event ID, when one was persisted |
workflowsTriggered | number | Number of workflow runs triggered by this event |
The full set of inbound routes mounted today is:
| Route | Purpose | Auth |
|---|---|---|
POST /webhooks/stripe | Stripe events (including Stripe Connect) | stripe-signature header (HMAC) |
POST /webhooks/custom/:companyId/:path | Generic per-tenant inbound endpoint | None — path is the secret |
POST /webhooks/:provider | Per-provider dispatcher (Airwallex, HubSpot, JustPaid) | Handler-defined (Svix) |
POST /webhooks/resend | Inbound email (email.received) | Svix signature over RESEND_WEBHOOK_SECRET |
POST /webhooks/nango | Internal — OAuth completion and provider events forwarded by Nango | x-nango-hmac-sha256 or legacy x-nango-signature (required) |
All inbound routes share a single rate-limit bucket of 10,000 requests / minute / IP.
Per-provider routes like /webhooks/hubspot/:companyId, /webhooks/quickbooks/:companyId, /webhooks/salesforce/:companyId, /webhooks/pandadoc/:companyId, /webhooks/netsuite/:companyId, and /webhooks/gmail/:companyId do not exist today. Earlier versions of this document advertised them — they were never implemented. Provider events from those systems arrive via the Nango forwarder path (POST /webhooks/nango) once you have authorized the connection through Nango Connect.
Stripe
POST /webhooks/stripeReceives events from Stripe directly. Supports both single-account workspaces and Stripe Connect — the connected account is read from event.account in the payload and resolved to a company via the connections table.
Headers
| Header | Required | Description |
|---|---|---|
stripe-signature | Yes | Stripe webhook signature in the form t=<unix_ts>,v1=<hex> |
Content-Type | Yes | application/json |
Behaviour
- Reads the raw body and
stripe-signatureheader. - If
STRIPE_WEBHOOK_SECRETis set, verifies the signature using HMAC-SHA256 over${timestamp}.${rawBody}. On mismatch the route returns401 Invalid signatureand persists the event withsignatureVerified: "failed". - If the event has
event.account, looks up the company that owns the matching Stripe-connected account viaconnections.externalAccountId. If no company is found the event is still recorded but no workflows are triggered. - Routes the event into matching workflow triggers and returns the standard response.
Example
stripe listen --forward-to localhost:3000/webhooks/stripe
curl -X POST "https://workflow.loopfour.ai/webhooks/stripe" \
-H "Content-Type: application/json" \
-H "stripe-signature: t=1705312000,v1=abc123..." \
-d '{
"id": "evt_test",
"type": "invoice.paid",
"account": "acct_xxx",
"data": { "object": { "id": "in_test", "amount_paid": 9900 } }
}'Custom (per-tenant)
POST /webhooks/custom/:companyId/:pathReceives events from any external system into a specific company. The :path segment becomes the event type, and a workflow matches when its webhook trigger's path field equals that segment. The comparison is case-insensitive and treats ., -, and _ as equivalent, so order.received and order-received both match. Do not put a leading slash on path — it will never match.
| Path Parameter | Description |
|---|---|
companyId | UUID of the receiving company |
path | Free-form identifier that becomes the event type |
The body is parsed as JSON when possible; non-JSON payloads are wrapped as { "rawBody": "..." }. No signature verification is performed — treat the URL itself as a shared secret and serve over HTTPS only.
curl -X POST "https://your-api.com/webhooks/custom/00000000-0000-0000-0000-000000000001/order-received" \
-H "Content-Type: application/json" \
-d '{ "orderId": "12345", "total": 99.99 }'Provider Dispatcher
POST /webhooks/:providerGeneric dispatcher for providers that have a ProviderHandler registered in the @workflows/modules module package. Today the dispatcher accepts:
:provider value | Source module |
|---|---|
airwallex | integrations/airwallex |
hubspot | integrations/hubspot |
justpaid | integrations/justpaid |
Each handler implements a four-method contract:
| Step | Method | Responsibility |
|---|---|---|
| 1 | verify(rawBody, headers) | Validate signature; return false to short-circuit with 401 Invalid signature |
| 2 | parseEvents(rawBody) | Split the request body into one or more provider events |
| 3 | extractExternalAccountId(event) | Pull the tenant identifier from each event (e.g. HubSpot portalId) |
| 4 | normalize(event, id) | Return a CanonicalEnvelope with eventType, externalEventId, and data |
Events whose extractExternalAccountId returns null, or that do not match any active connections row for the provider, are dropped with an info-level log. Successful events are routed into matching workflow triggers and counted in the response:
{ "received": true, "count": 3 }Unknown providers
A POST to /webhooks/:provider for a provider that has no registered handler returns:
{ "error": "Unknown provider: <name>" }with HTTP 404.
Nango (internal)
POST /webhooks/nangoReceives OAuth lifecycle and provider events forwarded by Nango. This is how Salesforce, QuickBooks, NetSuite, PandaDoc, Slack, and Gmail events reach Workflows today.
| Header | Description |
|---|---|
x-nango-hmac-sha256 | HMAC-SHA256 over the raw body, keyed by NANGO_WEBHOOK_SECRET. Preferred. |
x-nango-signature | Legacy form — sha256(secret + rawBody). Accepted when the HMAC header is absent. |
This route is fail-closed: an unset NANGO_WEBHOOK_SECRET returns 500, and a request with no signature header or a signature that does not verify returns 401.
type field | Behaviour |
|---|---|
auth | OAuth flow completed — flips a pending connections row to active or error, fetches externalAccountId, and registers any provider-specific subscriptions (e.g. HubSpot webhook subscriptions) |
sync | Sync run finished — updates lastSyncedAt on the connection |
webhook | An external provider sent an event via Nango — resolves the connection by nangoConnectionId, maps to a WebhookProvider, and routes through the same workflow-trigger pipeline used by direct routes |
This route is configured in your Nango environment and should not be called directly.
Webhook Event Storage
Every inbound webhook is persisted to webhook_events before workflow matching, so events can be audited, replayed, and inspected even if no workflow consumed them.
{
"id": "evt_550e8400-e29b-41d4-a716-446655440000",
"companyId": "00000000-0000-0000-0000-000000000001",
"provider": "stripe",
"eventType": "invoice.paid",
"eventId": "evt_external_123",
"payload": { "...": "..." },
"signatureVerified": "verified",
"status": "processed",
"workflowsTriggered": [
{ "workflowId": "wf_xxx", "runId": "run_xxx" }
],
"createdAt": "2024-01-15T10:30:00.000Z"
}signatureVerified | Meaning |
|---|---|
pending | Verification not yet attempted |
verified | Signature matched the configured secret |
failed | Signature did not match (route returned 401) |
skipped | No signature header sent, or no secret configured |
status | Meaning |
|---|---|
received | Event recorded, processing has not started |
processing | Currently dispatching to workflow triggers |
processed | All matched workflows started successfully |
failed | Processing or signature verification failed |
ignored | No matching workflows |
Error Responses
| HTTP | Body | Triggered by |
|---|---|---|
400 | { "error": "Invalid JSON" } | Body was not valid JSON when JSON was required |
400 | { "error": "Invalid payload" } | Provider handler's parseEvents threw |
401 | { "error": "Invalid signature" } | Signature verification failed |
404 | { "error": "Unknown provider: <name>" } | POST /webhooks/:provider with no registered handler |
429 | Standard rate-limit envelope | More than 10,000 webhook requests / min / IP |
500 | { "error": "Verification error" } | Provider handler's verify threw |
Workflow Trigger Configuration
To start a workflow from an inbound webhook, declare a webhook trigger in the workflow definition. path is required by the trigger schema; provider matches the inbound route (e.g. stripe, hubspot, justpaid, custom).
{
"trigger": {
"type": "webhook",
"provider": "stripe",
"path": "stripe"
}
}For custom webhooks, omit provider and set path to the :path segment of the URL:
{
"trigger": {
"type": "webhook",
"path": "order-received"
}
}Matching is on provider (or, for custom, on path) only — every event that
reaches the matched route starts the workflow. Narrow the events at the
provider's own subscription settings, or with a Condition block at the top of
the workflow.
See Webhook Triggers for the full trigger configuration reference.
Outbound Webhooks
Register outbound endpoints to receive notifications when workflow runs change state. These endpoints are managed via the standard x-api-key API.
Outbound webhooks notify your systems about workflow events. This is the inverse of the inbound surface, which lets external providers trigger workflows.
Endpoint Management
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/webhook-endpoints | List endpoints |
POST | /api/v1/webhook-endpoints | Create endpoint |
GET | /api/v1/webhook-endpoints/:id | Get endpoint |
PUT | /api/v1/webhook-endpoints/:id | Update endpoint |
DELETE | /api/v1/webhook-endpoints/:id | Delete endpoint |
POST | /api/v1/webhook-endpoints/:id/rotate-secret | Rotate signing secret |
POST | /api/v1/webhook-endpoints/:id/test | Send a test delivery |
GET | /api/v1/webhook-endpoints/:id/deliveries | List delivery attempts |
GET | /api/v1/webhook-endpoints/:id/deliveries/:deliveryId | Get one delivery |
POST | /api/v1/webhook-endpoints/:id/deliveries/:deliveryId/retry | Retry a failed delivery |
Create Endpoint
POST /api/v1/webhook-endpoints| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Human-readable name |
url | string | Yes | HTTPS URL to deliver events to |
events | string[] | No | Event types to receive (omit / null = all) |
workflowIds | string[] | No | Restrict to specific workflows (omit / null = all) |
headers | object | No | Additional headers to include on each delivery |
description | string | No | Free-form description |
Response (201 Created):
{
"success": true,
"data": {
"id": "whe_550e8400-e29b-41d4-a716-446655440000",
"name": "Invoice System",
"url": "https://api.example.com/webhooks/workflows",
"secret": "whsec_abc123...",
"events": ["workflow.completed", "workflow.failed"],
"workflowIds": ["550e8400-e29b-41d4-a716-446655440000"],
"headers": { "Authorization": "Bearer your-token" },
"status": "active",
"createdAt": "2024-01-15T10:00:00.000Z"
}
}The secret is only returned on creation and on rotate-secret. Store it securely — it is required to verify outbound delivery signatures.
Event Types
| Event | Description |
|---|---|
workflow.started | A workflow run began |
workflow.completed | A workflow run completed successfully |
workflow.failed | A workflow run ended with an error |
workflow.timeout | A workflow run exceeded its timeout |
workflow.cancelled | A workflow run was cancelled |
workflow.checkpoint | A long-running workflow reached a checkpoint |
Delivery Payload
{
"id": "evt_550e8400-e29b-41d4-a716-446655440000",
"type": "workflow.completed",
"timestamp": "2024-01-15T10:30:00.000Z",
"workflowId": "550e8400-e29b-41d4-a716-446655440000",
"workflowName": "Invoice Processing",
"runId": "run_660e8400-e29b-41d4-a716-446655440001",
"companyId": "00000000-0000-0000-0000-000000000001",
"status": "completed",
"triggerType": "webhook",
"startedAt": "2024-01-15T10:29:55.000Z",
"completedAt": "2024-01-15T10:30:00.000Z",
"durationMs": 5000,
"input": { "invoiceId": "inv_123" },
"output": { "processed": true }
}Failed runs include an error object:
{
"error": {
"code": "STEP_ERROR",
"message": "API rate limit exceeded",
"stepId": "sync-to-crm"
}
}Signature Verification
Every outbound delivery is signed with HMAC-SHA256 in a Stripe-compatible format.
| Header | Description |
|---|---|
X-Webhook-Signature | t=<unix_ts>,v1=<hex> |
X-Webhook-ID | Unique event ID |
X-Webhook-Timestamp | Unix timestamp |
Content-Type | application/json |
import crypto from 'node:crypto';
function verifyWebhook(rawBody, signatureHeader, secret) {
const [tPart, vPart] = signatureHeader.split(',');
const timestamp = tPart.split('=')[1];
const received = vPart.split('=')[1];
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}Always verify against the raw request body — re-serializing will produce a different hash.
Retry Policy
Failed deliveries (any non-2xx response or transport error) are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | 10 seconds |
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 15 minutes |
| 6 | 1 hour |
| 7 | 6 hours |
| 8 | 24 hours |
After 8 failed attempts the delivery is marked permanently failed. After 10 consecutive failures across deliveries, the endpoint is automatically disabled and must be re-enabled by setting status: "active" on the endpoint.
Test, List Deliveries, Retry
POST /api/v1/webhook-endpoints/:id/test{ "eventType": "workflow.completed" }Sends a synthetic delivery and returns the upstream HTTP status and response body so you can validate signing and connectivity end-to-end.
GET /api/v1/webhook-endpoints/:id/deliveries?status=failedReturns the recent delivery attempts for an endpoint with cursor-based pagination.
POST /api/v1/webhook-endpoints/:id/deliveries/:deliveryId/retryRe-queues a single delivery without waiting for the backoff schedule.
Best Practices
- Be idempotent. Providers may retry. Use
eventIdto deduplicate and write workflow actions so a repeated event has the same outcome. - Respond fast. Acknowledge inbound webhooks immediately — workflow execution is asynchronous, so your handler does not need to wait for the run to finish.
- Always verify when a secret is available. Configure
STRIPE_WEBHOOK_SECRETandNANGO_WEBHOOK_SECRETin production.NANGO_WEBHOOK_SECRETis mandatory wherever Nango webhooks are received — that route is fail-closed and returns500without it. The Stripe route instead skips verification when its secret is unset, which is convenient in development but should never be relied on in an environment that accepts real provider traffic. - Monitor
signatureVerifiedandstatus. A spike infailedorignoredis a strong signal that a provider rotated a secret or that a connection is misconfigured.
Loopfour