> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vorel.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Outbound webhook contract: 12 event types, HMAC-SHA256 signing, 6-attempt retry ladder, dead-letter on exhaustion. Subscribe in the dashboard; verify the signature; idempotency-key in the envelope.

Vorel posts JSON webhooks to URLs you register, on tenant-scoped lifecycle events
(lead created, conversation handoff requested, booking rescheduled, etc.). Bodies are
HMAC-SHA256 signed; consumers dedupe on the envelope id; we run a 6-attempt backoff ladder
before dead-lettering.

## Subscribe to events

Register webhook endpoints at `/(dashboard)/settings/integrations/webhooks`. Each subscription
carries:

* **`target_url`**: your HTTPS endpoint. Vorel POSTs the envelope here.
* **`secret`**: auto-generated; used as the HMAC key. Surfaced once at create time, after that
  only the prefix is visible (rotate by recreating).
* **Subscribed event list**: pick the subset you want; ignore the rest.

You can register multiple URLs per tenant (one for ops Slack, one for warehouse ETL, etc.) with
overlapping or distinct event subscriptions.

## The 12 event types

| Event                            | Fires when                                                                                  |
| -------------------------------- | ------------------------------------------------------------------------------------------- |
| `lead.created`                   | A lead row is created (qualification sub-agent or `POST /v1/leads`).                        |
| `lead.updated`                   | Slot changes via the agent's `update_lead` tool or `PATCH /v1/leads/:id`.                   |
| `lead.qualified`                 | Lead status moves off `'new'` (to `qualified`/`booked`/`converted`/etc.).                   |
| `conversation.created`           | First inbound from a new (tenant, customer) pair, **or** explicit `POST /v1/conversations`. |
| `conversation.handoff_requested` | Handoff sub-agent fires or the `request_handoff` tool runs.                                 |
| `conversation.closed`            | An operator action closes the conversation.                                                 |
| `booking.created`                | An appointment is booked (agent's `book_appointment` tool or `POST /v1/appointments`).      |
| `booking.rescheduled`            | `scheduled_start` or `scheduled_end` changes via PATCH.                                     |
| `booking.cancelled`              | `status` transitions to `'cancelled'`.                                                      |
| `booking.completed`              | `status` transitions to `'completed'`.                                                      |
| `offering.created`               | A catalog offering is created via `POST /v1/offerings`.                                     |
| `offering.updated`               | An offering is updated via `PATCH /v1/offerings/:id`.                                       |

<Note>
  `conversation.created` fires today only on the **public-API write path** (`POST
      /v1/conversations`). Internal ingest paths (inbound WhatsApp, inbound voice, webform handler)
  don't emit it yet. Adding emission there has wider blast radius (every existing customer message
  would suddenly produce a webhook). When that lands, the emission point is the conversation-row
  insert in the inbound message handlers; the enum already covers it.
</Note>

## Envelope shape

Every webhook body is a JSON object of this shape:

```json theme={null}
{
  "id": "evt_3f1c9a2e-6b40-4d8a-9c2f-1a2b3c4d5e6f",
  "event": "lead.qualified",
  "created_at": "2026-05-06T12:34:56.789Z",
  "tenant_id": "8b1f3a2c-1234-4abc-9def-987654321012",
  "data": {
    /* event-specific payload */
  }
}
```

* **`id`** is a unique event id, formatted `evt_<uuid>`. **Use it as your idempotency key.** Vorel's
  retry ladder may redeliver the same event up to 6 times; a 5xx that succeeded server-side but
  failed at the network layer will replay. Idempotent processing on `id` is non-negotiable.
* **`event`**: one of the 12 names above.
* **`created_at`**: ISO 8601 UTC.
* **`tenant_id`**: the tenant whose data triggered the event. Useful when one URL receives
  events for multiple tenants (or for sanity-checking).
* **`data`**: event-specific payload. Shapes are documented per-event (TODO: per-event payload
  schemas live in the OpenAPI spec at the corresponding resource).

## Signature verification

Every delivery carries these headers (sent lowercase; HTTP header names are case-insensitive, so
match accordingly):

```
x-webhook-signature: <HMAC-SHA256 hex digest of the raw request body>
x-event-type:        <event name, e.g. lead.qualified>
x-delivery-id:       <UUID of this delivery attempt>
```

The `x-event-type` and `x-delivery-id` headers are conveniences; the authoritative event name and
event id live in the envelope body (`event` + `id`). Verify the signature before trusting the
payload:

```javascript theme={null}
import { createHmac } from 'node:crypto';

function verifySignature(rawBody: string, header: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  // timingSafeEqual is preferred: using === here for clarity. In production, use a
  // constant-time compare to avoid timing-channel attacks.
  return expected === header;
}
```

**Verify before parsing.** A failed verification means **don't trust the body**: drop the
request, log the failure, return a 4xx. We retry on 5xx but not 4xx, so a verification failure
correctly results in no retry storm.

The signature is computed over the **raw request body bytes**: JSON-decode-and-reserialize will
break the verification (key ordering, whitespace, etc.). Capture the raw bytes before passing
to JSON parser.

## Retry ladder

| Attempt | Behaviour                                                                                                     |
| ------- | ------------------------------------------------------------------------------------------------------------- |
| 1       | Immediate POST.                                                                                               |
| 2       | Retry after **60 seconds** (5xx, timeout, or network error on attempt 1).                                     |
| 3       | Retry after **5 minutes**.                                                                                    |
| 4       | Retry after **30 minutes**.                                                                                   |
| 5       | Retry after **2 hours**.                                                                                      |
| 6       | Retry after **12 hours**.                                                                                     |
| Past 6  | **Dead-letter**. The delivery moves to `webhook_deliveries.status='dead_letter'`; a tenant-admin alert fires. |

Total retry window: \~14h 35min from first attempt to dead-letter.

The classifier rules:

* **2xx response** → `delivered`. Terminal. `delivered_at` recorded.
* **4xx response** → `permanent_fail`. Terminal. **No retry**: a 4xx means we built a request
  the consumer rejected, and replaying it won't change that.
* **5xx response, timeout, or network error** → retry per the ladder if budget remains; else
  `dead_letter`.

The 10-second per-attempt timeout is set at the dispatcher level so a hanging consumer can't
amplify the attempt cost.

## Dead-letter handling

When a delivery exhausts the 6-attempt ladder:

1. The row's `status` flips to `dead_letter`.
2. The `response_body` carries the last error text (truncated).
3. A tenant-admin alert fires (error-reporting capture + dashboard surface).

Replaying a dead-lettered delivery is operator-driven today: the dashboard surfaces a "replay"
button that re-enqueues the delivery for another 6-attempt cycle. We don't auto-replay
dead-letters because the cause is almost always a structurally-broken consumer, not a transient
fault.

## Recommended consumer behaviour

```javascript theme={null}
async function handleVorelWebhook(req: Request): Promise<Response> {
  // 1. Read raw body (NOT pre-parsed JSON) so signature verify works.
  const raw = await req.text();
  const signature = req.headers.get('X-Webhook-Signature');
  if (!signature || !verifySignature(raw, signature, process.env.VOREL_WEBHOOK_SECRET!)) {
    return new Response('invalid signature', { status: 401 });
  }

  const envelope = JSON.parse(raw);

  // 2. Idempotency check on envelope.id: return 200 quickly on a repeat.
  if (await alreadyProcessed(envelope.id)) {
    return new Response('ok (duplicate)', { status: 200 });
  }

  // 3. Process the event. Keep this fast.
  switch (envelope.event) {
    case 'lead.qualified':
      await onLeadQualified(envelope.data);
      break;
    // ...
  }

  // 4. Mark processed and ack with 2xx.
  await markProcessed(envelope.id);
  return new Response('ok', { status: 200 });
}
```

Three things to internalise:

* **Idempotency on `id` is a contract**, not a suggestion. We will redeliver under transient
  failure.
* **Verify before parsing.** Tampered bodies must return 4xx + we won't retry them.
* **Ack fast.** 5xx triggers a retry; 4xx triggers `permanent_fail`. If your processing is slow,
  enqueue and ack; don't make Vorel wait.

## Around-the-brain workflows

The [post-booking-confirmation n8n template](/integrations/n8n) wires `booking.created` to a
24-hour-before-reminder via Vorel's outbound conversation send. See [Automation](/product/automation)
for the v1 template set.

## What's NOT supported today

* **Replay individual deliveries via API.** Operator-only via the dashboard.
* **Multi-event filters per subscription** beyond the event-name set. No "deliver lead.created
  only when `attributes.budget_max > 5_000_000`" today; filter consumer-side.
* **`offering.deleted`**: there's no DELETE-offering API path; soft-delete happens via the
  operator dashboard. The event will be added in lockstep when the tenant-side delete surface
  lands.
* **Per-event payload schemas in this docs site.** Documented in the OpenAPI spec per-resource
  for now; a dedicated payload page is roadmap.

## Related docs

* [API introduction](/api-reference/introduction): surface overview
* [Rate limits](/api-reference/rate-limits): outbound webhook delivery is itself rate-limited
* [Automation](/product/automation): v1 templates that consume webhooks
* [How it works](/getting-started/how-it-works): where webhook emission fits

{/* verified-against: apps/workers/src/queues/webhook-dispatcher.ts computeSignature HMAC-SHA256 hex; classifyOutcome (2xx delivered / 4xx permanent_fail / 5xx retry-or-dead-letter) */}

{/* verified-against: packages/shared/src/events.ts envelope {id, event, created_at, tenant_id, data}; webhook-events.ts makeEventId() = `evt_${randomUUID()}` */}

{/* verified-against: packages/shared/src/events.ts comment about conversation.created emission only on public-API write path today */}
