JustPaid Workflows
API Reference

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-endpoints that 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
}
FieldTypeDescription
receivedbooleanAlways true on success
eventIdstring | nullInternal event ID, when one was persisted
workflowsTriggerednumberNumber of workflow runs triggered by this event

The full set of inbound routes mounted today is:

RoutePurposeAuth
POST /webhooks/stripeStripe events (including Stripe Connect)stripe-signature header (HMAC)
POST /webhooks/custom/:companyId/:pathGeneric per-tenant inbound endpointNone — path is the secret
POST /webhooks/:providerPer-provider dispatcher (HubSpot, JustPaid, generic webhook block)Handler-defined
POST /webhooks/nangoInternal — OAuth completion and provider events forwarded by Nangox-nango-signature (HMAC, optional)
POST /webhooks/triggerInternal — Trigger.dev run-status callbacksx-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/stripe

Receives 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

HeaderRequiredDescription
stripe-signatureYesStripe webhook signature in the form t=<unix_ts>,v1=<hex>
Content-TypeYesapplication/json

Behaviour

  1. Reads the raw body and stripe-signature header.
  2. If STRIPE_WEBHOOK_SECRET is set, verifies the signature using HMAC-SHA256 over ${timestamp}.${rawBody}. On mismatch the route returns 401 Invalid signature and persists the event with signatureVerified: "failed".
  3. If the event has event.account, looks up the company that owns the matching Stripe-connected account via connections.externalAccountId. If no company is found the event is still recorded but no workflows are triggered.
  4. 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/:path

Receives 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 ParameterDescription
companyIdUUID of the receiving company
pathFree-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/:provider

Generic dispatcher for providers that have a ProviderHandler registered in the @workflows/modules module package. Today the dispatcher accepts:

:provider valueSource module
hubspotintegrations/hubspot
justpaidintegrations/justpaid
webhookcore/webhook (generic webhook block)

Each handler implements a four-method contract:

StepMethodResponsibility
1verify(rawBody, headers)Validate signature; return false to short-circuit with 401 Invalid signature
2parseEvents(rawBody)Split the request body into one or more provider events
3extractExternalAccountId(event)Pull the tenant identifier from each event (e.g. HubSpot portalId)
4normalize(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/nango

Receives OAuth lifecycle and provider events forwarded by Nango. This is how Salesforce, QuickBooks, NetSuite, PandaDoc, Slack, and Gmail events reach Workflows today.

HeaderDescription
x-nango-signatureHMAC-SHA256 signature over the raw body. Verified only when NANGO_WEBHOOK_SECRET is set.
type fieldBehaviour
authOAuth flow completed — flips a pending connections row to active or error, fetches externalAccountId, and registers any provider-specific subscriptions (e.g. HubSpot webhook subscriptions)
syncSync run finished — updates lastSyncedAt on the connection
webhookAn 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/trigger

Receives run-status callbacks from the Trigger.dev runtime that executes workflow runs.

HeaderDescription
x-trigger-signatureHMAC-SHA256 signature over the raw body. Verified only when TRIGGER_WEBHOOK_SECRET is set, using a timing-safe comparison.
type fieldResulting workflow_runs.status
run.completedcompleted
run.failedfailed
run.cancelledcancelled

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"
}
signatureVerifiedMeaning
pendingVerification not yet attempted
verifiedSignature matched the configured secret
failedSignature did not match (route returned 401)
skippedNo signature header sent, or no secret configured
statusMeaning
receivedEvent recorded, processing has not started
processingCurrently dispatching to workflow triggers
processedAll matched workflows started successfully
failedProcessing or signature verification failed
ignoredNo matching workflows

Error Responses

HTTPBodyTriggered 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
429Standard rate-limit envelopeMore 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

MethodPathPurpose
GET/api/v1/webhook-endpointsList endpoints
POST/api/v1/webhook-endpointsCreate endpoint
GET/api/v1/webhook-endpoints/:idGet endpoint
PUT/api/v1/webhook-endpoints/:idUpdate endpoint
DELETE/api/v1/webhook-endpoints/:idDelete endpoint
POST/api/v1/webhook-endpoints/:id/rotate-secretRotate signing secret
POST/api/v1/webhook-endpoints/:id/testSend a test delivery
GET/api/v1/webhook-endpoints/:id/deliveriesList delivery attempts
GET/api/v1/webhook-endpoints/:id/deliveries/:deliveryIdGet one delivery
POST/api/v1/webhook-endpoints/:id/deliveries/:deliveryId/retryRetry a failed delivery

Create Endpoint

POST /api/v1/webhook-endpoints
FieldTypeRequiredDescription
namestringYesHuman-readable name
urlstringYesHTTPS URL to deliver events to
eventsstring[]NoEvent types to receive (omit / null = all)
workflowIdsstring[]NoRestrict to specific workflows (omit / null = all)
headersobjectNoAdditional headers to include on each delivery
descriptionstringNoFree-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

EventDescription
workflow.startedA workflow run began
workflow.completedA workflow run completed successfully
workflow.failedA workflow run ended with an error
workflow.timeoutA workflow run exceeded its timeout
workflow.cancelledA workflow run was cancelled
workflow.checkpointA 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.

HeaderDescription
X-Webhook-Signaturet=<unix_ts>,v1=<hex>
X-Webhook-IDUnique event ID
X-Webhook-TimestampUnix timestamp
Content-Typeapplication/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:

AttemptDelay
110 seconds
230 seconds
31 minute
45 minutes
515 minutes
61 hour
76 hours
824 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=failed

Returns the recent delivery attempts for an endpoint with cursor-based pagination.

POST /api/v1/webhook-endpoints/:id/deliveries/:deliveryId/retry

Re-queues a single delivery without waiting for the backoff schedule.

Best Practices

  • Be idempotent. Providers may retry. Use eventId to 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, and TRIGGER_WEBHOOK_SECRET in 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 signatureVerified and status. A spike in failed or ignored is a strong signal that a provider rotated a secret or that a connection is misconfigured.

On this page