CRM sync with webhooks

Build a receiver that verifies Jelliu webhooks, survives retries and writes every call outcome to your CRM exactly once.
View as Markdown

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. 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.
  • Node.js 18 or later, or Python 3.10 or later.
  • A tunnel to expose your machine over HTTPS, such as ngrok or Cloudflare Tunnel. Jelliu refuses webhook URLs that point at private, internal or loopback addresses, so http://localhost:3000 cannot be registered directly.

How it works

The rules the receiver follows come straight from how Jelliu delivers webhooks (see Webhooks):

Jelliu behaviourWhat 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:

KeyProtects againstStored for
X-Webhook-Signature-V2The 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

1

Create the project

mkdir jelliu-crm-sync && cd jelliu-crm-sync
npm init -y
npm pkg set type=module
npm install express
2

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.

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`));

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

3

Open a tunnel

In a second terminal, expose port 3000 over HTTPS:

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

4

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 -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)"
}'

Expected response 201 (abridged):

{
"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].

5

Start the receiver

export JELLIU_WEBHOOK_SECRET="whsec_..."
node server.mjs

Expected output:

Listening on http://localhost:3000/webhooks/jelliu
6

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):

// 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());
}

Expected output from the script:

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

And from the receiver:

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.

7

Receive a real event

Place a call with an agent in your workspace, for example by following Outbound voice campaign or with the Calls API. 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 -sS "https://api.jelliu.co/api/webhooks/b3d1f0e2-7a6c-4e5b-9d8f-1a2b3c4d5e6f/delivery-logs?limit=5" \
-H "Authorization: Bearer $JELLIU_API_KEY"
{
"data": [
{
"event": "call.completed",
"status": 200,
"duration_ms": 184,
"delivered_at": "2026-09-14T15:42:07.318Z"
}
]
}
8

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:

GroupOutcomes
Salessale_closed, callback_scheduled, rejected, no_answer, voicemail, failed, escalated_to_human
Supportissue_resolved, ticket_created, faq_answered
Schedulingappointment_booked, appointment_rescheduled, appointment_canceled
Generalinfo_provided, follow_up_needed
Collectionspayment_promised, payment_collected, payment_refused
Retentioncustomer_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.

Troubleshooting

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

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

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.

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

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.

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.