Changelog
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) andwebhook-signature(v1,<base64>HMAC-SHA256 of<webhook-id>.<webhook-timestamp>.<raw body>). The officialstandardwebhookslibraries 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 arewhsec_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 sendsx-jelliu-event-type,x-jelliu-delivery-idandx-jelliu-attempt. - New body.
{ "id", "type", "api_version", "created_at", "tenant_id", "data" }replaces{ "event", "timestamp", "data" }.id(evt_...) equalswebhook-idand is identical across attempts, manual retries and replays of the event; use it for deduplication.api_versionis2026-09-27. datakeys are snake_case in every event:call_id,sentiment_score,data_collection,kpi_data,campaign_name,changed_fields, and so on.payload_templatewas removed. Every delivery has the standard body, so the signature always covers a known shape.
Events
- Renamed:
contact.dncis nowcontact.opted_out, andagent.action_recordedis nowagent_action.recorded. Existing subscriptions to the old names were migrated automatically. - Activating a
pausedcampaign now sendscampaign.resumedinstead ofcampaign.started. call.failedcarriesreason(no_answer,busy,failed,canceled,voicemailorerror), and a call sendscall.completedorcall.failed, never both. The reconciler’s smaller payload withreconciled: trueis 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.createdandcontact.updated.contact.opted_outis 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-typesreturns the types available for subscription, each with a description and an exampledata; 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 aRetry-Afterthat requests a longer delay is honored. Any other4xxand every3xxend the delivery.410 Gonealso disables the endpoint. - Health: 3 consecutive failed deliveries trigger an in-app warning; 20 consecutive failures spanning at least 24 hours disable the endpoint.
PATCHno longer acceptsis_active; usePOST /api/webhooks/{webhookId}/enableand/disable. - Rotation overlap: after
rotate-secret, the previous secret continues to sign for 24 hours, andwebhook-signaturecarries 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}/enableand/disable,GET /api/webhooks/{webhookId}/deliveries(cursor-paginated, filter bystatusandtype),GET /api/webhooks/deliveries/{deliveryId}(every attempt, the event envelope sent and the request headers) andPOST /api/webhooks/deliveries/{deliveryId}/retry. GET /api/webhooks/{webhookId}/delivery-logsremains available as an alias of/deliveries, with the new response shape.- The webhook object changed:
secret,payload_template,delivery_logs,failure_count,last_triggered_atandlast_errorwere replaced bydisabled_reason,disabled_at,consecutive_failures,last_delivery_status,last_delivery_atandprevious_secret_valid_until. Members who are not admins see the URL without its path and query. filterskeys are snake_case (campaign_ids,agent_ids,outcomes,min_sentiment,audit_actions,conditions); camelCase keys are rejected with400. Filters saved before the change keep working.- URLs must use
https. A workspace can have up to 20 endpoints. Changing a webhook’surlorheaders, like rotating its secret, requires a signed-in session; API keys receive403. - Validation errors return
400 VALIDATION_FAILEDwith a specific message, in Spanish, inerror.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:
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.
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.
Migration.
- Requests keep accepting the previous implementation-specific values for
textNormalisationTypeand role-mappingsource; 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_refprefixes, 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/analyticsare refused,/api/analytics/analysis/*included. Event ingestion keeps working. - Reports.
POST /api/reportsandGET /api/reports/typesare 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
403with JSON-RPC error-32003. - A/B tests and call retries. Every A/B test operation is refused, and so are campaign updates that set
maxRetryAttemptsabove0. (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.createdis 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.sourcehas neutral values:api:manual,api:bulk,agent:tool,csv,crm_import,crm_webhook,whatsapp,webchat,social,email,outbound_message,outbound_call,automationandother. CSV and JSON bulk imports used to sendimport:csvandimport:api_bulk.phoneNumberisnullfor contacts known only by email, chat or social handle. See Contact sources.contact.dncis back in the catalog, which now has 14 events. It is sent once when a contact opts out on any channel, withcontactId,campaignId,name,phoneNumber,email,whatsappNumber,channel(voice,whatsapp,emailorsocial),basis(requestorsuppression_list),callId(nulloutside voice) andoptedOutAt. Likecontact.createdandaudit.log_recorded, it is not included in"*": name it explicitly.- Zapier. The trigger for
contact.dncis 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.
Any present value counts, null and [] included. The response is 400 VALIDATION_FAILED with the reason in error.details.fieldErrors:
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.
escalationRulesalso 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:
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
outcomebecomesescalated_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.escalatedZapier trigger fires with{ callId, phoneNumber, transferredTo: null, outcome: "escalated_to_human", escalatedBy: "agent" }.transferredToisnullbecause this path never learns which number was dialled and will not invent one. - On the admin path the payload is unchanged:
transferredTocarries the destination number andescalatedByis absent. Treat an absentescalatedByas 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.
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:
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: truefor 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 thefalsedefault, 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
invalidand the campaign carries on. Notdnc— nobody opted out, and granting consent later must make the row dialable again. Notfailed— no call was attempted, andfailedfeeds 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:
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.createdandagent.updatedare now sent, joiningcall.completed,call.failed,campaign.completed,crm_sync.completed,crm_sync.failed,agent.action_recordedandaudit.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 returns400 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.dncreturned to the catalog later the same day; see the entry above. "*"does not includecontact.created, which sends one event per contact, including every row of an import. Name it explicitly, as withaudit.log_recorded(andcontact.dnc, see the entry above).- New
X-Webhook-Idheader. A UUID per delivery, identical on every retry. Use it as your idempotency key instead of the signature. Delivery logs carry the same value asdelivery_id. sentimentScoreisnullwhen a call was not scored, instead of0. AminSentimentfilter does not deliver unscored calls.- Custom header values are masked. Webhook responses show each header value as
"[configured]". Sending"[configured]"back in aPATCHkeeps 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-confirmreports partial imports. It returns200when every valid row was written and207withdata.partial: truewhen some chunks failed, and addsfailedandtotalto the body. When every chunk fails, the request fails with that error, for example404 CAMPAIGN_NOT_FOUND. Row numbers inerrorsare the real file rows.- The 5 requests per minute import limit now applies only to
/bulk,/upload-previewand/upload-confirm. Listing contacts, adding one and downloading the template use the general limit.
Errors
- New
413 PAYLOAD_TOO_LARGEfor 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 returns400 VALIDATION_FAILED. A knowledge upload in the wrong multipart field returns400too.
Conversations
GET /api/conversations/{conversationId}returns the most recent messages, oldest first, instead of the first 50. Page back withbefore(a message ID) andlimit(1 to 200), usingmeta.messages.hasMoreandmeta.messages.nextBefore. See Message cursor.
Calls and campaigns
- Answered outbound calls move from
ringingtoin-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 itinvalid; only an opt-out or do-not-call marks itdnc. - 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.
nextCursorfromGET /api/calls, and the newmeta.nextCursorfromGET /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 returns400 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
hasCredentialsandhasWebhookSecret. - WhatsApp senders never return
verification_code. - Widgets no longer return
api_key. The key is returned once, asplaintext_api_key, when the widget is created. Creating or updating a widget with an agent from another workspace returns404 AGENT_NOT_FOUND. - MCP servers whose hostname resolves to a private address are refused with
400 VALIDATION_FAILEDinstead of500. - 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/criteriaand/data-collectionnow need the owner, admin, member or viewer role and an active subscription (403 BILLING_ERRORotherwise), and share the 30 requests per minute analytics budget.GET /api/analytics/overview/by-directionappliescampaignIdandagentId, also in theoverviewByDirectionsection ofpage-load. A malformed query returns400 VALIDATION_FAILEDinstead of silently widening to an unfiltered 90-day window.- The
eventfilter ofGET /api/analytics/eventsaccepts spaces, so events named likeOverview Viewedcan be queried.
Rate limits
- The 20 per minute outbound limit applies only to
POST /api/calls,POST /api/calls/{callId}/transfer,POST /api/email/sendand/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 camelCasedatafields, instead of database rows. See Sample data. - Admin-only MCP tools are listed only to
fullkeys: campaign create, update, activate, pause and delete, agent deletion, billing usage and plan limits, and the audit log. Areadorwritekey no longer sees tools that could only answer403. 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,timezoneis capped at 50 characters, and the campaign contacts filter lists the valid statuses.
Reports and knowledge
POST /api/reportsaccepts onlyformat: "json". Any other value, includingpdf, returns400 VALIDATION_FAILED.periodis optional inPOST /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.

