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

# CRM sync with webhooks

In this recipe you build a small service that receives `call.completed` and `call.failed` webhooks, proves they came from Jelliu, drops duplicates, and writes the outcome of each call to a CRM. You test it end to end on your machine through a tunnel before deploying it.

You will end up with:

* a receiver in **Node.js** (Express) or **Python** (Flask) that verifies `X-Webhook-Signature-V2`;
* two layers of idempotency, one for retried deliveries and one per call;
* a background worker that writes to your CRM and retries on its own;
* a script that sends correctly signed test events, so you can iterate without placing calls.

If your CRM is available in Jelliu's app catalogue, you may not need this at all: connect it under **Integrations** and Jelliu writes each interaction to it for you, reporting the result as `crm_sync.completed` or `crm_sync.failed`. See [Integrations](/platform/integrations#writing-interactions-back-to-your-crm). Build your own receiver when your CRM is not in the catalogue, or when you need your own mapping.

## Prerequisites

* A workspace API key with the **`full`** scope to create the webhook, and a `read` key to inspect deliveries. See [API keys](/platform/api-keys).
* Node.js 18 or later, or Python 3.10 or later.
* A tunnel to expose your machine over HTTPS, such as [ngrok](https://ngrok.com) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/). Jelliu refuses webhook URLs that point at private, internal or loopback addresses, so `http://localhost:3000` cannot be registered directly.

## How it works

```mermaid
sequenceDiagram
    participant J as Jelliu
    participant R as Your receiver
    participant Q as Queue
    participant W as Worker
    participant C as CRM
    J->>R: POST /webhooks/jelliu (signed)
    R->>R: Check timestamp and HMAC-SHA256 signature
    R->>R: Seen this signature before? Answer 200 "duplicate"
    R->>Q: Enqueue the event
    R-->>J: 200 within 10 seconds
    Q->>W: Next event
    W->>W: Already written this call? Skip
    W->>C: Upsert activity keyed by the call id
    C-->>W: OK, or error and retry later
    Note over J,R: No 2xx (5xx, timeout, network error): Jelliu retries, up to 5 attempts
```

The rules the receiver follows come straight from how Jelliu delivers webhooks (see [Webhooks](/webhooks#delivery-and-retries)):

| Jelliu behaviour                                                                                                     | What your receiver does                                                                               |
| -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Each attempt times out after **10 seconds**.                                                                         | Acknowledge right after verifying and enqueueing. Never call the CRM inside the request.              |
| `5xx`, timeouts and network errors are retried, up to **5 attempts** with exponential backoff starting at 5 seconds. | Answer `5xx` only when you could not safely store the event, so Jelliu tries again.                   |
| Any other non-`2xx`, such as `4xx`, is **not** retried.                                                              | Answer `4xx` only for requests you will never accept: bad signature, stale timestamp, malformed body. |
| A retry has the same timestamp and body, so the **same `X-Webhook-Signature-V2`**. There is no delivery id header.   | Use the signature to drop repeated deliveries.                                                        |
| **10 consecutive failures** disable the webhook.                                                                     | Keep the endpoint healthy; a CRM outage must not turn into failed deliveries.                         |

Two idempotency keys, because they protect against different things:

| Key                                                                | Protects against                                                                                         | Stored for                                             |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `X-Webhook-Signature-V2`                                           | The same delivery arriving again because an earlier attempt timed out after you had already received it. | A day or two is plenty: retries finish within minutes. |
| `event` + `data.callId`, for example `call.completed:5b0d2f7e-...` | Writing the same call to the CRM twice, whatever the reason.                                             | As long as the CRM record exists.                      |

## Build it

#### Create the project

**`Node.js`**

```bash title="Node.js"
mkdir jelliu-crm-sync && cd jelliu-crm-sync
npm init -y
npm pkg set type=module
npm install express
```

**`Python`**

```bash title="Python"
mkdir jelliu-crm-sync && cd jelliu-crm-sync
python -m venv .venv && source .venv/bin/activate
pip install flask requests
```

#### Write the receiver

Save this as `server.mjs` (Node.js) or `server.py` (Python). It reads three environment variables: `JELLIU_WEBHOOK_SECRET` (required), and optionally `CRM_API_URL` and `CRM_API_TOKEN`. Without `CRM_API_URL` it runs in dry-run mode and prints what it would write.

**`Node.js`**

```javascript title="Node.js"
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.JELLIU_WEBHOOK_SECRET; // "whsec_..."
const PORT = Number(process.env.PORT ?? 3000);
const TOLERANCE_MS = 10 * 60 * 1000;
const MAX_WORKER_TRIES = 5;

if (!SECRET) {
  console.error('Set JELLIU_WEBHOOK_SECRET before starting the receiver.');
  process.exit(1);
}

// In-memory stores keep the example self-contained. In production use a
// database table with a unique constraint, or Redis SET NX with a TTL.
const seenDeliveries = new Map(); // signature -> first seen (ms)
const writtenCalls = new Map(); // "event:callId" -> written at (ms)
const queue = [];

// Map Jelliu outcomes to your CRM's stages. Anything unmapped is "contacted".
const STAGE_BY_OUTCOME = {
  sale_closed: 'won',
  callback_scheduled: 'follow_up',
  appointment_booked: 'meeting_booked',
  rejected: 'lost',
  no_answer: 'not_reached',
  voicemail: 'not_reached',
  failed: 'not_reached',
};

function signatureIsValid(timestamp, rawBody, signature) {
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(signature, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/jelliu', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
  const signature = req.get('X-Webhook-Signature-V2') ?? '';
  const timestamp = req.get('X-Webhook-Timestamp') ?? '';
  const attempt = req.get('X-Webhook-Attempt') ?? '1';

  if (!Buffer.isBuffer(req.body)) {
    return res.status(400).json({ error: 'Expected a JSON body' });
  }
  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).json({ error: 'Timestamp outside tolerance' });
  }
  if (!signatureIsValid(timestamp, rawBody, signature)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  if (seenDeliveries.has(signature)) {
    console.log(`Duplicate delivery ignored (attempt ${attempt})`);
    return res.status(200).json({ status: 'duplicate' });
  }

  let event;
  try {
    event = JSON.parse(rawBody);
  } catch {
    return res.status(400).json({ error: 'Malformed JSON' });
  }

  seenDeliveries.set(signature, Date.now());
  queue.push({ event, tries: 0 });
  console.log(`Queued ${event.event} (attempt ${attempt})`);
  return res.status(200).json({ status: 'queued' });
});

async function writeCallToCrm(event) {
  const d = event.data ?? {};
  const record = {
    externalId: `jelliu-call-${d.callId}`,
    phone: d.phoneNumber ?? null,
    stage: STAGE_BY_OUTCOME[d.outcome] ?? 'contacted',
    outcome: d.outcome ?? null,
    summary: d.summary ?? null,
    durationSeconds: d.duration ?? null,
    sentiment: d.sentimentScore ?? null,
    extracted: d.dataCollection ?? null,
    jelliu: { callId: d.callId, contactId: d.contactId, campaignId: d.campaignId, agentId: d.agentId },
    occurredAt: event.timestamp,
  };

  if (!process.env.CRM_API_URL) {
    console.log('CRM write (dry run):', JSON.stringify(record));
    return;
  }

  // Replace with your CRM's upsert call. Keying the write on externalId
  // makes a repeated write update the same record instead of creating one.
  const res = await fetch(`${process.env.CRM_API_URL}/activities/${record.externalId}`, {
    method: 'PUT',
    headers: {
      Authorization: `Bearer ${process.env.CRM_API_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(record),
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`CRM responded ${res.status}`);
  console.log(`CRM upserted ${record.externalId}`);
}

async function processEvent(event) {
  switch (event.event) {
    case 'call.completed':
    case 'call.failed': {
      const key = `${event.event}:${event.data?.callId}`;
      if (writtenCalls.has(key)) {
        console.log(`Already written: ${key}`);
        return;
      }
      await writeCallToCrm(event);
      writtenCalls.set(key, Date.now());
      return;
    }
    default:
      console.log(`Ignoring ${event.event}`);
  }
}

let draining = false;
async function drain() {
  if (draining) return;
  draining = true;
  try {
    while (queue.length > 0) {
      const job = queue.shift();
      try {
        await processEvent(job.event);
      } catch (err) {
        job.tries += 1;
        if (job.tries < MAX_WORKER_TRIES) {
          const delayMs = 2 ** job.tries * 1000;
          console.warn(`Write failed (${err.message}); retrying in ${delayMs} ms`);
          setTimeout(() => queue.push(job), delayMs);
        } else {
          console.error(`Giving up on ${job.event.event}:`, err.message);
        }
      }
    }
  } finally {
    draining = false;
  }
}
setInterval(drain, 500);

app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}/webhooks/jelliu`));
```

**`Python`**

```python title="Python"
import hashlib
import hmac
import json
import os
import queue
import sqlite3
import threading
import time
from datetime import datetime, timezone

import requests
from flask import Flask, jsonify, request

SECRET = os.environ.get("JELLIU_WEBHOOK_SECRET", "").encode()  # b"whsec_..."
PORT = int(os.environ.get("PORT", "3000"))
TOLERANCE_SECONDS = 10 * 60
MAX_WORKER_TRIES = 5
DB_PATH = os.environ.get("DB_PATH", "jelliu_crm_sync.db")

if not SECRET:
    raise SystemExit("Set JELLIU_WEBHOOK_SECRET before starting the receiver.")

# Map Jelliu outcomes to your CRM's stages. Anything unmapped is "contacted".
STAGE_BY_OUTCOME = {
    "sale_closed": "won",
    "callback_scheduled": "follow_up",
    "appointment_booked": "meeting_booked",
    "rejected": "lost",
    "no_answer": "not_reached",
    "voicemail": "not_reached",
    "failed": "not_reached",
}


def connect():
    conn = sqlite3.connect(DB_PATH, timeout=10)
    conn.execute(
        "CREATE TABLE IF NOT EXISTS deliveries (signature TEXT PRIMARY KEY, received_at REAL NOT NULL)"
    )
    conn.execute(
        "CREATE TABLE IF NOT EXISTS written_calls (business_key TEXT PRIMARY KEY, written_at REAL NOT NULL)"
    )
    return conn


jobs: "queue.Queue[dict]" = queue.Queue()
app = Flask(__name__)


@app.post("/webhooks/jelliu")
def receive():
    signature = request.headers.get("X-Webhook-Signature-V2", "")
    timestamp = request.headers.get("X-Webhook-Timestamp", "")
    attempt = request.headers.get("X-Webhook-Attempt", "1")
    raw_body = request.get_data()  # raw bytes, before any JSON parsing

    try:
        sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    except ValueError:
        return jsonify(error="Invalid timestamp"), 400
    if abs((datetime.now(timezone.utc) - sent_at).total_seconds()) > TOLERANCE_SECONDS:
        return jsonify(error="Timestamp outside tolerance"), 400

    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        return jsonify(error="Invalid signature"), 401

    try:
        event = json.loads(raw_body)
    except ValueError:
        return jsonify(error="Malformed JSON"), 400

    conn = connect()
    try:
        with conn:
            conn.execute(
                "INSERT INTO deliveries (signature, received_at) VALUES (?, ?)",
                (signature, time.time()),
            )
    except sqlite3.IntegrityError:
        print(f"Duplicate delivery ignored (attempt {attempt})")
        return jsonify(status="duplicate"), 200
    except sqlite3.Error:
        # Could not record the delivery: let Jelliu retry.
        return jsonify(error="Storage unavailable"), 503
    finally:
        conn.close()

    jobs.put(event)
    print(f"Queued {event.get('event')} (attempt {attempt})")
    return jsonify(status="queued"), 200


def write_call_to_crm(event: dict) -> None:
    d = event.get("data") or {}
    record = {
        "externalId": f"jelliu-call-{d.get('callId')}",
        "phone": d.get("phoneNumber"),
        "stage": STAGE_BY_OUTCOME.get(d.get("outcome"), "contacted"),
        "outcome": d.get("outcome"),
        "summary": d.get("summary"),
        "durationSeconds": d.get("duration"),
        "sentiment": d.get("sentimentScore"),
        "extracted": d.get("dataCollection"),
        "jelliu": {
            "callId": d.get("callId"),
            "contactId": d.get("contactId"),
            "campaignId": d.get("campaignId"),
            "agentId": d.get("agentId"),
        },
        "occurredAt": event.get("timestamp"),
    }

    crm_url = os.environ.get("CRM_API_URL")
    if not crm_url:
        print("CRM write (dry run):", json.dumps(record))
        return

    # Replace with your CRM's upsert call. Keying the write on externalId
    # makes a repeated write update the same record instead of creating one.
    res = requests.put(
        f"{crm_url}/activities/{record['externalId']}",
        headers={"Authorization": f"Bearer {os.environ.get('CRM_API_TOKEN', '')}"},
        json=record,
        timeout=15,
    )
    res.raise_for_status()
    print(f"CRM upserted {record['externalId']}")


def process_event(event: dict) -> None:
    name = event.get("event")
    if name not in ("call.completed", "call.failed"):
        print(f"Ignoring {name}")
        return

    key = f"{name}:{(event.get('data') or {}).get('callId')}"
    conn = connect()
    try:
        if conn.execute("SELECT 1 FROM written_calls WHERE business_key = ?", (key,)).fetchone():
            print(f"Already written: {key}")
            return
        write_call_to_crm(event)
        with conn:
            conn.execute(
                "INSERT OR IGNORE INTO written_calls (business_key, written_at) VALUES (?, ?)",
                (key, time.time()),
            )
    finally:
        conn.close()


def worker() -> None:
    while True:
        event = jobs.get()
        for attempt in range(1, MAX_WORKER_TRIES + 1):
            try:
                process_event(event)
                break
            except Exception as exc:  # noqa: BLE001
                if attempt == MAX_WORKER_TRIES:
                    print(f"Giving up on {event.get('event')}: {exc}")
                else:
                    delay = 2 ** attempt
                    print(f"Write failed ({exc}); retrying in {delay} s")
                    time.sleep(delay)
        jobs.task_done()


if __name__ == "__main__":
    connect().close()
    threading.Thread(target=worker, daemon=True).start()
    print(f"Listening on http://localhost:{PORT}/webhooks/jelliu")
    app.run(port=PORT)
```

Verify against the **raw** body. Parsing the JSON and serializing it again changes whitespace and key order, and the signature will never match. That is why Express uses `express.raw` on this route and Flask reads `request.get_data()`.

#### Open a tunnel

In a second terminal, expose port 3000 over HTTPS:

**`ngrok`**

```bash title="ngrok"
ngrok http 3000
```

**`Cloudflare Tunnel`**

```bash title="Cloudflare Tunnel"
cloudflared tunnel --url http://localhost:3000
```

Copy the public `https://` URL it prints. Your webhook URL is that address plus `/webhooks/jelliu`, for example `https://4f2a-203-0-113-7.ngrok-free.app/webhooks/jelliu`.

#### Register the webhook

Create the webhook with a `full` key, subscribing to the two call events. Add `filters` if you only want some campaigns or agents.

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/api/webhooks" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://4f2a-203-0-113-7.ngrok-free.app/webhooks/jelliu",
    "events": ["call.completed", "call.failed"],
    "description": "CRM sync (local test)"
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://4f2a-203-0-113-7.ngrok-free.app/webhooks/jelliu',
    events: ['call.completed', 'call.failed'],
    description: 'CRM sync (local test)',
  }),
});
const { data } = await res.json();
console.log('Webhook id:', data.id);
console.log('Secret (store it now, it is shown once):', data.secret);
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.post(
    "https://api.jelliu.co/api/webhooks",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={
        "url": "https://4f2a-203-0-113-7.ngrok-free.app/webhooks/jelliu",
        "events": ["call.completed", "call.failed"],
        "description": "CRM sync (local test)",
    },
    timeout=30,
)
res.raise_for_status()
data = res.json()["data"]
print("Webhook id:", data["id"])
print("Secret (store it now, it is shown once):", data["secret"])
```

Expected response `201` (abridged):

```json
{
  "data": {
    "id": "b3d1f0e2-7a6c-4e5b-9d8f-1a2b3c4d5e6f",
    "url": "https://4f2a-203-0-113-7.ngrok-free.app/webhooks/jelliu",
    "events": ["call.completed", "call.failed"],
    "description": "CRM sync (local test)",
    "is_active": true,
    "failure_count": 0,
    "secret": "whsec_2f6c0a9b8e7d6c5b4a39281706f5e4d3c2b1a09182736450"
  }
}
```

`data.secret` is returned only here. Afterwards the API shows `[configured]`.

#### Start the receiver

**`Node.js`**

```bash title="Node.js"
export JELLIU_WEBHOOK_SECRET="whsec_..."
node server.mjs
```

**`Python`**

```bash title="Python"
export JELLIU_WEBHOOK_SECRET="whsec_..."
python server.py
```

Expected output:

```text
Listening on http://localhost:3000/webhooks/jelliu
```

#### Send a signed test event

There is no "send test event" endpoint, so sign one yourself with the same secret. This exercises exactly the code path a real delivery takes. Run it with the receiver's URL (local or the tunnel):

**`Node.js`**

```javascript title="Node.js"
// send-test-event.mjs
import crypto from 'node:crypto';

const url = process.argv[2] ?? 'http://localhost:3000/webhooks/jelliu';
const secret = process.env.JELLIU_WEBHOOK_SECRET;
const timestamp = new Date().toISOString();
const body = JSON.stringify({
  event: 'call.completed',
  timestamp,
  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: 'El cliente pidió que lo llamen el jueves en la tarde.',
    dataCollection: { preferred_time: 'jueves en la tarde' },
    kpiData: null,
  },
});
const signature = crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');

// Send the same delivery twice, the way a retry would arrive.
for (const attempt of [1, 2]) {
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Webhook-Event': 'call.completed',
      'X-Webhook-Timestamp': timestamp,
      'X-Webhook-Signature-V2': signature,
      'X-Webhook-Attempt': String(attempt),
    },
    body,
  });
  console.log(res.status, await res.text());
}
```

**`Python`**

```python title="Python"
# send_test_event.py
import hashlib
import hmac
import json
import os
import sys
from datetime import datetime, timezone

import requests

url = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000/webhooks/jelliu"
secret = os.environ["JELLIU_WEBHOOK_SECRET"].encode()
timestamp = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
body = json.dumps({
    "event": "call.completed",
    "timestamp": timestamp,
    "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": "El cliente pidió que lo llamen el jueves en la tarde.",
        "dataCollection": {"preferred_time": "jueves en la tarde"},
        "kpiData": None,
    },
}).encode()
signature = hmac.new(secret, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()

# Send the same delivery twice, the way a retry would arrive.
for attempt in (1, 2):
    res = requests.post(
        url,
        data=body,
        headers={
            "Content-Type": "application/json",
            "X-Webhook-Event": "call.completed",
            "X-Webhook-Timestamp": timestamp,
            "X-Webhook-Signature-V2": signature,
            "X-Webhook-Attempt": str(attempt),
        },
        timeout=15,
    )
    print(res.status_code, res.text.strip())
```

Expected output from the script:

```text
200 {"status":"queued"}
200 {"status":"duplicate"}
```

And from the receiver:

```text
Queued call.completed (attempt 1)
Duplicate delivery ignored (attempt 2)
CRM write (dry run): {"externalId":"jelliu-call-5b0d2f7e-9a41-4c3e-8f0a-2c6d1e7b9a10","phone":"+573001234567","stage":"follow_up",...}
```

Run the script a second time: it signs with a new timestamp, so the delivery is new, but the worker prints `Already written: call.completed:5b0d2f7e-...`. That is the per-call key doing its job.

#### Receive a real event

Place a call with an agent in your workspace, for example by following [Outbound voice campaign](/recipes/outbound-voice-campaign) or with the [Calls API](/resources/calls). When the call has ended and been analysed, `call.completed` (or `call.failed`) reaches your receiver.

Check what Jelliu saw with the delivery log (a `read` key is enough):

**`cURL`**

```bash title="cURL"
curl -sS "https://api.jelliu.co/api/webhooks/b3d1f0e2-7a6c-4e5b-9d8f-1a2b3c4d5e6f/delivery-logs?limit=5" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const webhookId = 'b3d1f0e2-7a6c-4e5b-9d8f-1a2b3c4d5e6f';
const res = await fetch(`https://api.jelliu.co/api/webhooks/${webhookId}/delivery-logs?limit=5`, {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
console.table((await res.json()).data);
```

**`Python`**

```python title="Python"
import os
import requests

webhook_id = "b3d1f0e2-7a6c-4e5b-9d8f-1a2b3c4d5e6f"
res = requests.get(
    f"https://api.jelliu.co/api/webhooks/{webhook_id}/delivery-logs",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    params={"limit": 5},
    timeout=30,
)
for entry in res.json()["data"]:
    print(entry)
```

```json
{
  "data": [
    {
      "event": "call.completed",
      "status": 200,
      "duration_ms": 184,
      "delivered_at": "2026-09-14T15:42:07.318Z"
    }
  ]
}
```

#### Go to production

* Deploy the receiver behind HTTPS and point the webhook at it with `PATCH /api/webhooks/{webhookId}` and a new `url`.
* Replace the in-memory stores and queue with durable ones: a database table with a unique constraint on the key, or Redis `SET NX` with a TTL for signatures, and a real queue. Only answer `200` once the event is stored durably; if that fails, answer `503` so Jelliu retries.
* Keep the secret in your secret manager. When you rotate it in the dashboard, the new secret applies at once, including to pending retries, with no overlap period: deploy it immediately.

## Mapping outcomes

`data.outcome` depends on the agent's objective. Some values you will see:

| Group       | Outcomes                                                                                                  |
| ----------- | --------------------------------------------------------------------------------------------------------- |
| Sales       | `sale_closed`, `callback_scheduled`, `rejected`, `no_answer`, `voicemail`, `failed`, `escalated_to_human` |
| Support     | `issue_resolved`, `ticket_created`, `faq_answered`                                                        |
| Scheduling  | `appointment_booked`, `appointment_rescheduled`, `appointment_canceled`                                   |
| General     | `info_provided`, `follow_up_needed`                                                                       |
| Collections | `payment_promised`, `payment_collected`, `payment_refused`                                                |
| Retention   | `customer_retained`, `customer_reactivated`, `churned`                                                    |

`outcome` can be `null` when there is no analysis for the call. Map unknown and `null` values to a neutral stage rather than failing, as the receiver above does. `dataCollection` holds the fields your agent was configured to extract, keyed by field name.

To receive only the calls that matter to your CRM, filter on the webhook instead of in your code: `"filters": { "outcomes": ["sale_closed", "callback_scheduled"] }`. Filters fail closed, so an event without the filtered field is not delivered. See [Filters](/webhooks#filters).

## Troubleshooting

#### Creating the webhook returns 400 VALIDATION\_FAILED on url

The URL points at a private, internal or loopback address, such as `localhost` or `192.168.x.x`. Register the public tunnel URL instead. The URL must also be at most 2000 characters.

#### Creating the webhook returns 403 FORBIDDEN

Creating, updating and deleting webhooks needs a `full` key: `This operation requires an API key with the 'full' scope`. Reading the webhook and its delivery logs works with `read`.

#### Every delivery gets 401 Invalid signature

* The body was parsed before verification. Use the raw bytes.
* The secret is wrong or incomplete. Use the whole value, including the `whsec_` prefix, with no trailing newline.
* The secret was rotated. The new one applies immediately; update the receiver.
* You are verifying the legacy `X-Webhook-Signature` header, which signs the body only. Verify `X-Webhook-Signature-V2` over `timestamp + "." + body`.

#### Deliveries fail with Timestamp outside tolerance

Check your server clock. `X-Webhook-Timestamp` is set once when the event is emitted and is reused by every retry, so the tolerance window must cover the retry schedule; 10 minutes is comfortable.

#### Nothing arrives

* The webhook may be disabled after 10 consecutive failures. Fetch it with `GET /api/webhooks/{webhookId}` and check `is_active` and `last_error`; re-enable it with `PATCH` and `{ "is_active": true }`.
* A filter may exclude the event. Filters fail closed.
* The tunnel URL changed. Free tunnels often assign a new hostname on every start; update the webhook `url`.
* `status: 0` in the delivery log means no HTTP response was received: a timeout, a connection error, or a delivery rejected before sending.

#### Jelliu shows 4xx and never retries

Only `5xx`, timeouts and network errors are retried. If your receiver answers `4xx` because a downstream system is unavailable, that event is lost for Jelliu. Answer `4xx` only for requests you reject on purpose.

#### The same call appears twice in the CRM

Deduplicating on the signature alone is not enough, because it only catches retries of one delivery. Keep the per-call key (`event` + `callId`) and make the CRM write an upsert on an external id derived from `callId`.

## Related

#### [Webhooks](/webhooks)

Event catalog, payloads, headers, filters and payload templates.

#### [Integrations](/platform/integrations)

Connected apps with built-in CRM writeback.

#### [Zapier](/platform/zapier)

No-code alternative for simple CRM updates.

#### [Outbound voice campaign](/recipes/outbound-voice-campaign)

Generate the calls this receiver syncs.