Documentation source
Webhooks
Outbound HTTP webhook endpoints for entity lifecycle events, with HMAC-SHA256 signature verification, Inngest-driven delivery, and automatic failure tracking.
# Webhooks
Webhooks deliver HTTP POST notifications to external systems when events occur in the platform. Administrators configure endpoint URLs, select which events to subscribe to, and provide a secret for signature verification. Delivery is handled asynchronously via Inngest with retry on failure.
## Overview
The module lives in `features/webhooks/` and provides the data layer (types, server actions, signature utilities) and admin UI. Webhook delivery is handled by the `features/inngest/functions/webhook-delivery.ts` Inngest function, which sends the HTTP request, verifies success, and increments the failure counter on error.
Webhooks are also used as action side effects: field-population lifecycle events (`on_populate`, `on_approve`, `on_reject`) can trigger webhook deliveries when configured for an action.
## Key Concepts
**WebhookEndpoint** -- The database record for a configured endpoint:
- `url` -- the destination URL that receives POST requests
- `secret` -- shared secret for HMAC-SHA256 signature generation
- `events` -- array of event types this endpoint subscribes to
- `enabled` -- toggle to pause delivery without deleting the endpoint
- `failure_count` -- incremented on delivery failure, visible in the admin UI
- `description` -- optional human-readable label
- `last_triggered_at` -- timestamp of last successful delivery
**WebhookEndpointView** -- The `WebhookEndpoint` type with the `secret` field omitted. Used in API responses and admin UI to avoid exposing secrets.
**Available Events** -- The platform defines four webhook event types:
- `entity/created` -- a new entity record was created
- `entity/updated` -- an entity was modified
- `entity/deleted` -- an entity was removed
- `document/uploaded` -- a document was uploaded
**Signature Verification** -- Every webhook payload is signed with HMAC-SHA256 using the endpoint's secret. The signature is included as a header so receivers can verify authenticity.
## How It Works
### Configuration
Administrators manage webhooks via the Admin panel. The `WebhookAdmin` component provides:
1. A list of configured endpoints showing URL, subscribed events, enabled status, and failure count
2. A dialog for creating new endpoints (URL, secret, description, event selection)
3. Toggle switches to enable/disable endpoints
4. Delete buttons for removal
### Delivery Pipeline
1. An event occurs in the platform (entity created, document uploaded, etc.)
2. The Inngest function `webhook-delivery` receives the event
3. It queries `webhook_endpoints` for enabled endpoints subscribed to this event type, scoped per the `webhook/fire` event's optional `workspaceId` (ADR-0013 Option C). When `workspaceId` is absent, delivery is **tenant-only** -- it matches endpoints with `workspace_id IS NULL`. When the emitter supplies `workspaceId`, delivery fans out to tenant-scoped endpoints **and** endpoints scoped to that workspace (`workspace_id IS NULL OR workspace_id = <id>`). Emitters that hold workspace context should populate `workspaceId`; the status-transition emitter leaves it unset because entities are tenant-wide (ADR-0008).
4. For each matching endpoint:
- Serialize the event payload as JSON
- Generate HMAC-SHA256 signature using `signPayload(payload, secret)`
- Send HTTP POST to the endpoint URL with the payload and signature header
- On success: reset `failure_count` to 0 and update `last_triggered_at` (filtered by both `id` and `tenant_id`)
- On failure: atomically increment `failure_count` DB-side via the service-role `increment_webhook_failure_count(p_endpoint_id, p_tenant_id)` RPC (avoids the stale read-modify-write that under-counts under concurrent deliveries and Inngest retries)
### Signature Verification
The `features/webhooks/lib/signature.ts` module provides:
- `signPayload(payload, secret)` -- generates HMAC-SHA256 hex digest
- `verifySignature(payload, signature, secret)` -- constant-time comparison to prevent timing attacks
Receivers should verify the signature header against the payload using their copy of the shared secret.
## API Reference
### Server Actions
| Function | Location | Purpose |
|---|---|---|
| `listWebhooks()` | `features/webhooks/server/actions.ts` | List all endpoints (secrets excluded) |
| `getWebhook(id)` | Same | Fetch single endpoint |
| `createWebhook(input)` | Same | Create endpoint with URL, secret, events |
| `updateWebhook(id, input)` | Same | Update endpoint fields |
| `deleteWebhook(id)` | Same | Delete endpoint |
### Signature Functions
| Function | Location | Purpose |
|---|---|---|
| `signPayload(payload, secret)` | `features/webhooks/lib/signature.ts` | Generate HMAC-SHA256 signature |
| `verifySignature(payload, signature, secret)` | Same | Constant-time signature verification |
### Types
| Type | Location | Purpose |
|---|---|---|
| `WebhookEndpoint` | `features/webhooks/types.ts` | Full endpoint record |
| `WebhookEndpointView` | Same | Endpoint without secret |
| `CreateWebhookInput` | Same | Create input shape |
| `UpdateWebhookInput` | Same | Partial update input |
### Components
| Component | Location | Purpose |
|---|---|---|
| `WebhookAdmin` | `features/webhooks/components/webhook-admin.tsx` | Full admin CRUD UI |
## For Agents
Agents do not directly manage webhooks. Webhook configuration is an admin-only operation. However, agent actions (creating entities, updating records, running heartbeats) generate the events that trigger webhook deliveries.
Agents can also indirectly trigger webhooks through extraction field actions: when a field has an `on_populate` or `on_approve` action of type `webhook`, the webhook delivery pipeline fires after extraction completes.
## Inbound Webhooks
The platform also accepts inbound webhooks from external systems and converts them into entity records. This is the push side of external integrations: a third-party service (Acuity Scheduling, Stripe, any SaaS with a webhook feature) posts to a per-tenant endpoint URL; the platform verifies the signature, maps the payload to entity fields, and creates or upserts the entity.
### Configuration
Each inbound endpoint is a row in `inbound_webhook_endpoints` and carries:
- `token` — URL-safe random token that uniquely identifies the endpoint (`/api/webhooks/inbound/{token}`)
- `entity_type_slug` — which entity type to create/upsert
- `field_mapping` — JSON mapping from source payload keys to entity `content` field names
- `signature_secret` — shared secret used to verify the HMAC signature from the sender
- `signature_header` — the header name the sender puts the signature in (e.g. `x-acuity-signature`, `stripe-signature`)
- `signature_encoding` — encoding of the digest in the signature header. `hex` (default, 64 chars — Stripe, Slack, GitHub, most SaaS) or `base64` (44 chars — Acuity Scheduling, Squarespace Scheduling). Mismatch causes verification to short-circuit on length, so set this correctly per provider.
Inbound endpoint rows are created via seed scripts or the Admin > Webhooks UI (inbound tab).
### Delivery Route
```
POST /api/webhooks/inbound/{token}
```
Accepted content types:
- `application/json` — default; body parsed directly as JSON
- `application/x-www-form-urlencoded` — form-encoded bodies (Acuity Scheduling, SendGrid, Mailgun, and other legacy SaaS providers); decoded to a flat `Record<string, string>` before field mapping
Multipart/form-data is intentionally not supported. Route through a JSON-converting intermediary if needed.
HMAC verification runs over the raw wire bytes before content-type decoding, so signature integrity is preserved for all content types.
### Acuity Scheduling Example
Acuity sends a skinny webhook on appointment events (`{action, id, calendarID, appointmentTypeID}`) as `application/x-www-form-urlencoded`. The DOC'S integration uses this flow:
1. Acuity posts to `/api/webhooks/inbound/{token}` with `x-acuity-signature` header (base64-encoded HMAC-SHA256)
2. Platform verifies HMAC using the Acuity API key as the shared secret, with `signature_encoding: 'base64'` set on the endpoint row
3. `field_mapping` creates a stub visit with `acuity_id` and `sync_status: 'webhook'`
4. `entity_created` event fires
5. Linker action (`docs-acuity-link-visit-to-patient`) runs: fetches the full appointment from the Acuity REST API via the tenant's `agent_connections` row, expands the stub, and resolves the visit to a patient record
This skinny-webhook + REST-expansion pattern keeps `processInboundWebhook` as a lean router and puts the round-trip fetch inside a retryable action with session-event logging.
### Security
`signature_secret` is never returned to browser-facing callers. `listInboundWebhooks`, `createInboundWebhook`, and `updateInboundWebhook` all return `PublicInboundWebhookEndpoint` — a redacted view that omits the field and exposes `has_signature_secret: boolean` instead (so the admin UI can show "secret set" without revealing the value). The secret is read server-side only inside `processInboundWebhook`, where the HMAC is computed and compared using `constantTimeEqual` from `lib/security/constant-time-equal.ts` to prevent timing-oracle attacks.
### Design Decisions
- **Raw-bytes HMAC** — signature verification runs before content-type parsing so the HMAC covers the unmodified wire representation. This matches what Acuity and most SaaS providers expect.
- **Stub + linker pattern** — inbound webhook creates the minimum entity; a triggered action does the heavy enrichment. Separation of concerns; enrichment is retryable and observable.
- **No multipart support** — file uploads belong in a dedicated pipeline. If a provider sends files via webhook, pipe through an intermediary.
## Related Modules
- **Inngest** (`features/inngest/`) -- `webhook-delivery` function handles async delivery
- **Extraction** (`features/entities/extraction/`) -- field actions can trigger webhooks
- **API Keys** (`features/api-keys/`) -- external systems receiving webhooks may use API keys to call back into the platform
- **Admin** -- webhook management lives in the Admin panel
- **Connections** (`features/agents/connection-presets.ts`) -- REST connection presets used by actions that expand inbound webhook stubs