> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.jelliu.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.jelliu.co/_mcp/server.

# Webhooks

Outbound webhooks send an HTTP `POST` to your endpoint when something happens in your workspace. Each delivery is signed with a secret unique to the webhook, so you can prove it came from Jelliu.

## Managing webhooks

Create and manage webhooks in the dashboard under **Settings → Webhooks** (`https://app.jelliu.co/settings?tab=webhooks`), or through the API:

| Method   | Path                                      | Required                                 |
| -------- | ----------------------------------------- | ---------------------------------------- |
| `GET`    | `/api/webhooks`                           | `read` key                               |
| `GET`    | `/api/webhooks/{webhookId}`               | `read` key                               |
| `POST`   | `/api/webhooks`                           | `full` key                               |
| `PATCH`  | `/api/webhooks/{webhookId}`               | `full` key                               |
| `DELETE` | `/api/webhooks/{webhookId}`               | `full` key                               |
| `POST`   | `/api/webhooks/{webhookId}/rotate-secret` | Dashboard session (API keys are refused) |
| `GET`    | `/api/webhooks/{webhookId}/delivery-logs` | `read` key                               |

### Create a webhook

```bash
curl -sS -X POST "https://api.jelliu.co/api/webhooks" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/jelliu",
    "events": ["call.completed", "call.failed", "campaign.completed"],
    "description": "CRM sync"
  }'
```

The response (`201`) includes the signing secret in `data.secret`, a value starting with `whsec_`. **This is the only time the secret is returned**; afterwards the API shows `[configured]`. Store it with your other credentials.

**`url`** `string` — required

Where deliveries are sent. Up to 2000 characters. It must resolve to a public address: private, internal and loopback targets are rejected. Use HTTPS.

---

**`events`** `string[]` — required

At least one event name from the [catalog](#event-catalog), or `"*"` for every event except `audit.log_recorded`.

---

**`description`** `string`

Up to 500 characters.

---

**`filters`** `object`

Only deliver events that match. See [Filters](#filters).

---

**`headers`** `object`

Extra HTTP headers to send with every delivery, as name/value strings. Use valid HTTP header names and no line breaks in values: otherwise deliveries are rejected and logged as failures. Custom headers cannot replace `X-Webhook-*`, `Host` or `Content-Length`.

---

**`payload_template`** `string`

Custom JSON body. See [Payload templates](#payload-templates). Up to 10000 characters.

---

`PATCH` accepts the same fields, all optional, plus `is_active` to disable or re-enable the webhook.

## Event catalog

A subscription can name any event below. The **Sent** column shows which events Jelliu currently emits; the others are accepted in subscriptions but are not delivered yet.

| Event                        | Sent    | Description                                                                                                          |
| ---------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- |
| `call.completed`             | Yes     | A call ended and its analysis is available.                                                                          |
| `call.failed`                | Yes     | A call ended in a technical failure, or could not connect.                                                           |
| `campaign.completed`         | Yes     | A campaign finished working through its contacts.                                                                    |
| `agent.action_recorded`      | Yes     | An agent invoked a tool. One event per tool call, so volume can be high.                                             |
| `audit.log_recorded`         | Yes     | A person or integration performed an audited action in the workspace. **Not included in `"*"`**: name it explicitly. |
| `crm_sync.completed`         | Yes     | An interaction was written to a connected CRM.                                                                       |
| `crm_sync.failed`            | Yes     | Writing an interaction to a connected CRM failed.                                                                    |
| `call.started`               | Not yet | A call started.                                                                                                      |
| `call.recording_ready`       | Not yet | A call recording is available.                                                                                       |
| `call.transcript_ready`      | Not yet | A call transcript is available.                                                                                      |
| `agent.created`              | Not yet | An agent was created.                                                                                                |
| `agent.updated`              | Not yet | An agent was updated.                                                                                                |
| `campaign.started`           | Not yet | A campaign was activated.                                                                                            |
| `campaign.paused`            | Not yet | A campaign was paused.                                                                                               |
| `contact.created`            | Not yet | A contact was created.                                                                                               |
| `contact.updated`            | Not yet | A contact was updated.                                                                                               |
| `contact.status_changed`     | Not yet | A contact's status changed.                                                                                          |
| `contact.converted`          | Not yet | A contact converted.                                                                                                 |
| `contact.dnc`                | Not yet | A contact was added to the do-not-call list.                                                                         |
| `usage.threshold_reached`    | Not yet | Usage crossed a plan threshold.                                                                                      |
| `subscription.changed`       | Not yet | The workspace subscription changed.                                                                                  |
| `integration.sync_completed` | Not yet | An integration sync completed.                                                                                       |
| `integration.sync_failed`    | Not yet | An integration sync failed.                                                                                          |
| `integration.error`          | Not yet | An integration reported an error.                                                                                    |

## Payload

Unless you set a payload template, the body is:

```json
{
  "event": "call.completed",
  "timestamp": "2026-09-14T15:42:07.318Z",
  "data": {
    "callId": "5b0d2f7e-9a41-4c3e-8f0a-2c6d1e7b9a10",
    "campaignId": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
    "contactId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "agentId": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
    "phoneNumber": "+573001234567",
    "outcome": "callback_scheduled",
    "sentimentScore": 0.6,
    "duration": 184,
    "summary": "...",
    "dataCollection": { },
    "kpiData": { }
  }
}
```

`data` depends on the event:

| Event                                   | `data` fields                                                                                                                                                                                                                                                                                                              |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `call.completed`, `call.failed`         | `callId`, `campaignId`, `contactId`, `agentId`, `phoneNumber`, `outcome`, `sentimentScore`, `duration`, `summary`, `dataCollection`, `kpiData`. A `call.failed` detected by background reconciliation carries `callId`, `agentId`, `campaignId`, `contactId`, `phoneNumber`, `outcome`, `duration` and `reconciled: true`. |
| `campaign.completed`                    | `campaignId`, `campaignName`                                                                                                                                                                                                                                                                                               |
| `crm_sync.completed`, `crm_sync.failed` | `channel`, `source`, `callId`, `conversationId`, `agentId`, `campaignId`, `contactId`, `results` (each with `provider`, `success`, `externalId`, `error`)                                                                                                                                                                  |
| `agent.action_recorded`                 | `action_id`, `agent_id`, `agent_name`, `conversation_id`, `call_id`, `tool_name`, `tool_source`, `action_kind`, `params` (already redacted), `outcome`, `error_message`, `result_summary`, `duration_ms`, and more                                                                                                         |
| `audit.log_recorded`                    | `audit_id`, `user_id`, `action`, `resource_type`, `resource_id`, `changes` (already redacted), `ip_address`, `user_agent`, `occurred_at`, `redacted_at`, `redaction_reason`                                                                                                                                                |

Treat every field as optional and ignore fields you do not recognize.

## Delivery headers

| Header                                          | Value                                                                                                                           |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`                                  | `application/json`                                                                                                              |
| `X-Webhook-Event`                               | The event name, for example `call.completed`.                                                                                   |
| `X-Webhook-Timestamp`                           | ISO 8601 UTC timestamp of the event, for example `2026-09-14T15:42:07.318Z`. The same value as `timestamp` in the default body. |
| `X-Webhook-Signature-V2`                        | Lowercase hex HMAC-SHA256 of `timestamp + "." + raw body`, keyed with your webhook secret.                                      |
| `X-Webhook-Attempt`                             | Delivery attempt number, starting at `1`.                                                                                       |
| `X-Webhook-Signature`, `X-Webhook-Signature-V1` | **Legacy.** HMAC-SHA256 of the body alone. Still sent for backward compatibility and will be removed.                           |

Verify `X-Webhook-Signature-V2`. The legacy signatures do not cover the timestamp, so a captured delivery could be replayed against you indefinitely.

`X-Webhook-Timestamp` is an ISO 8601 string, not a Unix number. It is set once when the event is emitted and stays the same on every retry.

## Verifying signatures

1. Read the **raw** request body, before any JSON parsing.
2. Build the signed string: the `X-Webhook-Timestamp` value, a period, then the raw body.
3. Compute HMAC-SHA256 of that string with your full secret (including the `whsec_` prefix) as the key, and hex-encode it.
4. Compare it with `X-Webhook-Signature-V2` using a constant-time comparison.
5. Reject timestamps outside your tolerance window. Because retries reuse the original timestamp, allow enough time for them; the examples use 10 minutes.

**`Node.js (Express)`**

```javascript title="Node.js (Express)"
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.JELLIU_WEBHOOK_SECRET; // "whsec_..."
const TOLERANCE_MS = 10 * 60 * 1000;

// Use the raw body: re-serialized JSON will not match the signature.
app.post('/webhooks/jelliu', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-Webhook-Signature-V2') ?? '';
  const timestamp = req.get('X-Webhook-Timestamp') ?? '';
  const rawBody = req.body.toString('utf8');

  const sentAt = Date.parse(timestamp);
  if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > TOLERANCE_MS) {
    return res.status(400).send('Timestamp outside tolerance');
  }

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const received = Buffer.from(signature, 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  if (received.length !== computed.length || !crypto.timingSafeEqual(received, computed)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(rawBody);
  // Hand off to a queue and acknowledge quickly.
  console.log('Received', event.event);
  res.sendStatus(200);
});

app.listen(3000);
```

**`Python (Flask)`**

```python title="Python (Flask)"
import hashlib
import hmac
import os
from datetime import datetime, timezone

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["JELLIU_WEBHOOK_SECRET"].encode()  # b"whsec_..."
TOLERANCE_SECONDS = 10 * 60


@app.post("/webhooks/jelliu")
def jelliu_webhook():
    signature = request.headers.get("X-Webhook-Signature-V2", "")
    timestamp = request.headers.get("X-Webhook-Timestamp", "")
    raw_body = request.get_data()  # raw bytes, before JSON parsing

    try:
        sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    except ValueError:
        abort(400)
    if abs((datetime.now(timezone.utc) - sent_at).total_seconds()) > TOLERANCE_SECONDS:
        abort(400)

    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)

    event = request.get_json()
    # Hand off to a queue and acknowledge quickly.
    print("Received", event["event"])
    return "", 200
```

There is no delivery ID header. A retry carries the same timestamp and body, so its `X-Webhook-Signature-V2` is identical to the first attempt's (unless the secret was rotated in between). Use the signature as an idempotency key to drop duplicates.

## Delivery and retries

|             |                                                                   |
| ----------- | ----------------------------------------------------------------- |
| Success     | Any `2xx` response.                                               |
| Timeout     | 10 seconds per attempt.                                           |
| Retried     | `5xx` responses, timeouts and network errors.                     |
| Not retried | Any other non-`2xx` response, such as `4xx`.                      |
| Attempts    | Up to 5 in total, with exponential backoff starting at 5 seconds. |

Before each attempt Jelliu reloads the webhook, so a rotated secret applies to pending retries and a disabled or deleted webhook stops receiving them.

**Automatic disabling.** Failures are counted per webhook, and any successful delivery resets the count. When a webhook reaches 10 consecutive failures it is disabled and the workspace gets an in-app notification. Fix the endpoint, then re-enable it with `PATCH /api/webhooks/{webhookId}` and `{ "is_active": true }`, which also resets the count.

Respond with `2xx` as soon as the signature checks out and do the work asynchronously, so slow processing does not turn into timeouts.

## Delivery logs

`GET /api/webhooks/{webhookId}/delivery-logs?limit=20` returns the most recent attempts. Jelliu keeps the last 25 per webhook; `limit` accepts 1 to 50 and defaults to 20.

```json
{
  "data": [
    {
      "event": "call.completed",
      "status": 503,
      "duration_ms": 412,
      "error": "HTTP 503: [remote-error] upstream unavailable",
      "delivered_at": "2026-09-14T15:42:07.318Z"
    }
  ]
}
```

`status` is `0` when no HTTP response was received (timeout, connection error, or a delivery rejected before sending). `delivered_at` is the event timestamp. Response bodies from your endpoint are truncated to 500 characters in `error`.

## Rotating the secret

Rotate a secret from the dashboard, which calls `POST /api/webhooks/{webhookId}/rotate-secret`. The new secret is returned once, in `data.secret`, and takes effect immediately, including for deliveries still waiting to be retried. There is no overlap period with the old secret, so deploy the new value to your receiver right after rotating.

API keys cannot rotate secrets, even with the `full` scope. That prevents a leaked key from silently taking over your integration.

## Filters

Filters narrow which events reach the endpoint. All filters you set must match.

| Filter         | Type               | Matches when                                                                                                                                                                              |
| -------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `campaignIds`  | uuid\[] (max 50)   | `data.campaignId` is in the list.                                                                                                                                                         |
| `agentIds`     | uuid\[] (max 50)   | `data.agentId` is in the list.                                                                                                                                                            |
| `outcomes`     | string\[] (max 20) | `data.outcome` is in the list.                                                                                                                                                            |
| `minSentiment` | number, -1 to 1    | `data.sentimentScore` is at least this value.                                                                                                                                             |
| `auditActions` | string\[] (max 50) | For `audit.log_recorded` only: `data.action` starts with one of these prefixes, for example `payments.`.                                                                                  |
| `conditions`   | array (max 10)     | Every condition matches. Each is `{ field, operator, value }`; `field` is a dot path inside `data`, and `operator` is one of `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `contains`, `exists`. |

Filters fail closed. If you filter on `agentIds` and an event does not carry `agentId`, it is **not** delivered. For the same reason, a webhook subscribed to `audit.log_recorded` cannot use `campaignIds`, `agentIds`, `outcomes` or `minSentiment`: the API rejects that combination. Use `auditActions`, or a separate webhook.

## Payload templates

`payload_template` replaces the default body with your own JSON. Placeholders:

| Placeholder      | Value                                                                                                                                          |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `{{event}}`      | The event name.                                                                                                                                |
| `{{timestamp}}`  | The event timestamp.                                                                                                                           |
| `{{data.field}}` | A field from `data`; nested paths such as `{{data.kpiData.score}}` work. Missing fields render as `null`, or as an empty string inside quotes. |

```json
{ "type": "{{event}}", "phone": "{{data.phoneNumber}}", "outcome": "{{data.outcome}}", "duration": {{data.duration}} }
```

Placeholders inside quotes are inserted as escaped text; outside quotes they are inserted as JSON values. The template must render to valid JSON. Jelliu checks it when you save, and if it cannot be rendered at delivery time the event is **not** sent and the reason appears in the delivery logs. The signature always covers the body exactly as sent.