Webhooks API
Receive provider events into JustPaid Workflows 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, Trigger.dev, 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 (HubSpot, JustPaid, generic webhook block) | Handler-defined |
POST /webhooks/nango | Internal — OAuth completion and provider events forwarded by Nango | x-nango-signature (HMAC, optional) |
POST /webhooks/trigger | Internal — Trigger.dev run-status callbacks | x-trigger-signature (HMAC, optional) |
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 "http://localhost:3000/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, so workflows with trigger webhook.custom.<path> are matched.
| 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 |
|---|---|
hubspot | integrations/hubspot |
justpaid | integrations/justpaid |
webhook | core/webhook (generic webhook block) |
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-signature | HMAC-SHA256 signature over the raw body. Verified only when NANGO_WEBHOOK_SECRET is set. |
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.
Trigger.dev (internal)
POST /webhooks/triggerReceives run-status callbacks from the Trigger.dev runtime that executes workflow runs.
| Header | Description |
|---|---|
x-trigger-signature | HMAC-SHA256 signature over the raw body. Verified only when TRIGGER_WEBHOOK_SECRET is set, using a timing-safe comparison. |
type field | Resulting workflow_runs.status |
|---|---|
run.completed | completed |
run.failed | failed |
run.cancelled | cancelled |
This route is wired automatically when you deploy the Trigger.dev runtime — adopters should not configure it manually.
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. The provider field matches the inbound route (e.g. stripe, hubspot, justpaid, custom).
{
"trigger": {
"type": "webhook",
"provider": "stripe",
"events": ["invoice.paid", "invoice.payment_failed"]
}
}For custom webhooks, the events list matches the :path segment:
{
"trigger": {
"type": "webhook",
"provider": "custom",
"events": ["order-received"]
}
}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_SECRET,NANGO_WEBHOOK_SECRET, andTRIGGER_WEBHOOK_SECRETin production. The routes fail open in development when secrets are unset, but you should not rely on that in any 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.