CRM sync with webhooks
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
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 rejects webhook URLs that do not use
httpsor that point to private, internal or loopback addresses, sohttp://localhost:3000cannot be registered directly.
How it works
The receiver’s rules follow from Jelliu’s delivery behavior (see Webhooks):
Two idempotency keys are used, because they protect against different failure modes:
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.
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.
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().
Open a tunnel
In a second terminal, expose port 3000 over HTTPS:
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.
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.
Expected response 201 (abridged):
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.
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.
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):
Expected output from the script:
And from the receiver:
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.
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:
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.
Go to production
- Deploy the receiver behind HTTPS and point the webhook at it with
PATCH /api/webhooks/{webhookId}and a newurl. Changingurlorheadersrequires a signed-in session (in the dashboard, or the API called with that session); API keys receive403. - 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 event ids, and a persistent queue. Respond200only after the event is stored durably; if storage fails, respond503so 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}/replaywithfromandtore-sends the events in that window (up to 30 days back; a range containing more than 1000 events is rejected). Thewebhook-idcheck discards events already processed.
Mapping outcomes
data.outcome depends on the agent’s objective. Common values:
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
Creating the webhook returns 400 VALIDATION_FAILED on url
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 the webhook returns 403 FORBIDDEN
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.
Every delivery gets 401 Invalid signature
- 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), afterv1,. - During a rotation,
webhook-signaturecan contain two space-separated values. Accept the request when anyv1,value matches. - The secret was rotated more than 24 hours ago and the receiver still uses the previous one.
Deliveries fail with Timestamp outside tolerance
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.
Nothing arrives
- 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 withGET /api/webhooks/{webhookId}and checkis_activeanddisabled_reason; after fixing the endpoint, re-enable it withPOST /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: nullreceived no HTTP response;last_errorstates 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.
Jelliu shows 4xx and never retries
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.
The same call appears twice in the CRM
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.

