Webhooks

Receive signed events from Jelliu when calls end, campaigns complete and more.
View as Markdown

Outbound webhooks send an HTTP POST to your endpoint when something happens in your workspace. Each delivery is signed with a secret unique to the webhook, so you can prove it came from Jelliu.

Managing webhooks

Create and manage webhooks in the dashboard under Settings → Webhooks (https://app.jelliu.co/settings?tab=webhooks), or through the API:

MethodPathRequired
GET/api/webhooksread key
GET/api/webhooks/{webhookId}read key
POST/api/webhooksfull key
PATCH/api/webhooks/{webhookId}full key
DELETE/api/webhooks/{webhookId}full key
POST/api/webhooks/{webhookId}/rotate-secretDashboard session (API keys are refused)
GET/api/webhooks/{webhookId}/delivery-logsread key

Create a webhook

curl -sS -X POST "https://api.jelliu.co/api/webhooks" \
-H "Authorization: Bearer $JELLIU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/jelliu",
"events": ["call.completed", "call.failed", "campaign.completed"],
"description": "CRM sync"
}'

The response (201) includes the signing secret in data.secret, a value starting with whsec_. This is the only time the secret is returned; afterwards the API shows [configured]. Store it with your other credentials.

url
stringRequired

Where deliveries are sent. Up to 2000 characters. It must resolve to a public address: private, internal and loopback targets are rejected. Use HTTPS.

events
string[]Required

At least one event name from the catalog, or "*" for every event except audit.log_recorded.

description
string

Up to 500 characters.

filters
object

Only deliver events that match. See Filters.

headers
object

Extra HTTP headers to send with every delivery, as name/value strings. Use valid HTTP header names and no line breaks in values: otherwise deliveries are rejected and logged as failures. Custom headers cannot replace X-Webhook-*, Host or Content-Length.

payload_template
string

Custom JSON body. See Payload templates. Up to 10000 characters.

PATCH accepts the same fields, all optional, plus is_active to disable or re-enable the webhook.

Event catalog

A subscription can name any event below. The Sent column shows which events Jelliu currently emits; the others are accepted in subscriptions but are not delivered yet.

EventSentDescription
call.completedYesA call ended and its analysis is available.
call.failedYesA call ended in a technical failure, or could not connect.
campaign.completedYesA campaign finished working through its contacts.
agent.action_recordedYesAn agent invoked a tool. One event per tool call, so volume can be high.
audit.log_recordedYesA person or integration performed an audited action in the workspace. Not included in "*": name it explicitly.
crm_sync.completedYesAn interaction was written to a connected CRM.
crm_sync.failedYesWriting an interaction to a connected CRM failed.
call.startedNot yetA call started.
call.recording_readyNot yetA call recording is available.
call.transcript_readyNot yetA call transcript is available.
agent.createdNot yetAn agent was created.
agent.updatedNot yetAn agent was updated.
campaign.startedNot yetA campaign was activated.
campaign.pausedNot yetA campaign was paused.
contact.createdNot yetA contact was created.
contact.updatedNot yetA contact was updated.
contact.status_changedNot yetA contact’s status changed.
contact.convertedNot yetA contact converted.
contact.dncNot yetA contact was added to the do-not-call list.
usage.threshold_reachedNot yetUsage crossed a plan threshold.
subscription.changedNot yetThe workspace subscription changed.
integration.sync_completedNot yetAn integration sync completed.
integration.sync_failedNot yetAn integration sync failed.
integration.errorNot yetAn integration reported an error.

Payload

Unless you set a payload template, the body is:

{
"event": "call.completed",
"timestamp": "2026-09-14T15:42:07.318Z",
"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": "...",
"dataCollection": { },
"kpiData": { }
}
}

data depends on the event:

Eventdata fields
call.completed, call.failedcallId, campaignId, contactId, agentId, phoneNumber, outcome, sentimentScore, duration, summary, dataCollection, kpiData. A call.failed detected by background reconciliation carries callId, agentId, campaignId, contactId, phoneNumber, outcome, duration and reconciled: true.
campaign.completedcampaignId, campaignName
crm_sync.completed, crm_sync.failedchannel, source, callId, conversationId, agentId, campaignId, contactId, results (each with provider, success, externalId, error)
agent.action_recordedaction_id, agent_id, agent_name, conversation_id, call_id, tool_name, tool_source, action_kind, params (already redacted), outcome, error_message, result_summary, duration_ms, and more
audit.log_recordedaudit_id, user_id, action, resource_type, resource_id, changes (already redacted), ip_address, user_agent, occurred_at, redacted_at, redaction_reason

Treat every field as optional and ignore fields you do not recognize.

Delivery headers

HeaderValue
Content-Typeapplication/json
X-Webhook-EventThe event name, for example call.completed.
X-Webhook-TimestampISO 8601 UTC timestamp of the event, for example 2026-09-14T15:42:07.318Z. The same value as timestamp in the default body.
X-Webhook-Signature-V2Lowercase hex HMAC-SHA256 of timestamp + "." + raw body, keyed with your webhook secret.
X-Webhook-AttemptDelivery attempt number, starting at 1.
X-Webhook-Signature, X-Webhook-Signature-V1Legacy. HMAC-SHA256 of the body alone. Still sent for backward compatibility and will be removed.

Verify X-Webhook-Signature-V2. The legacy signatures do not cover the timestamp, so a captured delivery could be replayed against you indefinitely.

X-Webhook-Timestamp is an ISO 8601 string, not a Unix number. It is set once when the event is emitted and stays the same on every retry.

Verifying signatures

  1. Read the raw request body, before any JSON parsing.
  2. Build the signed string: the X-Webhook-Timestamp value, a period, then the raw body.
  3. Compute HMAC-SHA256 of that string with your full secret (including the whsec_ prefix) as the key, and hex-encode it.
  4. Compare it with X-Webhook-Signature-V2 using a constant-time comparison.
  5. Reject timestamps outside your tolerance window. Because retries reuse the original timestamp, allow enough time for them; the examples use 10 minutes.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.JELLIU_WEBHOOK_SECRET; // "whsec_..."
const TOLERANCE_MS = 10 * 60 * 1000;
// Use the raw body: re-serialized JSON will not match the signature.
app.post('/webhooks/jelliu', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-Webhook-Signature-V2') ?? '';
const timestamp = req.get('X-Webhook-Timestamp') ?? '';
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).send('Timestamp outside tolerance');
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const received = Buffer.from(signature, 'utf8');
const computed = Buffer.from(expected, 'utf8');
if (received.length !== computed.length || !crypto.timingSafeEqual(received, computed)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody);
// Hand off to a queue and acknowledge quickly.
console.log('Received', event.event);
res.sendStatus(200);
});
app.listen(3000);

There is no delivery ID header. A retry carries the same timestamp and body, so its X-Webhook-Signature-V2 is identical to the first attempt’s (unless the secret was rotated in between). Use the signature as an idempotency key to drop duplicates.

Delivery and retries

SuccessAny 2xx response.
Timeout10 seconds per attempt.
Retried5xx responses, timeouts and network errors.
Not retriedAny other non-2xx response, such as 4xx.
AttemptsUp to 5 in total, with exponential backoff starting at 5 seconds.

Before each attempt Jelliu reloads the webhook, so a rotated secret applies to pending retries and a disabled or deleted webhook stops receiving them.

Automatic disabling. Failures are counted per webhook, and any successful delivery resets the count. When a webhook reaches 10 consecutive failures it is disabled and the workspace gets an in-app notification. Fix the endpoint, then re-enable it with PATCH /api/webhooks/{webhookId} and { "is_active": true }, which also resets the count.

Respond with 2xx as soon as the signature checks out and do the work asynchronously, so slow processing does not turn into timeouts.

Delivery logs

GET /api/webhooks/{webhookId}/delivery-logs?limit=20 returns the most recent attempts. Jelliu keeps the last 25 per webhook; limit accepts 1 to 50 and defaults to 20.

{
"data": [
{
"event": "call.completed",
"status": 503,
"duration_ms": 412,
"error": "HTTP 503: [remote-error] upstream unavailable",
"delivered_at": "2026-09-14T15:42:07.318Z"
}
]
}

status is 0 when no HTTP response was received (timeout, connection error, or a delivery rejected before sending). delivered_at is the event timestamp. Response bodies from your endpoint are truncated to 500 characters in error.

Rotating the secret

Rotate a secret from the dashboard, which calls POST /api/webhooks/{webhookId}/rotate-secret. The new secret is returned once, in data.secret, and takes effect immediately, including for deliveries still waiting to be retried. There is no overlap period with the old secret, so deploy the new value to your receiver right after rotating.

API keys cannot rotate secrets, even with the full scope. That prevents a leaked key from silently taking over your integration.

Filters

Filters narrow which events reach the endpoint. All filters you set must match.

FilterTypeMatches when
campaignIdsuuid[] (max 50)data.campaignId is in the list.
agentIdsuuid[] (max 50)data.agentId is in the list.
outcomesstring[] (max 20)data.outcome is in the list.
minSentimentnumber, -1 to 1data.sentimentScore is at least this value.
auditActionsstring[] (max 50)For audit.log_recorded only: data.action starts with one of these prefixes, for example payments..
conditionsarray (max 10)Every condition matches. Each is { field, operator, value }; field is a dot path inside data, and operator is one of eq, neq, gt, lt, gte, lte, contains, exists.

Filters fail closed. If you filter on agentIds and an event does not carry agentId, it is not delivered. For the same reason, a webhook subscribed to audit.log_recorded cannot use campaignIds, agentIds, outcomes or minSentiment: the API rejects that combination. Use auditActions, or a separate webhook.

Payload templates

payload_template replaces the default body with your own JSON. Placeholders:

PlaceholderValue
{{event}}The event name.
{{timestamp}}The event timestamp.
{{data.field}}A field from data; nested paths such as {{data.kpiData.score}} work. Missing fields render as null, or as an empty string inside quotes.
{ "type": "{{event}}", "phone": "{{data.phoneNumber}}", "outcome": "{{data.outcome}}", "duration": {{data.duration}} }

Placeholders inside quotes are inserted as escaped text; outside quotes they are inserted as JSON values. The template must render to valid JSON. Jelliu checks it when you save, and if it cannot be rendered at delivery time the event is not sent and the reason appears in the delivery logs. The signature always covers the body exactly as sent.