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.

This page covers the integration side of Vorel webhooks — subscribing, building a verified consumer, dealing with retries and dead-letters. The full event catalogue + retry ladder lives under API Reference → Webhooks; this page is the implementation guide.

End-to-end setup

1

Build your endpoint

A standard HTTPS endpoint that accepts POST + JSON. Write the handler before subscribing — the dashboard fires a test event on subscribe so you’ll want it ready to receive.
2

Subscribe in the dashboard

app.vorel.ai/(dashboard)/settings/integrations/webhooks. Provide:
  • Target URL (your endpoint, must be HTTPS).
  • Subscribed events — pick from the 12 canonical event types.
  • Description for the audit log.
On save, Vorel generates a per-subscription signing secret. Surfaced once — copy it into your secret store immediately.
3

Verify the signature on every request

HMAC-SHA256 of the raw request body, compared constant-time against the X-Webhook-Signature header. Reject 4xx on mismatch.
4

Idempotency-check on envelope.id

Vorel may redeliver the same event up to 6 times. Your consumer must dedupe on the envelope id field. Persist id → processed-state in your own store; check + ack quickly.
5

Process the event

Switch on envelope.event; handle the payload. Keep this fast — 5xx triggers a retry, slow consumers amplify the dispatcher’s per-attempt timeout (10 seconds).
6

Ack with 2xx

Any 2xx counts as delivered. 4xx counts as permanent_fail (no retry). 5xx / timeout / network error retries on the backoff ladder.

Canonical consumer (Node + Express)

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();

// IMPORTANT: capture raw body BEFORE parsing JSON. The signature is over raw bytes.
app.use(express.raw({ type: 'application/json' }));

const SECRET = process.env.VOREL_WEBHOOK_SECRET!;
const seen = new Set<string>(); // replace with persistent store in prod

app.post('/webhooks/vorel', async (req, res) => {
  const signature = req.header('X-Webhook-Signature');
  const rawBody = req.body as Buffer;

  if (!signature || !verifySignature(rawBody.toString('utf8'), signature, SECRET)) {
    res.status(401).send('invalid signature');
    return;
  }

  const envelope = JSON.parse(rawBody.toString('utf8'));

  if (seen.has(envelope.id)) {
    res.status(200).send('ok (duplicate)');
    return;
  }
  seen.add(envelope.id);

  try {
    await handleEvent(envelope);
    res.status(200).send('ok');
  } catch (err) {
    // 5xx triggers a retry. Use this when the failure is transient (DB
    // hiccup, downstream API down). Use a 4xx if the body is structurally
    // invalid for your processing — Vorel won't retry 4xx.
    res.status(500).send('processing failed');
  }
});

function verifySignature(rawBody: string, header: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  // Buffers must be equal length for timingSafeEqual; the hex digest is always 64 chars.
  if (header.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(header, 'hex'), Buffer.from(expected, 'hex'));
}

async function handleEvent(envelope: { event: string; data: unknown; tenant_id: string }) {
  switch (envelope.event) {
    case 'lead.qualified':
      await onLeadQualified(envelope.tenant_id, envelope.data);
      break;
    case 'booking.created':
      await onBookingCreated(envelope.tenant_id, envelope.data);
      break;
    // ...the other event types your subscription cares about
  }
}

Common failure modes

Cause is almost always one of:
  1. Body was JSON-parsed before signing. Re-serialising drops whitespace + reorders keys — the signature won’t match. Capture raw bytes (use express.raw() not express.json()).
  2. Wrong secret — make sure VOREL_WEBHOOK_SECRET matches the dashboard subscription’s secret. Subscriptions get unique secrets; re-check the right one.
  3. Hex case mismatch — Vorel emits lowercase hex. Most consumers handle both, but a case-sensitive === compare with an uppercase test string would fail.
Use timingSafeEqual against equal-length Buffers, and stick with lowercase hex throughout.
This is expected — the retry ladder may redeliver after a 5xx or timeout. Idempotency on envelope.id is non-negotiable. Re-deliveries can land minutes (or up to ~14h) after the first attempt, so an in-memory Set is not enough; persist ids in your consumer store.Common idempotency-store choices:
  • Redis with TTL (e.g. 30 days).
  • DB table with (id, processed_at), unique index on id.
  • Conditional write to a key-value store (Cloudflare KV, DynamoDB).
A delivery moves to webhook_deliveries.status='dead_letter' after the 6-attempt ladder exhausts. Causes:
  • Your endpoint returned 5xx for the whole window. Check your error logs around the timestamps in the delivery row.
  • Your endpoint was unreachable (DNS / TLS / firewall). Check ingress logs.
  • Your endpoint took >10s on every attempt. Check processing time.
Replay from the dashboard. Before replaying, fix the underlying cause — replaying without a fix just exhausts another 6 attempts.
The dispatcher times out at 10 seconds per attempt. If your consumer takes 30s to process, it’ll cascade: timeout → 5xx → retry. Pattern to fix:
  1. Ack first, process async. Receive the event, write to your queue, return 200 within a few hundred ms.
  2. Worker picks up the job and does the heavy lifting on its own time.
Vorel’s own consumers (the n8n templates) follow this pattern — every workflow’s first node is the webhook trigger that immediately succeeds.

Subscribing programmatically

Webhook subscriptions live in the dashboard today. There’s no public API for managing them yet — operator-side mutation only. If you need to provision subscriptions across many tenants during onboarding, your operator runs the dashboard flow per tenant; mass-provisioning APIs are roadmap.

Security posture

  • HTTPS-only. HTTP target URLs are rejected at subscription time.
  • HMAC-SHA256. Per-subscription secret; not a shared platform key.
  • No replay protection beyond the envelope id. The id doubles as your idempotency key — reject duplicates yourself.
  • Constant-time comparison. Vorel’s verification uses timingSafeEqual; your consumer should too.
  • No PII in the URL. Endpoint URLs are stored in webhooks.target_url plain (high-cardinality audit log on access). Don’t put bearer tokens in the URL — use a header on your end if you need additional auth.

What about webhook ingress (the other direction)?

Vorel receives webhooks from telephony / messaging / Clerk vendors at /api/webhooks/*. That surface is signature-verified per-vendor and rate-limited at the IP layer (500 req/min). That’s the ingress side; this page covers Vorel-as-emitter.