Webhooks
Signed, real-time event notifications for calls, conversations, campaigns, contacts and more.
Jelliu delivers workspace events to your endpoints as signed POST requests. Deliveries implement the Standard Webhooks specification: each request is signed with a secret unique to the endpoint, carries a stable event id for deduplication, and can be verified with the official standardwebhooks libraries without modification.
Webhooks v2 (2026-09-27). The headers, signature scheme, payload envelope and several event names changed. Receivers built before this date must be migrated; see the changelog.
Managing webhooks
Manage endpoints in the dashboard under Settings → Webhooks (https://app.jelliu.co/settings?tab=webhooks) or through the API:
Responses wrap the payload in data; the delivery list also returns meta.next_cursor. Validation errors return 400 VALIDATION_FAILED with a single, specific message in error.message (in Spanish, as in the dashboard). GET /api/webhooks/{webhookId}/delivery-logs remains available as a deprecated alias of /deliveries.
Outbound webhooks are available on every plan. Write operations (create, update, rotate, test, replay, retry, enable, disable) require an active subscription; without one they return 403 BILLING_ERROR with metadata.kind: "subscription_required". Listing, reading, the delivery log and deletion remain available. See Without an active subscription.
Create a webhook
The 201 response contains the endpoint and data.secret, for example whsec_o2N0WsUq4lyziZDhqOxfc90Dyn/Dhz/NK0x2lS6imYM=. The secret is returned only in this response. Store it with your other credentials.
The delivery URL. https only, up to 2000 characters, without embedded credentials. It must resolve to a public address; private, internal, loopback and link-local targets are rejected when the endpoint is saved and again before every attempt.
One to 100 event types from the catalog, or "*" for every type that is not opt-in only. Opt-in-only types must be listed explicitly and can be combined with "*": ["*", "contact.created"]. GET /api/webhooks/event-types returns the types available for subscription.
Up to 500 characters.
Restricts deliveries to matching events. See Filters.
Up to 20 custom headers sent with every delivery, as name/value strings (values up to 2000 characters, no line breaks). Names reserved by Jelliu are rejected: webhook-*, x-jelliu-*, host, content-length, content-type, connection, transfer-encoding, te, upgrade, keep-alive, proxy-*. A custom user-agent is not sent. Values are encrypted at rest and never returned; responses list each header name with the value "[configured]".
Unknown fields are rejected with 400. A workspace can have up to 20 endpoints.
Updating. PATCH /api/webhooks/{webhookId} accepts the same fields; all are optional, and at least one is required. events, filters and headers replace the stored value; null clears filters, headers and description. The headers object returned by a read can be sent back as is: a header whose value is "[configured]" keeps its stored value. Changing url or headers redirects a live integration and therefore requires a signed-in owner or admin session; API keys receive 403. Use /disable and /enable to deactivate and reactivate an endpoint.
Visibility. Secrets and header values are never returned. Owners, admins and full API keys see the complete url; other members and read keys see only its origin (https://example.com/…), because paths and query strings often carry tokens.
Payload
Every delivery is a POST with Content-Type: application/json and this envelope:
data never contains secrets, agent instructions (prompts), lesson text, raw provider payloads or cost internals. It does contain personal data when the event concerns a person (phone numbers, emails, names, AI summaries, and message text for message.* types); the catalog identifies which types include it. A field that was not produced is null (for example, sentiment_score for an unscored call; 0 is a valid neutral score). New fields and event types may be added at any time: ignore unrecognized fields and respond 2xx to types you do not handle.
Delivery headers
Custom headers are sent alongside these. The v1 X-Webhook-* headers are no longer sent.
Verifying signatures
Each signature is a base64-encoded HMAC-SHA256 of the following content:
The HMAC key is the base64-decoded portion of the secret following whsec_, not the secret string itself. The official Standard Webhooks libraries perform this decoding when given the full whsec_… value, and are the recommended way to verify deliveries.
To verify manually:
- Read the raw request body before any JSON parsing. Re-serialized JSON does not match the signature.
- Reject the request if
webhook-timestampdiffers from your clock by more than 5 minutes. Each attempt carries a new timestamp, so legitimate retries are never rejected as stale. - Derive the key: base64-decode the portion of the secret following
whsec_. - Compute
base64(HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw body>")). - Split
webhook-signatureon spaces. Accept the request if anyv1,<signature>entry matches your result, using a constant-time comparison.
Test vector. A correct implementation reproduces the Standard Webhooks specification’s test vector: secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, webhook-id msg_p5jXN8AQM9LWM0D4loKWxJek, webhook-timestamp 1614265330, body {"test": 2432232314} → v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=.
Deduplicate on webhook-id: record processed ids and respond 2xx to a repeated id without processing it again. The same event can be delivered more than once: after a lost response, a manual retry or a replay. The id is identical in every case and across every endpoint that receives the event; if two endpoints feed the same system, track processed ids per endpoint.
Respond with any 2xx status as soon as the signature is verified, and process the event asynchronously to stay within the 10-second timeout. The response body is ignored; its first 1 KB is stored in the delivery log for debugging. Jelliu reads at most 64 KB of a response and closes the connection beyond that; the status code still determines the outcome.
Retries
Each attempt uses the endpoint’s current configuration: an updated URL, new headers or a rotated secret apply to pending retries, and deleting or disabling the endpoint cancels them. At most one request per endpoint is in flight at any time. Delivery order is not guaranteed; use created_at or the event id (both sort chronologically) when order matters.
Endpoint health
consecutive_failures counts consecutive deliveries that ended failed; all attempts of one delivery count once. A successful delivery resets the counter.
- At 3 consecutive failed deliveries, the workspace receives an in-app warning.
- At 20, provided at least 24 hours have elapsed since the first failure in the streak, the endpoint is disabled (
is_active: false,disabled_reason: "consecutive_failures") and the workspace is notified. - A
410 Goneresponse to an automatic delivery disables the endpoint immediately (disabled_reason: "gone").
Tests, manual retries and replays do not affect endpoint health, and neither do failures on Jelliu’s side (for example, a stored secret that cannot be decrypted). After fixing the endpoint, call POST /api/webhooks/{webhookId}/enable, which also resets the counter. Events continue to be stored while an endpoint is disabled and can be replayed afterward.
Without an active subscription
While the workspace has no active subscription (status active, trialing or past_due), no requests are sent: each delivery is canceled when due, with last_error: "subscription_inactive". Cancellations do not affect endpoint health, and the endpoint remains active. Events are still stored for 30 days; after the subscription is reactivated, the missed period can be replayed. See Without an active subscription.
Delivery log
GET /api/webhooks/{webhookId}/deliveries lists the endpoint’s deliveries, newest first: one record per event, updated after each attempt.
Query parameters: status (pending, delivering, succeeded, failed, canceled), type (an event type), limit (1–100, default 50) and cursor (the meta.next_cursor of the previous page; null on the last page). Events excluded by the endpoint’s events or filters never create a delivery; an empty log for an expected event usually indicates a subscription or filter mismatch.
GET /api/webhooks/deliveries/{deliveryId} returns a single delivery with all of its attempts (attempt, at, status_code, duration_ms, error, response_snippet), the event envelope as sent, and the request headers (webhook-id, webhook-timestamp, x-jelliu-*; the signature and custom headers are never returned).
error and last_error contain stable, machine-readable codes:
Events and deliveries are retained for 30 days.
Testing and replay
Test an endpoint. POST /api/webhooks/{webhookId}/test sends a single signed delivery immediately and returns the endpoint’s response:
Without a body, it sends a webhook.test event (data: { "message": "Este es un evento de prueba de Jelliu.", "webhook_id": "<the endpoint id>" }). With type, it sends that type with its catalog example data, to exercise a specific handler. A test is a single attempt with no retries, works on a disabled endpoint, appears in the delivery log with manual: true, and does not affect endpoint health.
Retry one delivery. POST /api/webhooks/deliveries/{deliveryId}/retry queues the same event (same webhook-id) for an immediate attempt, using the endpoint’s current URL and secret. It returns 202 with { "data": { "delivery_id": "...", "status": "pending" } }, or 409 while an attempt of that delivery is in flight.
Replay a period. POST /api/webhooks/{webhookId}/replay re-sends every stored event in a time range that matches the endpoint’s current events and filters:
It returns 202 with { "data": { "count": 42 } }, where count is the number of deliveries queued. from must fall within the last 30 days, and a range containing more than 1000 events is rejected (narrow the range or pass types). Replayed events keep their original id, so existing deduplication applies. Test events are never replayed. Events are stored only while the workspace has at least one endpoint, active or disabled. Replays and manual retries follow the standard retry schedule and are marked manual: true; a disabled endpoint cancels them, so enable it first.
Rotating the secret
Rotate a secret from the dashboard, which calls POST /api/webhooks/{webhookId}/rotate-secret. The response contains the new secret, returned only once, and previous_valid_until:
For the next 24 hours, webhook-signature carries two signatures, one per secret, so a receiver configured with either secret continues to verify requests while the new value is deployed. After 24 hours, only the new secret signs. Rotating again within the window replaces the previous secret: at most two secrets are valid at any time.
API keys cannot rotate secrets or change an endpoint’s url or headers, even with the full scope. This prevents a leaked key from redirecting deliveries or re-signing them with a secret the receiver does not know.
Filters
Filters restrict which events are delivered to the endpoint. All configured filters must match. Keys are snake_case.
Filters fail closed: when a filter targets a field that the event’s data does not contain (for example, agent_ids on an event without agent_id), the event is not delivered. For this reason, an endpoint subscribed to audit.log_recorded cannot use campaign_ids, agent_ids, outcomes or min_sentiment; the API rejects the combination. Use audit_actions or a separate endpoint.
Filters saved before 2026-09-27 with camelCase keys (campaignIds, agentIds, minSentiment, auditActions) remain valid and are returned in snake_case. New writes must use snake_case.
Event catalog
"*" covers every type marked No under Opt-in only. Opt-in-only types are high volume and must be listed explicitly in events.
All types below are available. GET /api/webhooks/event-types returns the same list, with English and Spanish descriptions and an example data object for each type.
Notes on frequently used types:
- One terminal event per call. Each call ends with exactly one of
call.completed(a conversation took place) orcall.failed(no conversation).call.failedcarriesreason:no_answer,busy,failed,canceled,voicemailorerror(the call was never placed, for example because the provider refused it). campaign.startedandcampaign.resumed. The first activation of a campaign sendscampaign.started; reactivation after a pause sendscampaign.resumed. Both includecampaign_id,campaign_name,agent_id,channel,previous_statusandstatus.campaign.pausedincludesreason:manual(paused by a person),agent_deletedorblocked(the workspace cannot run the campaign;blocked_reasondescribes the required action).contact.createdis sent once for every new contact, regardless of origin: the API or the dashboard (individually, in bulk or from a CSV), an agent, a CRM import or push, a first inbound message on WhatsApp, web chat, Instagram, Messenger or email, an outbound message or call to an unsaved recipient, or an automation. It is not sent when an existing contact is matched or a removed contact is restored.sourceidentifies the origin (see Contact sources).contact.opted_outis sent once, when a contact becomes opted out: the person asks during a call, sends an opt-out keyword on WhatsApp or a social inbox, unsubscribes by email, or a campaign reaches a contact whose number is already on the suppression list (basis: "suppression_list"). Repeated requests send nothing. When one opt-out applies to several contacts (for example, every contact with the same WhatsApp number), each contact receives its own event.agent_action.recordedincludes the agent’s in-call handoffs:tool_nametransfer_to_numberandtransfer_to_agent, withtool_source: "engine"andaction_kind: "write".- Escalations have no dedicated webhook event: a call handed off to a person appears as
outcome: "escalated_to_human"oncall.completed. The Zapier app provides acall.escalatedtrigger; see Zapier. - Never sent: conversations of Jelliu’s own sales assistant, agent instructions, lesson text, sealed approval arguments, raw receipts or bank data, and cost internals.
Contact sources
source in contact.created identifies the channel through which the contact was created. It never names the underlying tool or provider.
Retired events
On 2026-09-27, two types were renamed: contact.dnc is now contact.opted_out, and agent.action_recorded is now agent_action.recorded. Existing subscriptions to the old names were migrated to the new ones. An endpoint that still lists a name the catalog no longer offers can be saved with it, but such a name cannot be added to an endpoint that does not already list it.
The names retired on 2026-09-15 (call.recording_ready, call.transcript_ready, contact.status_changed, subscription.changed, integration.sync_completed, integration.sync_failed, integration.error) are not part of the catalog. call.started, contact.updated and usage.threshold_reached have been reinstated and are available.

