# Webhooks

Get leads, calls, bookings, outbound calls and live chats pushed to your HTTPS endpoint as they happen, signed so you can trust them.

Instead of polling the API, register an HTTPS endpoint and Vocenya sends it a `POST` the moment something happens: a lead is captured, a call ends, an appointment is booked, a chat gets a message. The full list is in [Webhook events](https://vocenya.com/docs/webhook-events).

## Add an endpoint

Add endpoints in the portal under [Developers](https://vocenya.com/app/developers), or with the API using a key with `webhooks:manage`:

```bash
curl -X POST https://vocenya.com/api/public/v1/webhook-endpoints \
  -H "Authorization: Bearer $VOCENYA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks/vocenya", "events": ["lead.created", "call.completed"]}'
```

The response includes the endpoint's signing `secret` (it starts with `whsec_`). Store it now: it is not shown again. In the portal you can rotate it.

- The URL must be a public `https://` address. Private, local and internal addresses are refused (`unsafe_url`).
- An account can have up to 10 endpoints.
- Subscribing to an event also needs that record's read scope, for example `leads:read` for `lead.created`.
- Endpoints belong to one mode: those created with a test key, or on the Test tab of the Developers page, only receive events from test data.

## What we send

Each delivery is a `POST` with a JSON body:

```json
{
  "id": "9f0c6a1e-5b7d-4c1a-9e8f-2d3b4a5c6d7e",
  "type": "lead.created",
  "livemode": true,
  "created_at": "2026-10-02T14:05:40+00:00",
  "data": { "lead": { "id": 311, "name": "Jordan Smith", "phone_number": "+15555550123" } }
}
```

`livemode` is `false` for events from [test data](https://vocenya.com/docs/test-mode), which only go to test endpoints. And these headers:

| Header | Value |
| --- | --- |
| `Vocenya-Event` | The event type, like `lead.created`. |
| `Vocenya-Delivery` | The event id, the same as the body's `id`. Use it to ignore duplicates. |
| `Vocenya-Signature` | `t=<unix time>,v1=<hex signature>`. See below. |
| `User-Agent` | `Vocenya-Webhooks/1.0` |

Payloads carry ids, statuses, times and contact details, never conversation content (summaries, transcripts, lead reasons), with one exception: `chat.message.created` carries the message so your system can answer it. HIPAA-mode accounts receive ids only. Fetch the full record from the API when you need more.

## Verify the signature

Always check `Vocenya-Signature` before trusting a delivery. The signature is an HMAC-SHA256, keyed with your endpoint secret, of the timestamp, a dot and the **raw** request body:

```text
v1 = hex(HMAC_SHA256(secret, "<t>.<raw body>"))
```

To verify:

1. Split the header on `,` and read `t` and `v1`.
2. Reject the delivery if `t` is more than 300 seconds (5 minutes) from your clock. This stops replays.
3. Compute the HMAC of `t + "." + raw body` with your secret and compare it to `v1` in constant time.

Use the raw body exactly as received. Parsing the JSON and serialising it again changes the bytes and breaks the signature.

```javascript
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.VOCENYA_WEBHOOK_SECRET;

function verifySignature(rawBody, header) {
  const parts = Object.fromEntries(
    header.split(',').map((pair) => pair.trim().split('=')),
  );

  if (!/^\d+$/.test(parts.t ?? '') || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

  return expected.length === parts.v1.length
    && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

app.post('/webhooks/vocenya', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');

  if (!verifySignature(rawBody, req.get('Vocenya-Signature') ?? '')) {
    return res.status(400).send('Invalid signature');
  }

  const event = JSON.parse(rawBody);
  // Handle event.type here; skip event.id values you have already processed.
  res.sendStatus(200);
});
```

```php
<?php

$secret = getenv('VOCENYA_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_VOCENYA_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $pair) {
    [$key, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
    $parts[$key] = $value;
}

$valid = isset($parts['t'], $parts['v1'])
    && ctype_digit($parts['t'])
    && abs(time() - (int) $parts['t']) <= 300
    && hash_equals(hash_hmac('sha256', $parts['t'].'.'.$rawBody, $secret), $parts['v1']);

if (! $valid) {
    http_response_code(400);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true);
// Handle $event['type'] here; skip $event['id'] values you have already processed.
http_response_code(200);
```

```python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["VOCENYA_WEBHOOK_SECRET"]


def verify_signature(raw_body: bytes, header: str) -> bool:
    parts = dict(pair.strip().split("=", 1) for pair in header.split(",") if "=" in pair)
    timestamp, signature = parts.get("t", ""), parts.get("v1", "")

    if not timestamp.isdigit() or not signature:
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(SECRET.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/webhooks/vocenya")
def vocenya_webhook():
    if not verify_signature(request.get_data(), request.headers.get("Vocenya-Signature", "")):
        abort(400)

    event = request.get_json()
    # Handle event["type"] here; skip event["id"] values you have already processed.
    return "", 200
```

```ruby
require "json"
require "openssl"
require "sinatra"

SECRET = ENV.fetch("VOCENYA_WEBHOOK_SECRET")

def valid_signature?(raw_body, header)
  parts = header.split(",").map { |pair| pair.strip.split("=", 2) }.to_h
  timestamp, signature = parts["t"].to_s, parts["v1"].to_s

  return false unless timestamp.match?(/\A\d+\z/) && !signature.empty?
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{timestamp}.#{raw_body}")
  Rack::Utils.secure_compare(expected, signature)
end

post "/webhooks/vocenya" do
  raw_body = request.body.read
  halt 400, "Invalid signature" unless valid_signature?(raw_body, request.env["HTTP_VOCENYA_SIGNATURE"].to_s)

  event = JSON.parse(raw_body)
  # Handle event["type"] here; skip event["id"] values you have already processed.
  status 200
end
```

## Respond quickly

Answer with any `2xx` status within **10 seconds**. Do the real work afterwards (in a queue or background job), so slow processing never causes a timeout. Redirects are not followed: point the endpoint at its final URL.

## Retries

Anything other than a `2xx` within 10 seconds (an error status, a timeout or a connection error) is retried. A delivery is attempted up to **8 times**, waiting 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h between attempts, then marked failed. Every attempt sends the same body and event id with a fresh signature timestamp.

Because a delivery can arrive more than once, make your handler idempotent: record each `id` you process and skip repeats. Deliveries can also arrive out of order; use `created_at` and the record's own fields, not arrival order.

## Test your endpoint

Send a signed test event from the portal, or with the API:

```bash
curl -X POST https://vocenya.com/api/public/v1/webhook-endpoints/3/test \
  -H "Authorization: Bearer $VOCENYA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event": "chat.message.created"}'
```

Without `event` you get a `webhook.test` event with `{"message": "..."}` as its data. With `event`, you get a sample of that real event with made-up data and `"sample": true`. Test deliveries are signed and retried like any other.
