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. 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
fullscope to create the webhook, and areadkey 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:3000cannot be registered directly.
How it works
The rules the receiver follows come straight from how Jelliu delivers webhooks (see Webhooks):
Two idempotency keys, because they protect against different things:
Build it
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.
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:
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.
Expected response 201 (abridged):
data.secret is returned only here. Afterwards the API shows [configured].
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):
Expected output from the script:
And from the receiver:
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 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):
Go to production
- Deploy the receiver behind HTTPS and point the webhook at it with
PATCH /api/webhooks/{webhookId}and a newurl. - Replace the in-memory stores and queue with durable ones: a database table with a unique constraint on the key, or Redis
SET NXwith a TTL for signatures, and a real queue. Only answer200once the event is stored durably; if that fails, answer503so 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:
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
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-Signatureheader, which signs the body only. VerifyX-Webhook-Signature-V2overtimestamp + "." + 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 checkis_activeandlast_error; re-enable it withPATCHand{ "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: 0in 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.

