> ## 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

> Subscribe + verify + handle Vorel's outbound webhooks. End-to-end setup walkthrough; canonical consumer code; failure-mode handling.

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](/api-reference/webhooks); this page is the implementation guide.

## End-to-end setup

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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).
  </Step>

  <Step title="Ack with 2xx">
    Any 2xx counts as delivered. 4xx counts as `permanent_fail` (no retry). 5xx / timeout /
    network error retries on the backoff ladder.
  </Step>
</Steps>

## Canonical consumer (Node + Express)

```typescript theme={null}
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

<AccordionGroup>
  <Accordion icon="signature" title="Signature verification failing on every request">
    Cause is almost always one of:

    1. **Body was JSON-parsed before signing.** Re-serialising drops whitespace + reorders keys, so
       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.
  </Accordion>

  <Accordion icon="rotate" title="Same event firing multiple times">
    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 `id`s 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).
  </Accordion>

  <Accordion icon="circle-stop" title="Event in dead-letter">
    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.
  </Accordion>

  <Accordion icon="triangle-exclamation" title="Slow processing causing 5xx → retries → load">
    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.
  </Accordion>
</AccordionGroup>

## 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 / auth providers at `/api/webhooks/*`.
That surface is signature-verified per-source and rate-limited at the IP layer (500 req/min).
That's the **ingress** side; this page covers Vorel-as-emitter.

## Related docs

* [API Reference → Webhooks](/api-reference/webhooks): full event catalogue, envelope shape,
  retry ladder
* [Automation](/product/automation): the n8n templates that consume webhooks
* [SDKs](/api-reference/sdks): for the API calls your consumer might fan out to

{/* verified-against: apps/workers/src/queues/webhook-dispatcher.ts WEBHOOK_TIMEOUT_MS=10_000 */}
