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. |
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.
Envelope shape
Every webhook body is a JSON object of this shape:
{
"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:
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:
- The row’s
status flips to dead_letter.
- The
response_body carries the last error text (truncated).
- 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
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 wires booking.created to a
24-hour-before-reminder via Vorel’s outbound conversation send. See 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.