Skip to main content

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.

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

EventFires when
lead.createdA lead row is created (qualification sub-agent or POST /v1/leads).
lead.updatedSlot changes via the agent’s update_lead tool or PATCH /v1/leads/:id.
lead.qualifiedLead status moves off 'new' (to qualified/booked/converted/etc.).
conversation.createdFirst inbound from a new (tenant, customer) pair, or explicit POST /v1/conversations.
conversation.handoff_requestedHandoff sub-agent fires or the request_handoff tool runs.
conversation.closedAn operator action closes the conversation.
booking.createdAn appointment is booked (agent’s book_appointment tool or POST /v1/appointments).
booking.rescheduledscheduled_start or scheduled_end changes via PATCH.
booking.cancelledstatus transitions to 'cancelled'.
booking.completedstatus transitions to 'completed'.
offering.createdA catalog offering is created via POST /v1/offerings.
offering.updatedAn 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": "01HZX0Y9MTWDK7TQM4K6JYVN1B",
  "event": "lead.qualified",
  "created_at": "2026-05-06T12:34:56.789Z",
  "tenant_id": "8b1f3a2c-1234-4abc-9def-987654321012",
  "data": {
    /* event-specific payload */
  }
}
  • id — a unique event id. 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 request carries a header:
X-Webhook-Signature: <HMAC-SHA256 hex digest of the raw request body>
Verify it 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

AttemptBehaviour
1Immediate POST.
2Retry after 60 seconds (5xx, timeout, or network error on attempt 1).
3Retry after 5 minutes.
4Retry after 30 minutes.
5Retry after 2 hours.
6Retry after 12 hours.
Past 6Dead-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 responsedelivered. Terminal. delivered_at recorded.
  • 4xx responsepermanent_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 (Sentry 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.
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.