Skip to navigation

CRM sync with webhooks

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

This recipe builds a service that receives call.completed and call.failed events, verifies that they originate from Jelliu, discards duplicates and writes each call outcome to a CRM. The receiver is tested end to end locally, through a tunnel, before deployment.

The result:

  • a receiver in Node.js (Express) or Python (Flask) that verifies the Standard Webhooks signature Jelliu sends (webhook-id, webhook-timestamp, webhook-signature);
  • two layers of idempotency, one for repeated deliveries and one per call;
  • a background worker that writes to your CRM and handles its own retries;
  • a script that sends correctly signed test events, so the receiver can be exercised without placing calls.

If your CRM is available in Jelliu’s app catalog, this receiver may be unnecessary: connect the CRM under Integrations and Jelliu writes each interaction to it, reporting the result as crm_sync.completed or crm_sync.failed. See Integrations. Build a custom receiver when your CRM is not in the catalog or requires a custom 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 rejects webhook URLs that do not use https or that point to private, internal or loopback addresses, so http://localhost:3000 cannot be registered directly.

How it works

POST /webhooks/jelliu (signed) Check webhook-timestamp and the v1 HMAC-SHA256 signature Seen this webhook-id before? Answer 200 "duplicate" Enqueue the event 200 within 10 seconds Next event Already written this call? Skip Upsert activity keyed by the call id OK, or error and retry later No 2xx (5xx, 408, 425, 429, timeout, network error): Jelliu retries, up to 12 attempts over about 4 days Jelliu Your receiver Queue Worker CRM

The receiver’s rules follow from Jelliu’s delivery behavior (see Webhooks):

Jelliu behaviorReceiver behavior
Each attempt times out after 10 seconds.Acknowledge immediately after verifying and enqueueing. Never call the CRM within the request.
5xx, 408, 425, 429, timeouts and network errors are retried, up to 12 attempts: 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h, 12 h, then every 24 h. A Retry-After header that requests a longer delay than the schedule is honored.Respond 503 (or 429 with Retry-After) only when the event could not be stored safely, so that Jelliu retries.
Any other 4xx, and every 3xx, is not retried. 410 Gone also disables the endpoint.Respond 4xx only to requests that will never be accepted: invalid signature, stale timestamp, malformed body.
Every attempt of an event carries the same webhook-id and body; webhook-timestamp and the signature are recomputed on each attempt.Use webhook-id to discard repeated deliveries.
3 consecutive failed deliveries trigger an in-app warning; 20 consecutive failures spanning at least 24 hours disable the endpoint.Keep the endpoint healthy: a CRM outage must not turn into failed deliveries.

Two idempotency keys are used, because they protect against different failure modes:

KeyProtects againstStored for
webhook-id (the event id, evt_...)The same event arriving again: a retry after an attempt that timed out after the event was received, a manual retry, or a replay from the dashboard or the API.At least as long as a replay can reach back: Jelliu retains events for 30 days.
type + data.call_id, for example call.completed:5b0d2f7e-...Writing the same call to the CRM twice, for any 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.

The signature check requires no library: the HMAC key is the base64-decoded portion of the secret following whsec_, the signed content is <webhook-id>.<webhook-timestamp>.<raw body>, and webhook-signature is a space-separated list of v1,<base64> values (two during the 24-hour overlap after a secret rotation). The official standardwebhooks packages implement the same check if you prefer a dependency.

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_SECONDS = 5 * 60;
const MAX_WORKER_TRIES = 5;
if (!SECRET) {
console.error('Set JELLIU_WEBHOOK_SECRET before starting the receiver.');
process.exit(1);
}
// The HMAC key is the base64-DECODED part after "whsec_", not the string itself.
const KEY = Buffer.from(SECRET.startsWith('whsec_') ? SECRET.slice(6) : SECRET, 'base64');
// 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 seenEvents = new Map(); // webhook-id -> first seen (ms)
const writtenCalls = new Map(); // "type:call_id" -> 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',
busy: 'not_reached',
failed: 'not_reached',
};
function signatureIsValid(id, timestamp, rawBody, header) {
const expected = crypto
.createHmac('sha256', KEY)
.update(`${id}.${timestamp}.${rawBody}`)
.digest('base64');
// Space-separated "v1,<base64>" entries; any one matching is enough.
return header.split(' ').some((entry) => {
const [version, signature] = entry.split(',', 2);
if (version !== 'v1' || !signature) return false;
const a = Buffer.from(signature);
const b = Buffer.from(expected);
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 id = req.get('webhook-id') ?? '';
const timestamp = req.get('webhook-timestamp') ?? '';
const signature = req.get('webhook-signature') ?? '';
const attempt = req.get('x-jelliu-attempt') ?? '1';
if (!Buffer.isBuffer(req.body)) {
return res.status(400).json({ error: 'Expected a JSON body' });
}
const rawBody = req.body.toString('utf8');
if (!id || !timestamp || !signature) {
return res.status(400).json({ error: 'Missing webhook headers' });
}
const ts = Number(timestamp); // Unix seconds, set at send time on every attempt
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) {
return res.status(400).json({ error: 'Timestamp outside tolerance' });
}
if (!signatureIsValid(id, timestamp, rawBody, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
if (seenEvents.has(id)) {
console.log(`Duplicate event ${id} 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' });
}
seenEvents.set(id, Date.now());
queue.push({ event, tries: 0 });
console.log(`Queued ${event.type} (attempt ${attempt})`);
return res.status(200).json({ status: 'queued' });
});
async function writeCallToCrm(event) {
const d = event.data ?? {};
const record = {
externalId: `jelliu-call-${d.call_id}`,
phone: d.phone_number ?? null,
stage: STAGE_BY_OUTCOME[d.outcome] ?? 'contacted',
outcome: d.outcome ?? null,
failureReason: d.reason ?? null, // call.failed only
summary: d.summary ?? null,
durationSeconds: d.duration ?? null,
sentiment: d.sentiment_score ?? null,
extracted: d.data_collection ?? null,
jelliu: { callId: d.call_id, contactId: d.contact_id, campaignId: d.campaign_id, agentId: d.agent_id },
occurredAt: event.created_at,
};
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.type) {
case 'call.completed':
case 'call.failed': {
const key = `${event.type}:${event.data?.call_id}`;
if (writtenCalls.has(key)) {
console.log(`Already written: ${key}`);
return;
}
await writeCallToCrm(event);
writtenCalls.set(key, Date.now());
return;
}
default:
console.log(`Ignoring ${event.type}`);
}
}
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.type}:`, 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 and re-serializing the JSON changes whitespace and key order, so the signature never matches. For this reason, 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. The webhook URL is that address followed by /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, subscribed to both call events. Add filters to restrict deliveries to specific 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",
"description": "CRM sync (local test)",
"events": ["call.completed", "call.failed"],
"filters": null,
"headers": {},
"is_active": true,
"disabled_reason": null,
"consecutive_failures": 0,
"last_delivery_status": null,
"previous_secret_valid_until": null,
"created_at": "2026-09-27T21:00:00.000Z",
"secret": "whsec_Mo0Z3pTj9t5l0gQ2cV8yXb4nR6aK1eWfHs7uJdPqLzE="
}
}

data.secret is returned only in this response: whsec_ followed by the base64 encoding of 32 random bytes. It cannot be retrieved later, only rotated.

5

Start the receiver

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

Expected output:

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

Send a test event

With the tunnel and the webhook in place, request a test delivery. POST /api/webhooks/{webhookId}/test with { "type": "call.completed" } delivers a signed call.completed built from the catalog example (without type, it sends a webhook.test event) and returns the receiver’s response. See Testing and replay.

curl -sS -X POST "https://api.jelliu.co/api/webhooks/b3d1f0e2-7a6c-4e5b-9d8f-1a2b3c4d5e6f/test" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "call.completed" }'
{
"data": {
"delivery_id": "dlv_01K6B3N5C8Q2V7W4X9Y0Z1A2B3",
"success": true,
"status_code": 200,
"duration_ms": 184,
"error": null,
"response_snippet": "{\"status\":\"queued\"}"
}
}

To iterate without the tunnel, or to observe how the receiver handles a repeated event, sign an event locally with the same secret. This exercises the same code path as a real delivery. Run the script against the receiver’s URL (local or 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 key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const eventId = `evt_TEST${crypto.randomBytes(11).toString('hex').toUpperCase()}`;
const body = JSON.stringify({
id: eventId,
type: 'call.completed',
api_version: '2026-09-27',
created_at: new Date().toISOString(),
tenant_id: '7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d',
data: {
call_id: '5b0d2f7e-9a41-4c3e-8f0a-2c6d1e7b9a10',
campaign_id: '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90',
contact_id: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
agent_id: '7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4',
phone_number: '+573001234567',
outcome: 'callback_scheduled',
sentiment_score: 0.6,
duration: 184,
summary: 'El cliente pidió que lo llamen el jueves en la tarde.',
data_collection: { preferred_time: 'jueves en la tarde' },
kpi_data: null,
},
});
// Send the same event twice, the way a retry would arrive: same
// webhook-id and body, a fresh timestamp and signature each attempt.
for (const attempt of [1, 2]) {
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = 'v1,' + crypto.createHmac('sha256', key).update(`${eventId}.${timestamp}.${body}`).digest('base64');
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'webhook-id': eventId,
'webhook-timestamp': timestamp,
'webhook-signature': signature,
'x-jelliu-event-type': 'call.completed',
'x-jelliu-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 event evt_TEST3F9A0C1D2E4B5A6978C0D1 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 sends a new webhook-id, so the event is treated as new, but the worker prints Already written: call.completed:5b0d2f7e-.... The per-call key prevents the duplicate write.

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 analyzed, call.completed (or call.failed) reaches the receiver. Jelliu sends exactly one of the two per call.

Inspect the delivery log (a read key is sufficient). Results are sorted newest first; pass meta.next_cursor as cursor to fetch the next page:

curl -sS "https://api.jelliu.co/api/webhooks/b3d1f0e2-7a6c-4e5b-9d8f-1a2b3c4d5e6f/deliveries?limit=5" \
-H "Authorization: Bearer $JELLIU_API_KEY"
{
"data": [
{
"id": "dlv_01K6B3Q7T2M9N4P8R5S6V0W1X2",
"event_id": "evt_01K6B3Q7S9H3J5K7M2N4P6R8T0",
"event_type": "call.completed",
"status": "succeeded",
"attempt_count": 1,
"next_attempt_at": null,
"last_attempt_at": "2026-09-27T21:04:05.402Z",
"last_status_code": 200,
"last_error": null,
"last_duration_ms": 184,
"manual": false,
"created_at": "2026-09-27T21:04:05.123Z"
}
],
"meta": { "next_cursor": null }
}

event_id is the webhook-id the receiver logged, and id is the x-jelliu-delivery-id header, so each record can be matched to a request. GET /api/webhooks/deliveries/{deliveryId} adds every attempt, the first kilobyte of the endpoint’s response and the exact body sent. POST /api/webhooks/deliveries/{deliveryId}/retry queues an immediate new attempt. See Delivery log.

8

Go to production

  • Deploy the receiver behind HTTPS and point the webhook at it with PATCH /api/webhooks/{webhookId} and a new url. Changing url or headers requires a signed-in session (in the dashboard, or the API called with that session); API keys receive 403.
  • 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 event ids, and a persistent queue. Respond 200 only after the event is stored durably; if storage fails, respond 503 so that Jelliu retries. The Python example above already does this, and stores the event id and the pending work as a single row written in one transaction. A durable event id next to an in-memory queue is worse than neither: a restart loses the work while the id survives, so the next retry is answered as a duplicate and the event is lost.
  • Store the secret in your secret manager. After a rotation, Jelliu signs every delivery with both the new and the previous secret for 24 hours; deploy the new value within that window. See Rotating the secret.
  • If the receiver was unavailable, POST /api/webhooks/{webhookId}/replay with from and to re-sends the events in that window (up to 30 days back; a range containing more than 1000 events is rejected). The webhook-id check discards events already processed.

Mapping outcomes

data.outcome depends on the agent’s objective. Common values:

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 the call has no analysis. Map unknown and null values to a neutral stage rather than failing, as the receiver above does. On call.failed, reason is no_answer, busy, failed, canceled, voicemail or error, and outcome equals it when the call has no analyzed outcome. data_collection contains the fields the agent is configured to extract, keyed by field name. sentiment_score is null when the call was not scored; store it as empty rather than as 0, which is a valid neutral score.

To receive only the calls relevant to the CRM, filter at the webhook rather than in code: "filters": { "outcomes": ["sale_closed", "callback_scheduled"] }. Filters fail closed: an event without the filtered field is not delivered. See Filters.

Troubleshooting

The URL does not use https, points to a private, internal or loopback address (such as localhost or 192.168.x.x), or its hostname does not resolve to a public address. Register the public tunnel URL instead. The URL is also limited to 2000 characters. error.message states which rule failed, in Spanish, for example La URL debe usar https.

Creating, updating, testing and deleting webhooks requires a full key: Esta operación requiere una clave de API con el permiso 'full'. Reading webhooks and their delivery log requires only read.

  • The body was parsed before verification. Use the raw bytes.
  • The key is incorrect. It is the base64-decoded portion of the secret following whsec_, not the whole string; also check for a trailing newline.
  • The signed content is webhook-id, a period, webhook-timestamp, a period, then the body, and the result is base64 (not hex), after v1,.
  • During a rotation, webhook-signature can contain two space-separated values. Accept the request when any v1, value matches.
  • The secret was rotated more than 24 hours ago and the receiver still uses the previous one.

Check the server clock. webhook-timestamp is the Unix time, in seconds, at which each attempt was sent, so a 5-minute tolerance also covers retries.

  • The webhook may be disabled: after 20 consecutive failed deliveries spanning at least 24 hours, after the endpoint returned 410 Gone, or manually. Retrieve it with GET /api/webhooks/{webhookId} and check is_active and disabled_reason; after fixing the endpoint, re-enable it with POST /api/webhooks/{webhookId}/enable.
  • A filter may exclude the event. Filters fail closed.
  • The tunnel URL changed. Free tunnels often assign a new hostname on each start; update the webhook url.
  • A delivery with last_status_code: null received no HTTP response; last_error states the cause (timeout, connection_error, url_not_allowed, …).
  • The workspace has no active subscription. Deliveries are canceled (last_error: subscription_inactive) until the subscription is reactivated. See Without an active subscription.

Only 5xx, 408, 425, 429, timeouts and network errors are retried. If the receiver returns another 4xx because a downstream system is unavailable, the delivery ends failed. Respond 503 (or 429 with Retry-After) for temporary failures, and 4xx only for requests rejected intentionally. A failed delivery can be retried with POST /api/webhooks/deliveries/{deliveryId}/retry.

Deduplicating on webhook-id catches every repeat of one event, but keep the per-call key (type + call_id) as well and make the CRM write an upsert on an external id derived from call_id: two webhooks pointing to the same receiver, or an event replayed after the id store expired, would otherwise write twice.