Skip to navigation

Changelog

Notable changes to the Jelliu API and its documentation.
View as Markdown

2026-09-30 — New webhook event: security.event_recorded

Each decision a security control makes about an agent is now available as a webhook event, for SIEM ingestion: a deny rule that blocked a tool or would have in audit mode, a stored key refused because the request was headed to a host it is not bound to, and an access request from an agent raised or decided. data.ocsf carries the same event as an OCSF 1.9.0 Detection Finding. The type is opt-in only: "*" does not cover it, so list it explicitly. See the event catalog.

2026-09-27 — Outbound webhooks v2: Standard Webhooks, more events, retries over four days

Outbound webhooks were rebuilt. This is a breaking change for receivers: the headers, signature, body and event names changed. The Webhooks guide documents the new contract in full; this entry lists the required changes.

The request

  • Standard Webhooks signing. Deliveries carry webhook-id, webhook-timestamp (Unix seconds, set when each attempt is sent) and webhook-signature (v1,<base64> HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body>). The official standardwebhooks libraries verify it without modification. See Verifying signatures.
  • The HMAC key changed. It is the base64-decoded portion of the secret following whsec_, not the whole string. New secrets are whsec_ followed by the base64 encoding of 32 random bytes; a secret issued before this change remains valid once the receiver decodes it this way.
  • The X-Webhook-* headers were removed, including the legacy body-only signatures. Jelliu also sends x-jelliu-event-type, x-jelliu-delivery-id and x-jelliu-attempt.
  • New body. { "id", "type", "api_version", "created_at", "tenant_id", "data" } replaces { "event", "timestamp", "data" }. id (evt_...) equals webhook-id and is identical across attempts, manual retries and replays of the event; use it for deduplication. api_version is 2026-09-27.
  • data keys are snake_case in every event: call_id, sentiment_score, data_collection, kpi_data, campaign_name, changed_fields, and so on.
  • payload_template was removed. Every delivery has the standard body, so the signature always covers a known shape.

Events

  • 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 automatically.
  • Activating a paused campaign now sends campaign.resumed instead of campaign.started.
  • call.failed carries reason (no_answer, busy, failed, canceled, voicemail or error), and a call sends call.completed or call.failed, never both. The reconciler’s smaller payload with reconciled: true is gone.
  • "*" covers every event except the high-volume ones that must be named: message.received, message.sent, message.status_updated, agent_action.recorded, audit.log_recorded, contact.created and contact.updated. contact.opted_out is now covered by "*".
  • The catalog lists every event planned for v2 (calls, conversations, messages, approvals, payments, knowledge, lessons, WhatsApp, integrations, phone numbers, billing). GET /api/webhooks/event-types returns the types available for subscription, each with a description and an example data; other types are rejected until they are marked available. As of 2026-09-28, every catalog type is available.

Delivery

  • Retries: up to 12 attempts over approximately four days (30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h, 12 h, then 24 h three times). 408, 425, 429, 5xx, timeouts and network errors are retried, and a Retry-After that requests a longer delay is honored. Any other 4xx and every 3xx end the delivery. 410 Gone also disables the endpoint.
  • Health: 3 consecutive failed deliveries trigger an in-app warning; 20 consecutive failures spanning at least 24 hours disable the endpoint. PATCH no longer accepts is_active; use POST /api/webhooks/{webhookId}/enable and /disable.
  • Rotation overlap: after rotate-secret, the previous secret continues to sign for 24 hours, and webhook-signature carries both signatures during that period.

API

  • New: GET /api/webhooks/event-types, POST /api/webhooks/{webhookId}/test, POST /api/webhooks/{webhookId}/replay (stored events from the last 30 days; a range containing more than 1000 events is rejected), POST /api/webhooks/{webhookId}/enable and /disable, GET /api/webhooks/{webhookId}/deliveries (cursor-paginated, filter by status and type), GET /api/webhooks/deliveries/{deliveryId} (every attempt, the event envelope sent and the request headers) and POST /api/webhooks/deliveries/{deliveryId}/retry.
  • GET /api/webhooks/{webhookId}/delivery-logs remains available as an alias of /deliveries, with the new response shape.
  • The webhook object changed: secret, payload_template, delivery_logs, failure_count, last_triggered_at and last_error were replaced by disabled_reason, disabled_at, consecutive_failures, last_delivery_status, last_delivery_at and previous_secret_valid_until. Members who are not admins see the URL without its path and query.
  • filters keys are snake_case (campaign_ids, agent_ids, outcomes, min_sentiment, audit_actions, conditions); camelCase keys are rejected with 400. Filters saved before the change keep working.
  • URLs must use https. A workspace can have up to 20 endpoints. Changing a webhook’s url or headers, like rotating its secret, requires a signed-in session; API keys receive 403.
  • Validation errors return 400 VALIDATION_FAILED with a specific message, in Spanish, in error.message.
  • Events and deliveries are kept for 30 days.

Migration. Update your receiver to verify the new headers (or use a standardwebhooks library), read type and snake_case data, deduplicate on webhook-id, and handle contact.opted_out and agent_action.recorded under their new names. Then send yourself an event with POST /api/webhooks/{webhookId}/test. Zapier triggers did not change.

2026-09-16 — One definition of success, coverage beside every rate, and honest report names

Analytics changed what its rates divide by, and reports changed what they call the figures they already had. Review the migration notes if you chart any success rate, store the numbers you read, or consume successRate, successfulCalls or costPerSuccessfulCall from POST /api/reports.

no_effective_contact: a call that connected and never became a conversation

Someone picks up, hears half a word and hangs up. They have refused nothing and been served nothing, but post-call analysis still stamped an outcome on that call — usually rejected — and reporting took it at face value. The outcome donut showed a refusal for somebody who never heard the offer, and the same call sat in the success-rate denominator, dragging a real conversion rate down with calls nobody could have converted.

A call now has no effective contact when it ran shorter than 30 seconds or carried fewer than 8 transcript turns. It is an or, not an and: calls that run past 30 seconds with almost no turns are common — hold music, a voicemail greeting the agent talked over, a line left open — and they are the ones that look most like real calls in a table.

The bucket is disjoint from success and from rejection, and it shows up as:

FieldWhere
noEffectiveContactGET /api/analytics/overview, overview/by-direction, calls-per-day, calls-by-hour, agent-ranking, top-campaigns, campaigns/{id}, kpi/tenant-summary
effectiveCompletedThe same responses — the new denominator of every success rate
no_effective_contactA key inside outcomeBreakdown
no_effective_contact_callsThe analytics export, beside the new effective_completed_calls
suppressedGET /api/analytics/analysis/criteria

It applies to calls you have already read. The bucket is derived when a row is queried, not stored on the call, so nothing was backfilled and your entire history is reclassified at once. A chart you drew last week over the same window will not match the one you draw today, and there is no stored column holding the old numbers. successRate, conversionRate and the per-hour and per-campaign rates all go up for workspaces with many short calls; rejected and rejectionRate go down, because a hang-up is not a refusal. To reconstruct the old figures, fold noEffectiveContact back into whichever side of the fraction you were using — it is reported for exactly that reason.

no_effective_contact is not a call outcome. You will never see it in GET /api/calls, in a call.completed webhook, or in an export’s outcome filter; the call’s own outcome still says whatever the analysis decided. The outcomeBreakdown key covers every call carrying an outcome that fails the threshold, so it also absorbs slices that used to read no_answer or voicemail; the noEffectiveContact counter covers connected calls only. Calls with outcome: null are in neither.

Coverage travels with the rate

A rate hides its sample size: 25% over four calls and 25% over four thousand are the same two characters on a card. Analytics responses now report analysed (analysedCalls in kpi/tenant-summary) beside total / totalCalls / totalInteractions — how many rows an evaluation actually ran on. The analytics export gained analysed_calls for the same reason: an export is reconciled line by line, so a rate without its coverage is the one number in it nobody can check.

GET /api/analytics/analysis/criteria gained suppressed per criterion: verdicts left out of success / failure / unknown / total because the call had no effective contact. A two-second call cannot have resolved a query, and grading it failure fills the table with failures nobody could have avoided. Safety criteria — did the agent state something untrue, did it name the technology behind it — keep counting whatever the call’s length. suppressed: 140 with failure: 0 reads very differently from total: 140 with failure: 0.

Migration. Read analysed before you put a rate on a dashboard. Nothing is hidden and the figure is still returned, but a rate over 9 of 412 calls describes the analysed slice, not the window.

kpi/tenant-summary changed denominator, and the change is large

successRate was totalSuccessful / totalCalls — every row in the window, unanswered rings and failed dials included. It is now totalSuccessful / effectiveCompleted, the definition every other analytics surface uses. A workspace converting two thirds of the people it actually spoke to used to read around 12% on this card, and no other screen agreed with it. totalCalls is still reported as the volume.

Reports: connection rate, not success rate

Three report fields were renamed. No figure changed — the arithmetic is byte-for-byte what it was. successfulCalls was status = 'completed', the call connected; successRate was connected over dialled, the share of dials that were answered. Neither ever measured success, and a report headed “Tasa de éxito: 82%” that means “82% of our dials connected” is the kind of wrong a customer repeats to their own board.

ReportNew fieldOld field
roi_analysisconnectedCallssuccessfulCalls
roi_analysiscostPerConnectedCallcostPerSuccessfulCall
campaign_comparison, agent_performanceconnectionRatesuccessRate

The old names are still sent with the same values, so a client reading successRate today keeps working and reads exactly what it read yesterday.

The old names are kept for one release and are removed once the dashboard, the Zapier app and the MCP tools are on the new ones. There is no longer window and no separate sunset date. Switch on your next deploy.

Reports deliberately do not apply the no-effective-contact bucket: their denominator is dials and connections, which answers a different question — how much of your dialling reached a human at all. If what you wanted was a success rate, read successRate from GET /api/analytics/overview. See Counting and coverage for the whole contract and Report field renames for the table.

2026-09-15 — Neutral values, retired settings, escalations and workspace-local days

Changes to what your integration sends and receives. Review the migration notes if your code compares enum values, sends any of the four retired fields, branches on call outcome, charts support KPIs, or assumes a day starts at midnight UTC.

Neutral values in responses

Some enum values were implementation-specific. They now use stable, neutral names in API responses, webhooks and exports.

WhereValue now
Phone number providermanaged for a number bought through Jelliu. sip_trunk is unchanged.
Agent text_normalisation_type (textNormalisationType on create and update)engine, which is also the default. system_prompt is unchanged.
Call initiation_source and quality.sources[].source in GET /api/analytics/qualityphone for calls over the phone network. Other values, such as sip_trunk, whatsapp and js_sdk, are unchanged.
Agent action ledger tool_source (/api/agent-actions, agent_actions exports, agent.action_recorded webhook)integration or engine. mcp_gateway and internal are unchanged.
Agent action ledger result_ref prefixintegration: or engine: (for example integration:log:<id>)
Identity role mapping source (/api/identity/role-mappings)org_role. email_domain and idp_group are unchanged.
Contact metadata.source of a CRM-imported contactcrm_import

Migration.

  • Requests keep accepting the previous implementation-specific values for textNormalisationType and role-mapping source; they are stored as the neutral value and read back with it. Nothing you send today is rejected.
  • Validation errors list only the neutral values, for example Valor no válido. Valores permitidos: engine, system_prompt.
  • If your client compares these fields against hard-coded values, or parses result_ref prefixes, switch to the values in the table. Values are translated when they leave the API, so records created before this change also read with the neutral values.

Features that need an active subscription

A workspace without an active subscription (active, trialing and past_due count as active) is now refused the features that do work for it or send data out. The response is 403 BILLING_ERROR with metadata: { "tier": "none", "kind": "subscription_required" } and a Spanish message naming the feature, for example «Webhooks salientes» requiere una suscripción activa. Este espacio de trabajo no tiene un plan activo: elige uno en Ajustes → Plan para continuar. Tus datos siguen disponibles para consultar y exportar.

  • Outbound webhooks. Creating, updating and rotating the secret are refused. Deliveries are paused: skipped events are not retried, not logged and do not count toward automatic disabling, and they are not sent later. Listing, reading, delivery logs and deleting stay available.
  • Analytics. Reads under /api/analytics are refused, /api/analytics/analysis/* included. Event ingestion keeps working.
  • Reports. POST /api/reports and GET /api/reports/types are refused.
  • Exports. type: "analytics" is refused. Every other export type, and reading and downloading exports, stays available.
  • MCP servers. Creating, updating and assigning to an agent are refused; listing, reading, deleting and unassigning stay available. The MCP server answers 403 with JSON-RPC error -32003.
  • A/B tests and call retries. Every A/B test operation is refused, and so are campaign updates that set maxRetryAttempts above 0. (Objection handlers were on this list too; that gate was removed the same day — see Objection handlers reach the agent, and are no longer gated by plan.)

Migration. Branch on error.metadata.kind === "subscription_required" to send the user to choose a plan. Reading, exporting and deleting your own data is never refused. See Without an active subscription.

Contact events

  • contact.created is sent for every new contact, not only for contacts added through the API, imports, agents and CRM pushes. Contacts created when someone writes first on WhatsApp, web chat, Instagram, Messenger or email, when you message or call someone not saved yet, or by an automation are now announced too.
  • source has neutral values: api:manual, api:bulk, agent:tool, csv, crm_import, crm_webhook, whatsapp, webchat, social, email, outbound_message, outbound_call, automation and other. CSV and JSON bulk imports used to send import:csv and import:api_bulk. phoneNumber is null for contacts known only by email, chat or social handle. See Contact sources.
  • contact.dnc is back in the catalog, which now has 14 events. It is sent once when a contact opts out on any channel, with contactId, campaignId, name, phoneNumber, email, whatsappNumber, channel (voice, whatsapp, email or social), basis (request or suppression_list), callId (null outside voice) and optedOutAt. Like contact.created and audit.log_recorded, it is not included in "*": name it explicitly.
  • Zapier. The trigger for contact.dnc is now “Contact Opted Out”. It fires for every channel, once per opt-out, with the same fields as the webhook; it used to fire only for calls, possibly more than once. “New Contact” covers every source.

Migration. If you match source values, add the new ones and replace import:csv and import:api_bulk with csv and api:bulk. Expect more contact.created events if your workspace receives inbound conversations. A webhook created earlier with contact.dnc in its list starts receiving it.

Four settings retired: they are now a 400

Four fields were stored, returned and read by nothing. An agent, campaign, template or widget configured with them behaved exactly as if they were empty, which is worse than not offering them at all — the customer believed they had configured something. They are removed from the contract rather than quietly implemented, and sending one is refused instead of dropped, so whoever is still sending it finds out.

FieldWhereSend instead
escalationRulesPOST /api/agents, PATCH /api/agents/{agentId}The agent’s hand-over tool (needs_human / request_help), which is the escalation path that actually runs
skillsPOST /api/agents, PATCH /api/agents/{agentId}department, which inbound routing really uses
retry_policyPOST /api/onboarding/templatesmaxRetryAttempts and retryIntervalMinutes on the campaign
branding.logo_urlPOST /api/widgets, PUT /api/widgets/{id}Nothing — the widget draws no logo

Any present value counts, null and [] included. The response is 400 VALIDATION_FAILED with the reason in error.details.fieldErrors:

{
"error": {
"code": "VALIDATION_FAILED",
"message": "Invalid update input",
"details": {
"formErrors": [],
"fieldErrors": {
"escalationRules": ["El campo escalationRules ya no existe. Usa la herramienta de traspaso a un humano (needs_human) en su lugar."]
}
}
}
}

They are also no longer returned. escalation_rules and skills are gone from agent create, retrieve, list and update responses and from campaign reads; branding.logo_url is stripped from widget admin reads, POST /widget/init and the embed script. Campaign responses also stopped carrying retry_policy, voicemail_action and voicemail_message, three legacy columns that never had a write surface at all (voicemail detection is an agent setting, enableVoicemailDetection).

Migration.

  • Stop sending these fields. That is the whole change for most clients.
  • If you read-modify-write — fetch a resource, change one key, send the object back — refresh your cached copy before the next write. A copy fetched before this deploy still carries the retired keys and will now be refused. Responses no longer contain them, so a fresh read is enough.
  • Nothing you stored was destroyed: the database columns are kept, they are simply not part of the API any more.
  • escalationRules also left the agent change-approval flow. It is no longer a behaviour field, so a change request can never carry it.

Objection handlers reach the agent, and are no longer gated by plan

objectionHandlers was stored, versioned, returned by the API and gated by plan — and nothing that builds an agent’s instructions read it. Both halves of that are fixed.

They are applied to conversations. Every path that assembles a prompt — create, the standalone fallback, a re-derive after an edit, a rollback to an earlier version — renders your answers into the client section, which overrides the generic objective playbook where the two differ. The render does not look at the agent’s category, so the answers reach an agent of any objective.

New limits, and a body over them is refused, not trimmed. At most 5 entries; an objection is 1 to 100 characters and an answer 1 to 200. Empty objections and empty answers are refused too. The instructions are read on every turn of every conversation, which is what the limits are protecting. The previously published limits — 50 entries and 2,000-character answers — are gone. Anything larger now answers 400 VALIDATION_FAILED:

{
"error": {
"code": "VALIDATION_FAILED",
"message": "Invalid agent input",
"details": {
"formErrors": [],
"fieldErrors": {
"objectionHandlers": ["Máximo 5 objeciones"]
}
}
}
}

Nothing is silently cut, so you are never told your text was saved when half of it was dropped.

The plan check is gone. Saving a non-empty objectionHandlers used to need an active subscription. Every paid tier already allowed it, so in practice the gate only ever caught workspaces with no subscription — and what it blocked was a customer supplying their own answers instead of ours, which is the same capability at the same cost. Every plan can save its own objection answers.

What it does not do. The objection method itself ships only with the sales objective’s playbook; no other objective, retention included, has one. objectionHandlers supplies the wording, not a method, so on a sales agent it refines what is already there and on any other agent it is that agent’s only objection guidance. Answers are still fraud-screened, still versioned, and anything longer than five short answers still belongs in systemPrompt.

Migration. If you store more than 5 objections, or answers longer than 200 characters, trim them before your next write — the save is refused otherwise. Rows written before the caps are returned as they were stored; only their first 5 entries, truncated to the caps, are rendered into the prompt.

The features.objectionHandlers and features.escalationRules flags are still present in GET /api/billing/limits, but neither gates anything any more. Ignore them.

Escalation is recorded when the agent hands the call over

escalated_to_human used to be written only when an admin completed a warm transfer through POST /api/calls/{callId}/transfer/complete — a path almost nobody uses. The far more common case, the agent transferring the caller to a person during the call, left no trace anywhere: no outcome, no event, no KPI. It does now.

  • The call’s outcome becomes escalated_to_human, unless the analysis already decided something more specific (sale_closed, issue_resolved, rejected, voicemail, …), which is left untouched. A transfer is the outcome of the AI’s part of the call, not of the call.
  • The call.escalated Zapier trigger fires with { callId, phoneNumber, transferredTo: null, outcome: "escalated_to_human", escalatedBy: "agent" }. transferredTo is null because this path never learns which number was dialled and will not invent one.
  • On the admin path the payload is unchanged: transferredTo carries the destination number and escalatedBy is absent. Treat an absent escalatedBy as the admin path.
  • Routing a conversation to another agent of your workspace is deliberately not an escalation: no person picked up, so no outcome is written and no event fires.
  • It is recorded exactly once per call, whether the post-call report arrives normally or is recovered later by background reconciliation.

Migration. Expect outcome: "escalated_to_human" on ordinary calls, and many more call.escalated runs. A Zap that filters on escalatedBy equals admin will match nothing — filter on escalatedBy equals agent, or treat missing as admin. See Escalation.

Support KPIs: escalated_count counts escalations, unresolved_count is new

The support category went from five KPIs to six.

KeyLabelCounts
escalated_countEscalados a una personaOutcome escalated_to_human
unresolved_countNo resueltosOutcome rejected

escalated_count used to count rejected as well, and its label said so (No resueltos / escalados). That was a workaround for an outcome nothing wrote; now that escalations have real producers the two numbers are separated and each is named for what it is.

Migration. escalated_count will drop at the cut-over: the rejected half moved to unresolved_count. Add the new key, and sum the two for a like-for-like comparison with history. They are disjoint — a call has one outcome, so none feeds both.

Transfers appear in the agent action ledger

agent.action_recorded, GET /api/agent-actions and the agent_actions export now carry the agent’s in-call hand-offs: tool_name transfer_to_number (handing the caller to a person) and transfer_to_agent (routing to another agent), both with tool_source: "engine" and action_kind: "write" — a transfer changes the world and cannot be undone. Before this, only connected-app and webhook tool calls reached the ledger, so it could not answer “the agent gave the call to someone else”. The voice engine’s other built-ins (ending the call, detecting the language, skipping a turn) stay out: they are machinery, not actions.

Migration. Expect more rows and more agent.action_recorded events per call.

Days start at midnight in your workspace’s timezone

Everything dated in Jelliu used to break at midnight UTC. For a workspace at UTC-5 that falls at 19:00 local, so the last five hours of every working day were filed under tomorrow: the evening’s calls left “today”, the daily send allowance reset in the middle of the afternoon, and a report’s busiest day was smeared across two rows. Day boundaries now follow the workspace timezone in four places:

WhereWhat changed
KPI rows and *_today KPIs (revenue_today), and the daily series from GET /api/analytics/kpi/{campaignId}/timeseriesThe day a call’s KPIs are filed under, and what “today” means
A bare YYYY-MM-DD in POST /api/reports date_from / date_to, the day buckets inside a report (cost_per_call), and the date column of an analytics exportdate_from starts at 00:00 local and date_to now ends at the last instant of that day local, so the day is included — it used to stop at midnight UTC and leave the day out
Daily send quotas for WhatsApp, email, Instagram and MessengerThe allowance resets at local midnight
The dashboard’s live monitor”Today” on the supervisor board

A workspace with no timezone set, or one whose stored value is not a valid IANA id, falls back to America/Bogota. It never fails: a wrong day boundary is a reporting defect, one that throws takes the dashboard down.

Forward only, and not backfilled. Rows written before 2026-09-15 stay on the UTC day they were filed under. A range that spans that date therefore contains both kinds, and older rows can sit one day off for workspaces west of UTC. Nothing was rewritten.

Not affected: the explicit timezone / tz query parameters on the analytics and dashboard endpoints (they still default to UTC and still mean what you pass), the widget’s daily visitor-message cap, the WhatsApp template test-send counters, and the weekly AI budget window — all still UTC. Campaign calling hours are unchanged: they already used schedule.timezone.

Prior opt-in is enforced when you ask for it

Setting require_opt_in to true on your workspace’s compliance configuration for a country now means something. A call to a contact in that country is refused unless the contact has a live voice_call consent record (status: "granted", and either no expires_at or one still in the future). The refusal is 403 COMPLIANCE_BLOCKED with code: "no_opt_in" and the message Contact has not granted opt-in consent for voice calls.

Three limits on the blast radius, all deliberate:

  • Not enforced unless you enabled it. The published country defaults say require_opt_in: true for Colombia and Brazil. Those describe those countries’ telemarketing law — they are guidance, and enforcing them on their own would have stopped every Colombian and Brazilian campaign at once, because no consent record exists for contacts loaded before this. Only the value on your own saved configuration is read. No configuration, or one still carrying the false default, enforces nothing.
  • A call with no contact id is not blocked. The consent ledger is keyed by contact, so a manual dial that names no contact is a question with no answer rather than a negative one. It is allowed through and logged.
  • In a campaign the contact is marked invalid and the campaign carries on. Not dnc — nobody opted out, and granting consent later must make the row dialable again. Not failed — no call was attempted, and failed feeds the failure KPIs and the retry budget.

POST /api/compliance/check-call reports it the same way, and its response now documents the stable code alongside the human reason: quiet_hours, blocked_prefix, opt_out, dnc, no_opt_in, daily_attempt_cap, total_attempt_cap. Branch on code. See Prior opt-in.

New: GET /api/calls/concurrency

Reads how many voice calls your workspace has up right now and the ceiling the next call is refused at:

{ "data": { "active": 2, "max": 5 } }

active comes from the live counter the admission gate itself reads, so this number and the one deciding whether your next POST /api/calls connects can never disagree. max is the effective ceiling — the lower of the platform ceiling and your plan’s concurrency limit — because that is the value a call is actually refused at with 429 MAX_CONCURRENT_CALLS_REACHED.

read scope, tenant-scoped from your credential, available on every plan: it is deliberately not behind the live-monitor feature, because every workspace is entitled to see its own capacity. GET /health never carried this — it is a platform probe with no workspace, so anything reading an activeCalls field from it was reading a number that did not exist.

Widget rate_limit_rpm is settable from the dashboard

Nothing changed in the API, but the field is now editable in the dashboard, so it is worth stating plainly: rate_limit_rpm is an integer of 1 or more, defaults to 30, and is accepted on POST /api/widgets and PUT /api/widgets/{id}.

Raising it does not lift the per-workspace cap of 300 requests per minute across all of a workspace’s widgets, which still applies on top — ten widgets at 300 rpm each are still capped at 300 rpm in total. The per-IP limit of 60 requests per minute is unchanged.

2026-09-15 — Contract fixes

These changes correct behavior that did not match the documentation, or that answered a client mistake as a server error. Some change what your integration receives; review the ones that apply to you.

Webhooks

  • Every event in the catalog is delivered. campaign.started, campaign.paused, contact.created, contact.converted, agent.created and agent.updated are now sent, joining call.completed, call.failed, campaign.completed, crm_sync.completed, crm_sync.failed, agent.action_recorded and audit.log_recorded. See Payload for their fields.
  • 11 events were retired because they were never sent: call.started, call.recording_ready, call.transcript_ready, contact.updated, contact.status_changed, contact.dnc, usage.threshold_reached, subscription.changed, integration.sync_completed, integration.sync_failed, integration.error. Creating a webhook that names one returns 400 VALIDATION_FAILED. Existing webhooks keep working, but a retired name cannot be added to a webhook that did not have it. See Retired events. contact.dnc returned to the catalog later the same day; see the entry above.
  • "*" does not include contact.created, which sends one event per contact, including every row of an import. Name it explicitly, as with audit.log_recorded (and contact.dnc, see the entry above).
  • New X-Webhook-Id header. A UUID per delivery, identical on every retry. Use it as your idempotency key instead of the signature. Delivery logs carry the same value as delivery_id.
  • sentimentScore is null when a call was not scored, instead of 0. A minSentiment filter does not deliver unscored calls.
  • Custom header values are masked. Webhook responses show each header value as "[configured]". Sending "[configured]" back in a PATCH keeps the stored value.

Contacts

  • CSV import completes local numbers with the workspace country, or rejects the row with a Spanish message. Numbers are no longer turned into a different country’s number by adding a +. See Normalization.
  • upload-confirm reports partial imports. It returns 200 when every valid row was written and 207 with data.partial: true when some chunks failed, and adds failed and total to the body. When every chunk fails, the request fails with that error, for example 404 CAMPAIGN_NOT_FOUND. Row numbers in errors are the real file rows.
  • The 5 requests per minute import limit now applies only to /bulk, /upload-preview and /upload-confirm. Listing contacts, adding one and downloading the template use the general limit.

Errors

  • New 413 PAYLOAD_TOO_LARGE for bodies over 1 MB and uploads over a route’s limit, for example a knowledge document over 20 MB.
  • Client input is no longer a 500. A malformed ID, a number out of range, a date that cannot be parsed, or a JSON body that does not parse returns 400 VALIDATION_FAILED. A knowledge upload in the wrong multipart field returns 400 too.

Conversations

  • GET /api/conversations/{conversationId} returns the most recent messages, oldest first, instead of the first 50. Page back with before (a message ID) and limit (1 to 200), using meta.messages.hasMore and meta.messages.nextBefore. See Message cursor.

Calls and campaigns

  • Answered outbound calls move from ringing to in-progress, usually within a minute of being answered.
  • Compliance refusals in campaigns keep the right status. Calling hours and the daily attempt limit reschedule the dial; the total attempt limit marks the contact failed; a blocked prefix or refused destination marks it invalid; only an opt-out or do-not-call marks it dnc.
  • The campaign schedule applies to WhatsApp and email sends, not only to calls.
  • Drafts can be created at the plan’s active-campaign limit. The limit is enforced when you activate.
  • List cursors are opaque. nextCursor from GET /api/calls, and the new meta.nextCursor from GET /api/agents, encode the position of the last row, so rows created at the same instant are no longer skipped between pages. Pass the value back unchanged. Legacy timestamp cursors are still accepted with their old meaning; a malformed cursor returns 400 VALIDATION_FAILED. See Cursor.

Security

  • Integrations no longer return token material. Retrieve, create and update omit OAuth tokens and the inbound webhook secret, and return hasCredentials and hasWebhookSecret.
  • WhatsApp senders never return verification_code.
  • Widgets no longer return api_key. The key is returned once, as plaintext_api_key, when the widget is created. Creating or updating a widget with an agent from another workspace returns 404 AGENT_NOT_FOUND.
  • MCP servers whose hostname resolves to a private address are refused with 400 VALIDATION_FAILED instead of 500.
  • Creating an API key no longer returns the key’s stored hash.

Analytics

  • /api/analytics/analysis/* has the same access rules as the rest of analytics. GET /api/analytics/analysis/criteria and /data-collection now need the owner, admin, member or viewer role and an active subscription (403 BILLING_ERROR otherwise), and share the 30 requests per minute analytics budget.
  • GET /api/analytics/overview/by-direction applies campaignId and agentId, also in the overviewByDirection section of page-load. A malformed query returns 400 VALIDATION_FAILED instead of silently widening to an unfiltered 90-day window.
  • The event filter of GET /api/analytics/events accepts spaces, so events named like Overview Viewed can be queried.

Rate limits

  • The 20 per minute outbound limit applies only to POST /api/calls, POST /api/calls/{callId}/transfer, POST /api/email/send and /api/whatsapp. Listing calls, reading a call, downloading audio, hanging up and reading or saving email settings use the general limit, so browsing call history no longer blocks the next call.
  • No per-IP limit in front of /api/*. Requests are no longer also counted against one 120 per minute budget per client IP before authentication. The MCP endpoint keeps its own 120 per minute per IP.
  • Each limiter counts a request once. Webhook receivers, A/B test, agent sub-resource, KPI and event routes were counted two or more times against the same budget.

Zapier and MCP

  • Zapier trigger samples have the live event shape, { id, event, timestamp, data } with the same camelCase data fields, instead of database rows. See Sample data.
  • Admin-only MCP tools are listed only to full keys: campaign create, update, activate, pause and delete, agent deletion, billing usage and plan limits, and the audit log. A read or write key no longer sees tools that could only answer 403. See Scopes and tools.
  • Some MCP tool inputs match their routes. The agent list takes the returned meta.nextCursor, the by-direction and top-campaigns analytics tools offer only the filters those routes apply, timezone is capped at 50 characters, and the campaign contacts filter lists the valid statuses.

Reports and knowledge

  • POST /api/reports accepts only format: "json". Any other value, including pdf, returns 400 VALIDATION_FAILED.
  • period is optional in POST /api/reports. It never changed the result; omitting it returns the same report.
  • GET /api/agents/{agentId}/knowledge/jobs/{jobId} is now in the API reference. It returns the status of an upload queued with ?async=true. Jobs are kept for one hour.

2026-09 — Developer portal launched

The Jelliu developer portal is live at developer.jelliu.co, with guides for authentication, errors, rate limits, pagination, webhooks, the MCP server and the web chat widget, alongside the API reference.