Changelog
Every change to the API, newest first, with who needs to act.
/v1 stays one path. A change that could break an integration ships only in a new dated contract; each key is pinned to the contract current when it was made (GET /me → apiKey.apiVersion), a request may send Zentiq-Version: <date> to use its pinned contract or a newer one, and every answer carries Zentiq-Version. Privacy and security fixes are not versioned: they apply to every contract the day they ship. See Versions and Moving to 2026-10-01.
2026-10-01#
Slack and Zapier connections, and the interview style (15 October 2026)#
Two new connection kinds, slack and zapier: send the webhook link as url on POST /v1/connections. Each gives one ready-made action that asks the visitor first: telling the team in a Slack channel, or sending the visitor's details and request to a Zap. A screening role's content can also set screening.style: "structured" (the default: numbered questions and the same thank-you after each answer) or "natural" (no question numbers; the interviewer says back a fact or follows up). Both ask the same questions in the same order and score them the same way. Who acts: integrations that read a connection's kind should accept the two new values; nothing else changes.
A third camera framing: full body (8 October 2026)#
display_mode on a persona can now also be full_body: the person from head to hips with their hands in view, beside half_body (waist up, the default) and presenter (chest up). Single avatars only; an animated character is shown half body. Who acts: nobody. Integrations that read display_mode should accept the new value.
A request's contact details, later in the conversation (4 October 2026)#
New webhook event handoff.updated: a website visitor's request for a person gained their contact details after handoff.requested was sent (the visitor asked for a callback first, then agreed and said how to reach them). It carries the same fields and follows the same privacy rules as handoff.requested, with the same reference, so you can match it to the request you already have. Also: a document with no text in it (an empty file, a scanned page saved as a PDF) now fails with errorCode empty instead of reaching ready with nothing to answer from. Who acts: integrations that store requests from handoff.requested can subscribe to handoff.updated to keep the phone number or email; nothing breaks for those that don't.
Portals (30 September 2026)#
What you build from templates is now called a portal, everywhere: /v1/dashboards is /v1/portals (with every path under it), the scopes are portals:read, portals:write and portals:publish, ids start portal_, the events are portal.created and portal.updated, and fields are portalId, portalIds and portalName (a session's source is portal, usage's bySource.portal). The SDKs follow (listPortals, createPortal, <ZentiqPortal>), and so do the tools AI assistants call (list_portals, create_portal). Keys that hold the old scope names keep what they held, and dash_ ids are still accepted. Renamed operations: listDashboards → listPortals, createDashboard → createPortal, listDashboardTemplates → listPortalTemplates, getDashboard → getPortal, updateDashboard → updatePortal, getDashboardPersona → getPortalPersona, updateDashboardPersona → updatePortalPersona, publishDashboard → publishPortal, getDashboardGoLive → getPortalGoLive, listDashboardDoors → listPortalDoors; getUsage reports bySource.portal. Who acts: nobody yet; no integration was using these names.
Breaking, for keys on 2026-10-01 (older keys keep 2026-07-08 until they move)#
- B1: plan refusals are in the error envelope. A
403 upgrade_requiredis{ "error": { "code": "upgrade_required", "message", "details": { "feature", "plan", "limit"? } } }instead of{ "error": "upgrade_required", "feature", "plan", "message", "limit"? }. Who acts: code that readserror === "upgrade_required"at the top level. The SDKs read both shapes. - B6: three scopes split out.
transcripts:read(conversation words; was part ofsessions:read),embed:write(embed tokens) andportals:publish(publishing, opening doors, switching a widget on; both were part ofportals:write); alsocontent:*(subject content),duos:*andwidgets:*(were part ofpersonas:*) andevents:read(was part ofwebhooks:manage). A key made before 2026-10-01 keeps the scopes it implied, forever, unless its owner edits it. Who acts: new keys that need these must list them. - New scopes are opt-in for full-access keys. A full-access key made before 2026-10-01 doesn't reach
candidates:*,candidates:data,people:*,consents:*orinbox:*until an owner adds them to it; a new full-access key reaches everything exceptcandidates:data,candidates:interviews,cases:writeandconsents:*. Who acts: owners who want an old key to reach people, candidates or the inbox. too_large(413) is its own error code; keys on 2026-07-08 keepinvalid_requestwith the same 413.- B5: webhooks on the 2026-10-01 contract. A webhook's deliveries (and
GET /v1/events) are written in the contract of the key that made it (PATCH /webhooks/{id} { apiVersion }moves it): the envelope addsapiVersionandorganizationId(org_…); every id indatacarries its prefix (sess_,sub_for a subject,portal_,usr_,unit_,evt_,cand_,inv_, …; a certificate's number, a question set's own id and a conversation's reference stay as they are); onlead.captured,booking.requestedandhandoff.requestedthe visitor's details arecontact(camelCasepreferredTime), their own words (need,budget,timeline) sit beside it,leadScoreisleadBandandinboxItemId(inb_…) names the item; onlead.updatedandbooking.updated,byis{ type, id }. Who acts: receivers of webhooks made with a 2026-10-01 key. A delivery's own id inGET /webhooks/deliveriesiswhd_…. - B2: portal and subject ids are prefixed. Every portal id is
portal_…and every subject idsub_…in the answers oflistPortals(itsnextCursortoo),createPortal,getPortal,updatePortalandpublishPortal, and in the new subject and door operations. Every contract accepts both the prefixed and the bare id on input, so ids stored before keep working. Who acts: code that compares ids it stored with ids it reads. - B3: a refused publish is an error.
publishPortalanswers422 go_live_refusedwithdetails.problems(one entry per thing to finish:subjectId,subjectName,field,message,short) anddetails.warnings, instead of200with the portal unchanged andpublishRefused.createPortalwithpublish: truestill answers201withpublishRefusedfor every contract (the portal was made). Who acts: code that looks forpublishRefusedafter a publish. sessionTranscript: a refused transcript names why in its code:candidate_interview(any job interview) orlearner_conversation(a young learner's conversation), still403. Keys on 2026-07-08 keepforbidden. Who acts: code that readsforbiddenthere.getSession,sessionAnalytics: the summary text goes only to a key holdingtranscripts:read(a key made before 2026-10-01 holds it throughsessions:read). Who acts: new keys that read summaries listtranscripts:read.- B8: inputs are read exactly as documented. On 2026-10-01,
nullon a field the reference doesn't mark nullable is400 invalid_requestnaming it (param), where 2026-07-08 reads it as not sent:createEmbedToken(name,email,unitName,gradeBand,role),createPortal(personaId,loginMethods,publish),updatePortal(name,personaId),updatePortalPersona(its text fields),createKey(name) and every other operation. An empty query parameter (?cursor=,?status=,?source=) is400like any other malformed value. OncreateEmbedToken,roleis exactlylearner,leaderorobserver, and anameorunitNameover 80, agradeBandover 8, anemailover 200 or anexternalUserIdover 120 characters is400. OnsessionSay,interrupt: nullmeans not sent (sotrue). Keys on 2026-07-08 keep v1.0's reading of all of these:nulland an empty parameter are not sent,roleis read in any case with anything else a learner, over-long fields are cut (an over-longemailis left out), andinterrupt: nulldoesn't interrupt. Who acts: code moving to 2026-10-01 that sendsnullfor "no value", an empty cursor, or text longer than the documented limits. - B7: the voice decides a persona's engine. On
createPersonaandupdatePersona,settings.engineandsettings.globalVoiceare read-only outputs: a studio voice runs onglobal, your organisation's cloned voices onpremier, as in the console; choose a voice byvoiceId(GET /voices). Keys on 2026-07-08 may still send them: aglobalVoicethat names a studio voice picks that voice when novoiceIdis sent, andenginenever overrides the voice. Who acts: code that chose an engine withsettings.engineinstead of a voice.
Response times (every contract; an addition; off until an owner or admin switches on "Measure how fast the avatar answers")#
Session(andSessionDetail) addsresponseTimes:{ p50Ms, p90Ms, maxMs, turns }, how long the avatar took to start answering each turn of the session (the typical wait, nine in ten within, the slowest, and how many turns were timed), measured in the person's browser from when they stopped speaking or sent a typed line to the avatar's first sound. It isnullwhile the setting is off, for a session your own app shows (only Zentiq's own session pages time turns), for a builder's preview, and for a session with no turn that could be timed fairly.listSessionsandgetSessioncarry it; the answers ofsteerSession,endSessionanddeleteSessionalways saynull. Numbers only: never the conversation's words. Who acts: nobody; read it if you chart how fast your avatars answer.
Zero data retention (every contract; coming soon, switched on by Zentiq on request)#
- Zentiq's own platform part of zero data retention is built; zero retention with the AI and voice services that run a live conversation is being set up. When it's ready, Zentiq switches it on for an Enterprise organization on request, once Zentiq has confirmed what each service keeps.
- What it doesn't cover yet: the AI and voice services that run a live conversation, which may keep its words for a time under their own terms and logs, and Zentiq's database backups, which keep erased words until they roll over.
- While it is on, Zentiq's platform keeps no conversation's words after it ends, and the API and webhooks carry none:
sessionTranscriptanswers{ turns: [], kept: false }for every session of the organization (while it runs too), summaries arenullwherever they appear,GET /v1/eventsand every webhook delivery leave conversation words out, and notranscript.finalis sent. See What leaves Zentiq. createWebhookandupdateWebhookrefuseincludeConversation: truewhile it is on:409 conflictwithdetails.reason: "zero_retention". Switching the words off always works, and switching zero data retention on turns the option off on every webhook of the organization (a webhook that received onlytranscript.finalis switched off). Who acts: nobody, unless Zentiq switches it on for your organization.
The newer features by plan (every contract; additions only)#
- Case links, chat handover, the site guide, presenter mode and pages ready for AI assistants come with the Starter plan and up; Verified customer, connected systems and Shopify order emails with Plus and up; the US hiring rules with Pro and up. On a plan without one, a request its switch would refuse while off (
409 conflictwithdetails.reasoncase_links_offorswitched_off, or403 forbiddenfor connections) answers403 upgrade_requiredwithdetails.reason: "plan_required"anddetails.plan, the plan that includes it (keys on 2026-07-08:reasonbesidefeatureandplan). On the right plan nothing changes. Who acts: code that turns those refusals into "ask an admin to switch it on" can name the plan instead. - Actions never run without the one-time code they were set to need: while Verified customer is off, an action set to need it (and a connected system's tool that can delete or undo things, which always needs it) is refused and never reaches your system; once Verified customer has been on (then switched off, reset, or not on the plan any more), every action that needs the code is, the defaults too. The visitor is offered a person. Who acts: nobody.
Shopify order emails and Shopify's Dev Dashboard apps (every contract; additions only)#
createConnectionforshopifytakes a Dev Dashboard app'sclientIdandclientSecret(Shopify has made new apps there since 2026-01-01): Zentiq asks Shopify for the app's 24-hour access tokens itself (the client credentials grant, for a store in the app's own Shopify organisation). An app made in the Shopify admin before then still sendstoken, now with an optionalapiSecret(its API secret key), which only order emails need. Both secrets are write-only. A Shopify connection'sdetailsaddsapp:dev_dashboardoradmin_token. Who acts: nobody; new stores use the new fields.- Shopify order emails (off until an owner switches on "Put a personal case link in your Shopify order emails"): each order of a connected store gets a case link, which the store's own shipping and delivery emails print. These links are listed like any other (
listCaseLinks,getCaseLink) withsource: "api"; theircustomerIdisorder-<Shopify order id>and theirreferencethe order's name without "#". See Case links from your CRM or helpdesk.
From the 2026-09-27 release (every contract; additions only)#
- Marking what an avatar made (EU AI Act Article 50(2)).
aiGenerated: trueonSession(andSessionDetail),SessionAnalytics,Transcript,SessionToken,Avatar,DuoandLibraryDuo; eachTranscriptTurnsaysaiGenerated: truefor the avatar's words andfalsefor the person's. A webhook delivery (and aGET /v1/eventsitem) saysaiGenerated: truewhen itsdatacarries an avatar's words (a summary, a report, an avatar'stranscript.finalturn); the privacy rules for whatdataholds are unchanged. Who acts: nobody; keep the marker with what you store. See Telling people it's an AI. createSessionToken:disclosureaddslabel(the short label for the video, in the session's language, or the avatar's own wording),language,display("whole_session"),ruleandownLabel(true only where an owner has confirmed in the console that your app shows its own AI label).textis now in the session's language when the avatar has no wording of its own. Who acts: apps that draw their own screen keeplabelvisible for the whole session; the browser and React SDKs draw it for you.- Every session is told never to claim to be a person and to say it's an AI whenever asked, whatever prompt it runs. Whether its first line also says so follows the new setting "Say it's an AI at the start" (Automatic: yes for under-18s, job interviews and health, finance, insurance or complaints services). For API sessions it follows your organisation's setting;
overrides.greetingis spoken as written unless the setting is on. - AI apps connected to Zentiq: a result about something an avatar made carries
_meta["com.zentiqai/aiGenerated"]and a plain line saying so;create_session_token,create_embed_tokenand the door tools say the AI label is required.
From the 2026-09-26 release (every contract; compliance fixes are not versioned)#
- The AI disclosure is always on (EU AI Act Article 50(1)). A persona's
settings.ai_disclosureis alwaystrue:createPersonaandupdatePersonastill accept it, ignorefalse, and read backtrue, and afalsestored before reads astrue.settings.ai_disclosure_textmay change only to wording that still says plainly that it's an AI (AI, or "artificial intelligence" in the avatar's language): other wording is400 invalid_request(param: body.settings.ai_disclosure_text), and stored wording that doesn't is read back as""(the default, "You're speaking with an AI avatar.").createSessionToken'sdisclosure.requiredis alwaystrue. Who acts: apps that hid the disclosure whenrequiredwas false; nobody else. createSessionToken: when the consent record for the persona's own face or voice can't be checked just now, nothing starts:503 consent_unverified(new error code, retryable). A withdrawn consent is still409 consent_withdrawn.- New scope
customers:tokens(Actions: identity tokens for your website's signed-in customers, and nothing else). A full-access key holds it; no other scope implies it. createCustomerToken(POST /portals/{id}/customer-tokens,customers:tokens; shipped 2026-09-26 on every contract) answers as every operation does:{ customerId, name?, email?, authTime?, ttlSeconds? }gives201 { token, expiresAt }for a portal of the key's organisation (portal_…or the bare id; another organisation's is404, an archived one410), 10 minutes unless asked and never more than 15. A field of the wrong type, or one the token's rules refuse, is400 invalid_requestnaming it inparam; the answer carries the usual headers (X-Request-Id,Zentiq-Version,RateLimit-*) and it is limited as reads are (600 a minute per key). It is never offered to an assistant through/mcp.createCustomerToken(security fix, every contract): a token works for one conversation only. The first conversation that uses it (at its start, or by a sign-in during it) keeps it; any other conversation refuses it even while it still lives, and goes on as a visitor's. Mint one per page view, as before. Each mint is recorded for your organisation with the portal, the key and how long the token lives, never who it names, and is never sent to a webhook. Anamekeeps letters, marks, spaces, apostrophes, hyphens and dots only. Who acts: code that reused one token across conversations.
Security hardening (every contract; security and privacy fixes are not versioned)#
listEvents,getEvent:events:readopens the event log, and each event also needs the scope that reads its kind of record: sessions, practice, sign-ins and ratingssessions:read;transcript.finalandsession.transcript_readytranscripts:read; reports, scores, reviews, certificates and completionsresults:read; assigned workassignments:read;candidate.*candidates:read; leads, bookings, hand-offs, forgotten details and knowledge gapsinbox:read; members, units and consentpeople:read; portals, subjects and jobsportals:read; the widgetwidgets:read; switchessettings:read; knowledge documentsknowledge:read; voicesvoices:read; usageusage:read; webhook testswebhooks:manage. Anything else is left out of the list (asking for it bytypeincluded) and404one by one. Webhooks are unchanged. Who acts: a key reading the log that needs more kinds of events adds their scopes.createPortalwithpersonaId,updatePortalpointing it at a persona that isn't the portal's own, andupdatePortalPersonaon a portal that speaks with such a persona (a standalone persona, a website widget's or another portal's) needpersonas:writeas well asportals:write(403): that persona speaks elsewhere too. A portal's own persona is changed withportals:writeas before. Who acts: keys withportals:writebut notpersonas:writethat point portals at personas.getCandidate,listCandidates,decideCandidate:reviews.recommendations(how many reviewers recommend advance, hold or do not advance) isnullunless the role shares scores (candidates.share_scores), and once a candidate is erased, likescore,bandandrank.listResults:externalUserIdneedspeople:read(403,param: query.externalUserId), as theexternalIdit finds people by does.updateInboxItem: the answer carries the visitor'scontact,bookingandconsentonly for a key that also holdsinbox:read(elsenull); moving the item on is unchanged.avatarPreview: your organisation's own pictures need a key holdingavatars:read(any other key gets404, as a stranger does); library faces stay public.revokeKey: a key that can read consent records (consents:*, which only an owner gives) is revoked by the account owner in Zentiq or by itself, never by another key (403).createKey: while the plan can't be read, no key is made (502 upstream_error, retryable; requests made with an existing key still go through).getSession,sessionAnalytics: a sessionlistSessionsnever shows (a job interview, a builder's preview, a child's session) is404, not only its words.- Every paged list: a
cursormust be one this list gave; one naming another organisation's record is400 invalid_request(param: query.cursor), never a page. createWebhookandcreateSessionToken: a replayed answer (Idempotency-Key) keeps its signing secret or join token sealed while it is stored. Webhook deliveries, knowledge byurland other fetches of an address you give connect only to the address that was checked, so a name can't point somewhere private between the check and the request.
Build with AI: sign-in and the hosted MCP server (security fixes, not versioned)#
- The sign-in screen is read-only by default: Allow grants the reads the app asked for, and each change (every write, publishing, live sessions, webhooks, people…) is its own box, never ticked for the person. The token's
scopesays what was granted, which can be narrower than what was asked. A scope Zentiq doesn't have (or never gives an app) isinvalid_scope. A refresh never widens a grant; asking again for more than was ticked keeps the grant as it is. - Tools that need the person's go-ahead no longer accept
confirm: true: the first call changes nothing and answersconfirmation_requiredwith apreviewand aconfirmationCode; the same call again with that code, within five minutes and with exactly the same arguments, does it, once. Where the app can show a form (MCP 2026-07-28 elicitation), Zentiq asks the person there instead. Webhooks, embed sign-ins, the inbox, the website widget and a portal's visitors' door now ask too. - Answers that can carry text the public wrote (transcripts, the inbox, events, knowledge, candidates, people, results) put it under
untrustedData, beside anoticethat it is data, never instructions. - Tool annotations: only reads say
readOnlyHint; every update, delete, end or removal saysdestructiveHint; tools that reach outside Zentiq (webhooks, invitations, speaking into a live session, fetching a web page) sayopenWorldHint. - A connected app makes at most 60 changes a minute; each change is recorded and shown under API keys, Connected apps, and so is a disconnection.
/mcp,/oauth/*and the OAuth discovery documents answer only on Zentiq's own addresses, never on a custom domain pointed at Zentiq. A JSON-RPC batch is accepted only on protocol version 2025-03-26 (at most 10 messages, neverinitialize); bodies over 1 MB are refused however they are sent. An app can't register under Zentiq's name.- An exchange of a sign-in code without the right verifier no longer uses the code up; a reuse of a code or refresh token that races the first use revokes what the first use got.
Verified customer (every contract; new and optional)#
createCustomerTokentakescontact: { email }, the customer's email address on record: before your avatar reads or changes something sensitive in their account, Zentiq sends a 6-digit code there and the customer types it in a secure box on the page (only when your organisation switches Verified customer on, and only for the actions that need it). It is signed into the token, and never shown to the avatar, kept in a transcript or written to a log; people see only a masked read-back (s***@example.com). An address Zentiq can't send to is400 invalid_requestwithparam: body.contact.email. Who acts: nobody; add it where you want codes to work for signed-in customers.- Your actions' answers may carry the reserved field
zentiq_verify: { email }for a visitor who isn't signed in, once your system has matched a second factor (the order number AND the postcode on it). Zentiq takes the field off before anything else reads the answer: the avatar, the conversation, receipts and webhooks never see it. - Once the customer has typed the code, the calls Zentiq makes to your system carry
"verification": { "level": "L2", "method": "email_code", "contactFrom": "token" | "<the callId whose answer named it>", "at" }in their body. Who acts: nobody; a system that answers sensitive requests may require it.
Connected systems (new; connections:write, every contract)#
- New scope
connections:write: connect, test, list and disconnect your own systems for actions. A full-access key holds it; a restricted key only when it lists it; no other scope implies it; an app connected through Build with AI can never hold it. See Connected systems. listConnections(GET /connections),createConnection(POST /connections),deleteConnection(DELETE /connections/{id}) andtestConnection(POST /connections/{id}/test). A key or token is write-only: it is checked with a read-only call, sealed and never returned (a connection shows a masked hint only). A sign-in (an MCP server's OAuth, or a calendar's) answers withauthorizeUrl, where an owner approves it while signed in to the console. Connecting and testing are403 forbiddenwhile the organisation's "Connect your own systems" switch is off; listing (withon: false) and disconnecting work either way. None is offered over MCP.- New webhook event
connection.failed(contract 2026-10-01): a connected system started failing, with its id, kind, an error code and the HTTP status. Never its key or anything it sent back. In the event log (listEvents,getEvent) it needsconnections:writebesideevents:read. - A connection's id is
conn_…(listed with the other prefixes); in the path, anything else is404 not_found.
Case links (new; cases:write, every contract; off until an owner switches on "Send customers a personal link to their case")#
- A conversation that already knows the case. Your CRM or helpdesk sends one customer a personal link to your website avatar about their order, claim, appointment or ticket: the avatar greets them about it, and every call to your actions from that conversation carries their id (
customer: { id, verified: true }) and the case (case: { reference, kind }in a POST body,zentiq_caseon a GET). The body of every other call is unchanged. See Case links from your CRM or helpdesk. createCaseLink(POST /portals/{id}/case-links): the customer's id, the case'sreference,kindandsummary, optionalfacts,firstName,email,opening,expiresInDays(7, at most 30) andmaxOpens(20, at most 100). The answer'surlis shown once, never again.409 conflictwithdetails.reasoncase_links_offordoor_closed.listCaseLinks(GET /portals/{id}/case-links),getCaseLink(GET /case-links/{id}) andrevokeCaseLink(DELETE /case-links/{id}) give a link's state, never the case's details. A link's id isclk_….- New scope
cases:write: only an owner or admin gives it, in the console; full access never includes it, a key can never pass it on, an app connected through Build with AI can never hold it, and none of these operations is offered over MCP. - New webhook event
case_link.opened(contract 2026-10-01): a customer started a conversation from a link, with the link, your reference and customer id, the opens so far and the channel. The case'ssummaryonly withincludeConversation, and never for a visitor who may be under 18. In the event log it needscases:writebesideevents:read. - Verified customer: the
emailon a case link is the customer's contact on record; after the code, a call'sverification.contactFromis"case".
Sessions (every contract; privacy fixes are not versioned)#
listSessionslists every session of the organisation again by default (on 2026-09-25 it had changed to list only the sessions started through the API, which left out sessions integrations relied on);source=apilists only those. Never listed: a job interview (a candidate's, or a member's mock interview), a child's session, a builder's preview. New filters:personaId,subjectId,portalId,personId,from,to. A session carriesmode,subjectId(sub_…, bare for keys on 2026-07-08) andkind(persona,subject,visitor).sessionTranscript,getSession,sessionAnalytics: every job interview is refused or has no words, a member's mock interview included (it was only the candidate screening).sessionTranscript,getSession,sessionAnalyticsandgetResultReport: a website visitor who said they are under 18 (the same marks that keep their words off webhooks and out of the inbox) is refused (403 learner_conversation;forbiddenfor keys on 2026-07-08) or has no summary, whoever ran the session.getSession,sessionAnalyticsfollow the released rule: no score, percent or pass for a practice attempt (the person's own), a result held for a reviewer or from the person, or a voided one; once a reviewer decided, the numbers are the decision's.createSessionToken: every persona session carries the platform's rules after its prompt (a prompt override included): where to send someone in danger (the organisation's country), no stage directions, and never naming the technology behind the avatar; the console's playground carries the same. NewlanguageMode(followswitches to the person's language,fixedanswers in the session's language anyway; default: the persona's own setting). An API session has been told since 2026-09-25 to keep its language unless the persona follows the person's.createSessionToken: a busy start (503 capacity) and a slow one (504 upstream_timeout) carryRetry-After(seconds). There is no queue: ask again after it, for a few minutes at most.sessionSay: a single avatar answering on its own speaks the line as its next turn once it is idle (the model may paraphrase slightly, andinterruptdoesn't apply); in external turn mode and for a duo the line is spoken as sent, as before. For such a single avatar, the body'sendSession: truedoesn't end the session (it keeps running, and using minutes, until you end it withPOST /sessions/{id}/end), andturnIdandeventId, which bind a line to a turn in external turn mode, don't apply.getSession,sessionAnalytics: the report's score, percent and pass are alsonullfor any job interview (a member's mock interview included), for a builder's preview, and while the session's details can't be read just now (ask again).endSession,deleteSession: through one end path (minutes stop on Zentiq's clock, the room is freed,session.endedgoes out once whoever ends it first).
People (new in 2026-10-01; people:read / people:write, opt-in for full-access keys made before it)#
listUnits,createUnit,getUnit,updateUnit: the organisation's classes, teams and groups, with counts; a unit can be switched off (everything below it too) and never moves under itself.listPeople,createPerson,getPerson: people sync.createPersonfinds or adds byexternalId(your own id, the one sync key embed tokens use too: a repeat returns the same person with200), else by email (an email another account already has is409 conflict,details.reason: email_in_use, never saying whose), else a login card for a learner without an email (the PIN only in that answer,Cache-Control: no-store, never replayed). No email is sent. A new learner's place waits for a parent's consent where the organisation's rules say so (waiting_consent).addMembership,updateMembership,deleteMembership,recordGuardianConsent: a person's places. The API never gives, changes, switches off or removes an owner's place, and only a full-access key gives, changes, switches off or removes an ADMIN, AVATAR_BUILDER or WORKSPACE_ADMIN place (403 forbidden,createPersontoo):people:writealone manages learners, leaders and observers. OWNER, ADMIN, AVATAR_BUILDER and WORKSPACE_ADMIN are held at the organisation level only; a staff place takes a staff seat (403 upgrade_required); a year or grade is a number or a range.waiting_consentturns active only through guardian consent, and consent is never recorded on a place that is switched off.consent.recordedsays who gave it (organisation,guardian,teacher) and how, never the guardian's address (privacy fix, every contract; the console's own consent record follows the same rule).- Adult learners (new, every contract; off until an owner or admin switches on "Mark learners who are adults (18+)"):
createPerson,addMembershipandupdateMembershiptakeageBand: "18+"on a learner's place, and everyMembershipsaysageBand("18+"or null). An adult learner starts without a parent's or guardian's consent, and a place of theirs waiting for consent is switched on when it is marked, with no consent recorded. With the setting off the mark is409 conflict(details.reason: "adult_learners_off"); on any other role's place it is400(param: ageBand). Every other protection for young learners stays. Who acts: tutoring businesses and schools with adult learners. deleteMembershipnever removes the last place of an owner or admin invited in the console, even after a role change (409 conflict: without a place their account would count as an owner); the console's Remove from organisation does that. The console's Staff & teams follows the same rule.
Results (new in 2026-10-01; results:read)#
listResults,getResult: a session's result (res_+ the session's id) with numbers only: score, percent, pass, pass mark, the rubric's categories, the review decision, an open contest, the certificate (number, status,verifyUrl) and the completion record; never the summary, feedback, evidence, answers, flags or notes. The released rule applies (release:not_scored,awaiting_review,reviewers_only,voided,released): no numbers while a reviewer still decides a scored attempt or the organisation shares results through a person only (nor the review decision, the certificate or the completion record then, which would say pass or fail), none for a void, the reviewer's decision once decided. Never listed: a practice attempt, a candidate's interview, another job interview.person.externalIdneedspeople:read. Filters:portalId,subjectId,unitId,personId,externalUserId,release,passed,from,to,updatedAfter(anything that changed the result since).getResultReport(beta;results:readandtranscripts:read): the report's summary, strengths, what to work on and the next step, once released, only where the conversation may be read (403 learner_conversationfor a young learner's).
Corrections to the reference (the API always answered this way)#
createKey(POST /keys): the reference said 200; the answer has always been201 Created.revokeKey(DELETE /keys/{id}): the reference showed{ deleted, id }; the answer has always been{ revoked: true, id }(now documented asKeyRevoked). Revoking a revoked key no longer moves itsrevokedAt.sessionTranscript(GET /sessions/{id}/transcript): the reference showed each turn withat; turns have always carriedrole,textandts(when the turn was stored), a duo'sspeaker, andwordswith timings where speech recognition gave them, pluskept. Only recorded turns are listed (an entry without a role and text is not a turn).listSessions,getSession,steerSession,endSession,deleteSession: a session'sstatusisACTIVE,COMPLETEDorABANDONED(the reference saidENDED, which was never sent), and it has always carriedturnModeandautoRespond.createWebhook(POST /webhooks): the reference said 200; the answer has always been201 Created.webhookDeliveries(GET /webhooks/deliveries): a delivery'sstatusisnullwhen its last attempt got no answer (a refused address, a timeout, a network error); the reference showed it as always a number.getPortalPersonaandupdatePortalPersona(/portals/{id}/persona):dataisnullfor a portal nobody speaks on yet; the reference showed it as always aDashboardPersona.
Portals and subjects (every contract)#
- Subjects and their content (new):
getSubject,getSubjectContent(the content document: facts, FAQs, opening hours, teams, scenarios, questions, the rubric, a screening role..., in camelCase, with anETag),updateSubjectContent(always a draft; lists replace the whole list, object sections change only the fields sent,factsalso by key;If-MatchorbaseVersion→409 version_conflict;400with the document's own paths),listSubjectVersions,getSubjectGoLive,publishSubject(the go-live check422, the round lock409, the plan403). Every draft saved sendssubject.draft_saved. updateSubject(new,content:write):PATCH /subjects/{id}changes a subject'sname, its unit (unitId, null for the organisation level) and, for a website subject, its language rule for visitors (visitorLanguage:follow,listedorenglish; null goes back to the organisation's setting), which applies to the next conversation at once.visitorLanguageon another kind of subject, or where the organisation has locked it, is409 conflict; a website widget's subject changes with its avatar (409 conflict). Answers the subject. Who acts: nobody.updateSubjectContent:facts.assistant_nameis always the name of the avatar that speaks, which is set on each portal (updatePortalPersona,name): a changed value is400 invalid_request(param: body.set.facts.assistant_name); it used to be stored and never used. The valuegetSubjectContentgives can be sent back as it is, or cleared. Who acts: code that names the assistant through the content; name the portal's persona.- Doors and go-live (new):
getPortalGoLive(what each subject still needs, by field; a warning about the portal's actions, never a refusal, hassubjectId: null, and so does one about its rehearsal before go-live,field: "rehearsal": "Not rehearsed yet" or the problems a rehearsal found; a safety problem it found refuses a publish only when the organisation has switched on "Safety problems found in a rehearsal stop a portal going live"),listPortalDoors(the members' page, the visitors' page and whether it is open, the embed, the custom domain) andupdateVisitorDoor(the websites allowed to frame it and start conversations, and opening or closing it:portals:publish). listPortalsis paged (limit,cursor,nextCursor; oldest first, as before), takesunitId, and never lists the hidden portals behind personas' website widgets. A portal addsgroup,addresses,unitId,brand,domain, and each subjecttemplate(typestays, the same value),kind,group,screeningandhasDraft.listPortalTemplatesaddskind,duo,requiresandincludedInPlan, and lists the duo templates where the duo builder is open for the organisation.createPortalbuilds through the console's own rules: always a draft first;unitId;Idempotency-Key; a website or screening portal opens its visitors' door as the console's does (the organisation's public website switch goes on) for a key holdingportals:publish(without it the door stays shut, andvisitorinloginMethodsis403, as onupdatePortal); a duo persona must be live (409 duo_not_live) where the duo builder is open (404otherwise). Fields are checked against their documented limits (400withparam:nameup to 80,welcomeup to 160, knownloginMethods, at most 12templatesof up to 60 characters each,loginMethodsnot empty; they used to be cut or dropped: the first 12 templates were kept).updatePortaladdsunitId,personaId,brand.modeandbrand.hideZentiqBadge; opening a portal to visitors (addingvisitor) needsportals:publishand runs the go-live check on a published portal (422). A website widget's portal is409 conflictto change (and404to read), here and on publish, persona, doors and embed tokens.- B4
updatePortalPersonatakes the face and voice by id (avatarId,voiceId);faceandvoiceby name still work and are deprecated. Answers addid,avatarIdandvoiceId. createEmbedTokenanswers in the standard envelope (error: null,requestId), takesunitIdandconsent: { by: "organisation" }(the organisation attests a young learner's consent, withpeople:writeas well; not where consent is collected per learner in Zentiq), finds the person by the sameexternalIdasPOST /people, and records a unit it makes (unit.created). A retry makes a new token: an answer holding one is never replayed.planSetupplans a duo template where the duo builder is open, and spends one of the organisation's daily AI helper uses (429 rate_limited,details.allowance: "ai_daily").
Webhooks and events (every contract; privacy fixes are not versioned)#
- Every event sent is recorded first: the envelope's
id(evt_…) is the same on every attempt and every redelivery, andcreatedAtis when the event happened. Deliveries come from a durable queue: the first attempt at once, then 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h later; 10 s per attempt; a3xxis a failed attempt (redirects are never followed, as the docs said;307/308used to be); the address is checked before every attempt; new headersX-Zentiq-Event-IdandX-Zentiq-Delivery-Id. A webhook failing every delivery for three days is paused (pausedAt;PATCH { active: true }resumes it). Each attempt's body is rebuilt from the recorded event with the rules as they are then. session.endedgoes once per session: the clean-up job no longer adds its own for a member's session, a visitor's conversation or a candidate's interview (API sessions keep theirs). A candidate's interview sends onlycandidate.*(noreport.produced,visitor.session.ended,session.endedor rating for it).transcript.finalmust be named in a webhook'sevents("*"never includes it) and needsincludeConversation.report.producedsends itssummaryandreport.summarywithincludeConversation, where the rules allow words.- A website visitor who says they are under 18 has none of their words sent on their
handoff.requested,lead.capturedorbooking.requested, even withincludeConversation(and their details are never captured).visitor.detail_removedno longer sendsreason(it could say the visitor is a child): only whichfieldsto delete. - A reviewer's decision on a scored result is its own
assessment.completed(its ownevt_id,reviewed: true, thedecision, the marker's result underai): one per decision, the newest persessionIdis the result. It used to go out again under the attempt's own event id, so a receiver that de-duplicates by id could drop the decision. assignment.completedno longer carriespass: it was alwaystrue, so it said the person passed even where the organisation keeps results from the person. Read the result (GET /results/{id}), which follows the released rule.knowledge.document.failedaddserrorCode; candidate events addexternalId(your ATS's id; never an email address) andresent; the three visitor-request events addverifiedandportalId(the portal the visitor came through) once they are recorded.- New event types (sent from the next release):
session.transcript_ready,subject.draft_saved,widget.updated,job.completed,candidate.invite_opened,candidate.invite_expired,webhook.test. POST /webhooksaccepts only types a webhook can receive (400naming anything else; it used to drop unknown names and accept never-sent ones);includeConversation: trueneedstranscripts:readand every change of it is recorded in the organisation's activity log; on a webhook that receives words, changing itsurl,eventsorapiVersion, oractive: trueswitching it back on, needstranscripts:readtoo (403; switching the words off never does);Idempotency-Keyis honoured; newdescription. A webhook showsdescription,pausedAt,apiVersion,updatedAt, andactive: falsewhile paused. NewGETandPATCH /webhooks/{id};GET /webhooksis paged;GET /webhooks/deliveriesaddseventId,state,responseMs,errorCode,nextAttemptAt, filters and real paging.- New
GET /eventsandGET /events/{id}(events:read): every event offered to webhooks, exactly as a webhook with the key's rights receives it, each only with the scope that reads its kind of record too (see Security hardening above); a visitor's contact details (and their consent) only for a key holdinginbox:read, a candidate's events (candidate.*) only for one holdingcandidates:read; conversation fields only with?includeConversation=trueandtranscripts:read. Webhooks themselves are unchanged.GET /eventsis limited as a search (120 a minute per key). NewGET /event-types: the catalogue with each type's fields by class and its JSON Schema.
Candidate screening (new; candidates:read / candidates:write, opt-in for full-access keys made before 2026-10-01)#
- New
GET /roles: the organisation's screening roles, with their door (the interview link a job advert carries), review settings, rounds andshareScores. - New
POST /roles/{id}/invites(personal links, from an ATS with its ownexternalIdandmetadata; the Invite dialog's rules: the role published and live, at most 200 or 100 emailed, skips with a reason code:open_invite,already_interviewed,listed_twice;Idempotency-Key),GET /roles/{id}/invites,GET /invites/{id}andDELETE /invites/{id}(cancel a waiting invite). - New
GET /roles/{id}/candidatesandGET /candidates/{id}: ids, statuses, contact details, must-have results (never the answers), the latest decision (value, reason code, the deciding member's id), review counts, holds and retention.score,bandandrankonly where the role's new switchcandidates.share_scoresis on (off by default; a person decides every outcome); never answers, summaries, notes, review reasons, integrity flags, recordings or an adjustment's kind. An erased candidate carries no contact details and no evaluation (score, band, rank, must-haves) and holds no place in the ranking. Reading a candidate through the API is not opening their interview. - New
POST /candidates/{id}/decision: a person's decision, carried by the key:decidedBynames the member (usr_…), whose own hiring access applies; the reviews the role asks for come first, a decline waits for at least one and needs the member to have opened the interview or reviewed it in Zentiq (409,details.reason: needs_open). One candidate at a time: there is no bulk decline. - Candidate events carry what an ATS correlates on:
candidate.screenedthe invite (inviteId) and yourexternalId;candidate.decidedthe role, the portal, the invite andexternalId;candidate.invitedandcandidate.invite_cancelledyourexternalId. - New scope
candidates:interviews(interview content for a hiring system): never part of full access, on any contract; only an owner or admin puts it on a key, in the console (Interview content (hiring) on the API keys page); a key never gives it to another key (POST /keysanswers403); an app or assistant connected to Zentiq never holds it, and no MCP tool needs it. - New
GET /candidates/{id}/interviewandGET /roles/{id}/interviews(candidates:readandcandidates:interviews; theheavyrate limit): the candidate's interview as the hiring team reads it in Zentiq: each must-have and interview question with their answer and the platform's note, the written summary, strengths, concerns and evidence, the transcript turn by turn (ai/candidate, the unscored practice question never included), whether they asked for an adjustment (adjustments.requested; never which one), their request for a review, the reviews (hidden while the role uses blind review), and when it happened (practiceQuestionsays whether it opened with one). Score, band, rank, verdicts, marks and ratings only where the role shares scores (candidates.share_scores). Never reviewers' or deciders' notes, integrity or flag details (counts and kinds only) or the recording. Every read is logged on the candidate ("Interview opened by API key …" in their history), and never counts as a person having opened it. A candidate under 18, a practice or mock interview and a builder's preview are404; an erased candidate, a deleted interview and one the candidate ended themselves are410 gone(details.reason).GET /roles/{id}/interviewslists oldest first,sincefor syncing, 20 a page (at most 50). Webhooks still carry no words: fetch the interview oncandidate.screened. getCandidate,listCandidates,getSession,sessionTranscript: the descriptions now say where a hiring system reads the interview; they still never carry it, and a job interview is still never read as a session (403/404as before).
The Serve inbox (new; inbox:read / inbox:write, opt-in for full-access keys made before 2026-10-01)#
- New
GET /inbox/itemsandGET /inbox/items/{id}: website visitors' leads, booking requests and hand-offs, one item (inb_…, webhooks'inboxItemId) per conversation, placed by its first request and paged with no window on how many older requests there are; filtersportalId,subjectId,personaId,status,kind,overdue,createdAfter/createdBefore, andupdatedAfterwithorder=ascfor syncing a CRM. Each item has the visitor's contact details (they are the business's own customers),consent(marketingis always false),verified,removedFields(details the visitor had removed) and who moved it last (statusBy: a member or an API key). The visitor's own words (need,budget,timeline,summary, a hand-off'sreason, a lead'sanswers) only for a key holdingtranscripts:read, and never from an under-18's, a candidate's or a safety-flagged conversation. - New
PATCH /inbox/items/{id}{ status, note?, expectedStatus? }: recorded aslead.updatedorbooking.updatedwithby= the key; a staleexpectedStatusis409 conflictwithdetails.currentStatus. lead.captured,booking.requestedandhandoff.requestedrecordverified(false when the request was read only from the visitor's page, not the server's own record of the conversation) and theportalIdthe visitor came through.
A person in the same call (new; sessions:write; off until an owner switches on serve.staff_join)#
- New
POST /sessions/{id}/staff-token{ userId? }: a member of your team joins a website visitor's live conversation as themselves, for example from your own helpdesk. It answers{ sessionId, staffToken, url, room, identity, userId, handedOver }; join the room aturlwithstaffToken(microphone and camera, no data channel). The avatar says who joined and what the visitor needs, then goes quiet; when the last person leaves it carries on, or the conversation ends if the visitor has gone. A member's own key (made by them in the console) joins as them; only a full-access key may name another member (userId,usr_…). The member must be able to open the portal's inbox, and at most two people from the team can be in one call. Refusals:403 forbidden(the switch is off, no inbox access, or the key may not name that member),409 session_ended,409 conflict(details.reason:full,typed,interview),503 capacity(joining isn't available on that conversation yet: try again later) and502 upstream_error. Not offered to assistants over MCP. - New webhook events
handoff.joinedandhandoff.left: who from your team joined or left a visitor's call (theirstaffUserId, never an email or a name), how (via:console,email,api), for how long (seconds) and what happened next (next:stayed,resumed,ended). Ids only; never for a candidate's interview.
AI assistants and the site guide (new; off until an owner switches them on)#
- New
POST /agents/{org}/{door}/ask{ question }, with no key, for AI assistants and search tools: one question to a business's public website assistant, answered in writing with the same rules as a typed conversation on its page. It answers{ answer, url, aiGenerated: true }:urlis the assistant's own page, where a person talks to it, books or reaches the team. Nothing is stored and no details are taken; email addresses, phone numbers and payment or identity numbers are removed from the question and the answer. Only for a public page whose business switched onserve.agent_readyand opted that page in (never a page for children or a candidate's interview): any other address is404 not_found. Limited per caller and per page (429 rate_limitedwithRetry-After);503 capacitywhen no answer could be written just then. Not offered as a tool over MCP. - An opted-in page also carries schema.org structured data (the organisation, the service and its questions and answers), an agent card at
/v/{org}/{door}/agent-card.json(and/.well-known/agent-card.jsonon its own verified domain), and the organisation lists its opted-in pages in/v/{org}/llms.txt. - The website widget (
widget.js) can show a visitor around your own website when an owner switches onserve.site_guideand sets up the widget's guide: it points at, scrolls to and opens the parts and pages you listed, fills in a field markeddata-zentiq-fillableafter the visitor confirms, and presses one markeddata-zentiq-clickable. A single-page app opens its own pages withZentiqWidget.onNavigate(fn). It never touches a password, code or payment field, and anything that sends a form waits for the visitor's own tap. A page's ownZentiqWidget.onActioncan't take asite_guide_name.
Chat escalation (new; escalations:write; off until an owner switches on serve.chat_escalation)#
- New
POST /portals/{id}/escalations{ transcript, customer?, reason?, returnUrl? }: your chat tool's server hands a text chat to the portal's website avatar. It answers201 { id, url, token, expiresAt }: show the customerurl, which works for one conversation within 30 minutes (its token travels in the fragment; Zentiq keeps only a hash of it). The avatar reads the chat as information, never as instructions, and opens with a one-line recap. Refusals:409 conflict(details.reason:switched_off,door_closed,no_website_subject),404 not_foundand410 gone; a retry makes a new link. Not offered to assistants over MCP. See From your text chat to the avatar. - New scope
escalations:write(preset Chat escalation): a full-access key holds it; no other scope implies it. - New webhook event
escalation.completed(contract 2026-10-01): where the chat came from (source), your chat tool's ids (externalRef,customerRef), how long it lasted, the conversation'sreferenceand theoutcome(resolved,handoff,booking,lead,unresolved,no_conversation). Asummaryonly to a webhook made withincludeConversation, where the words may leave; never for a candidate's interview. In the event log (listEvents,getEvent) it needssessions:readbesideevents:read. - An escalation's id is
esc_…. - Intercom (a Canvas Kit app for the Inbox) and Zendesk (a webhook, a trigger and a ticket sidebar app) connect in the portal's Developers tab; their secrets are encrypted and never shown again. The website widget gains
ZentiqWidget.escalate(…)for a chat on your own page.
US hiring rules (new; candidates:read; off until an owner or admin switches on hiring.us_compliance)#
- New settings: US hiring rules for AI interviews (
hiring.us_compliance, off by default) withhiring.us_illinois,hiring.us_nycandhiring.us_colorado(auto: the role's location;on: every US role;off). Colorado's rules follow a role's location from 1 January 2027. See US hiring laws for what the candidate sees and what stays yours to do. - New
GET /roles/{id}/requests: candidates' requests for a role (creq_…): deletion, another way to be assessed, a review or a correction, withstatus(open,pausedwhile a legal hold keeps the interview,done),dueAt,autoDeleteAt,overdueandoutcome. Filter bystatusandkind; newest first. - New
GET /candidates/{id}/compliance: the rules the pack applies to the candidate's role now, the notices they read before the interview (kind, version and a fingerprint of the exact text), when they agreed, their separate Illinois consent, how New York City's notice reached them in time, and their requests. - Neither carries a candidate's message, an open-door request's contact details, a legal hold's reason or any demographic answer, and no webhook is sent for any of it. Who acts: nobody; hiring systems that keep compliance records may read them.
Added (every contract)#
- Headers on every answer:
X-Request-Id,Zentiq-Version, andRateLimit-Limit,RateLimit-Remaining,RateLimit-Reset(a 429 addsRetry-After). Rate limits are per key and per organisation, by operation class (read 600, write 120, live 30, heavy 20, search 120 a minute per key; twice that per organisation), and are counted across all servers. - Errors may carry
param(the input at fault:body.name,query.limit,params.id) anddetails. An id of another kind (sess_…where a key id belongs) is404 not_found. - The developer API is checked against the plan on every request (
403 upgrade_required, featureapi.access). GET /me:plan.features,rateLimits, and onapiKey:effectiveScopes,fullAccess,apiVersion.GET /keysis paged (limit,cursor,nextCursor) and listsapiVersionandcreatedBy(usr_…orkey_…).POST /keys:apiVersion(default: the request's contract), every scope of the catalogue, andIdempotency-Key(a retry gives back the same key). A key can only give scopes it holds itself; unknown scope names are a 400 from 2026-10-01 (dropped, as before, on 2026-07-08).- The console's API keys page makes scoped keys from presets (Read only, Website assistant, CRM, Hiring integration, People sync, Learning platform, Full access) or scopes ticked one by one.
- Sessions (
createSessionToken,steerSession,sessionSay,listSessions): inputs are checked against the reference; a field of the wrong type is400 invalid_requestnaming it (param), where before some were ignored.nullstill means "not sent".createSessionTokenhonoursIdempotency-Keyper key (a retry replays the first start), runs in the region of the session servers and may take up to a minute before it answers.sessionTranscriptneedstranscripts:read(held by every key made before 2026-10-01 that holdssessions:read). - SDKs 1.0.0 (
@zentiq/node,@zentiq/client,@zentiq/react,@zentiq/mcp, andzentiqfor Python) speak this contract, with methods named after every operation id. Who acts: users of@zentiq/nodeand@zentiq/client0.x (see each package's own changelog).
Also changed on every contract (keys on 2026-07-08 see these too)#
- The developer docs are at
/apidocs. Every/docsaddress, each page under it included, redirects there for good (308), so saved links keep working; the SDKs' pages and the OpenAPI document's contact link point there. - Idempotency.
Idempotency-Keyis now honoured oncreatePersona,createKnowledgeBase,createAvatar,publishPortalandplanSetupas well: a retry replays the first answer, and the same key with another request is409 idempotency_conflict. A key belongs to the API key that sent it (it used to be the organisation's): the same value sent with two API keys is two requests, and a retry that spans this release runs once more. A request that never answered frees itsIdempotency-Keyafter 10 minutes (createSessionToken: 90 seconds, as before). Who acts: integrations that reuse oneIdempotency-Keyacross different requests. - Rate limits. Besides each key's budget, each organisation has one twice as large for all its keys together (write: 240 a minute, shared by
sessionSay,steerSession,endSession,deleteSessionand every other write), counted across all servers.planSetupis limited as a heavy operation (20 a minute; it was 30). The earlier limit of 120 requests a minute per key on each server still applies in front of these, while theRateLimit-*headers describe the budgets above: a key can be refused (429, withRetry-After) by that limit before its budget reaches zero. Who acts: integrations that drive one key or several keys hard. - Webhook deliveries. Each carries
User-Agent: Zentiq-Webhooks/1.0. The envelope'sidisevt_followed by the recorded event's own id (it wasevt_and 24 hexadecimal characters): treat it as opaque. Each attempt is signed afresh, with a new timestamp and signature: check every delivery against its own headers. Who acts: receivers that parse the event id or cache a signature. - Keys made in the console are on the newest contract (2026-10-01), so replacing a key in the console moves its integration to 2026-10-01. To stay on 2026-07-08, make the new key with
createKeyandapiVersion: "2026-07-08". webhookDeliveries: alimitover 200, or one that isn't a number, is400(it was read as 200, or as the default).createKey: a body that isn't JSON,scopesthat isn't a list, or anamethat isn't text is400(such a request used to make a full-access key named "API key").searchKnowledge:topKis a whole number from 1 to 50 andminScorea number from 0 to 1; anything else is400(they used to be converted, or ignored).- The studio voices are in every organisation's voice list from this release, and the library's faces and voices carry their current names (one studio voice has a new one), so
updatePortalPersona's deprecatedfaceandvoiceby name follow the current names. Who acts: code that names a face or voice: useavatarIdandvoiceId.
2026-09-25#
For every contract. Nothing was removed; one new optional field (includeConversation on webhooks) and one new list filter (source on GET /v1/sessions).
- Publishing goes through the go-live check. A portal goes live only once every subject is set up with the organisation's own details, never a template's example content (the same check as the console's Publish).
POST /v1/portalswithpublish: trueanswers201and stays a draft when the check refuses, withpublishRefusedsaying what to finish first;POST /v1/portals/{id}/publishanswers200, unchanged, withpublishRefused(on the 2026-10-01 contract that publish is422 go_live_refused). An embed token needs a published portal. Who acts: flows that publish and then mint embed tokens: readstatusorpublishRefused. - Webhooks carry only what their event allows: each event has one list of fields, deny by default. Conversation words go only to a webhook with
includeConversation: true(off by default), and never from a child's, a candidate's, a practice, a held or a safety-flagged session;session.ended'ssummaryis null without it.lead.captured,booking.requestedandhandoff.requestedkeep the visitor's contact details; their summary, reason and the visitor's own words needincludeConversation.safety.flaggedis no longer sent at all. Scores are null while a result is held for review, kept from the person or voided, and a practice attempt's are never sent. People's names, staff notes and free-text reasons are no longer sent;consent.recordedsaysby: "guardian"without an address. Who acts: receivers that read words, names or scores from webhooks: read them with the API, where the rules apply, or turn onincludeConversation. - Sessions:
PATCH /v1/sessions/{id},POST /v1/sessions/{id}/say,POST /v1/sessions/{id}/endandDELETE /v1/sessions/{id}reach only sessions started through the API (any other is404 not_found). Transcripts and summaries refuse a child's conversation.POST /v1/sessions/tokensstarts sessions exactly as the console does; a persona with a studio voice speaks with that voice's own engine. - Personas:
GET /v1/personaslists standalone personas only; a portal's own persona is read and changed withGET/PUT /v1/portals/{id}/persona, which answers410 gonefor an archived portal and413past the console's limits (character 4,000, greeting 300, name 80 characters). - Knowledge: a failed document's
erroris one plain sentence, never another service's message.
Faces, voices, personas, knowledge and languages (every contract unless it says otherwise)#
- A persona's
settingsis an allowlist (privacy, not versioned). It used to return every stored key but a few; it now returns exactly the documented keys (PersonaSettings), each always present with its type:engine,globalVoice(only on global),language(a code),languageMode(new),accent,voice_accent,skipGreeting,interruptible,cc,company,greet_mode,greetings,idle_actions,display_mode,hand_motion,vision,vision_safe_mode,ai_disclosure,ai_disclosure_text. Storage addresses (a generated room's picture) and the console's own keys (looks, the language rule) no longer appear. Who acts: nobody, unless you read an undocumented key. settings.enginesays the engine the persona really runs on, decided by its voice:globalfor a studio voice,premierfor your organisation's cloned voices (it used to repeat a stored switch that the live session had stopped following for studio voices).GET /personastakeskind(singleorduo); a persona made from/duosnames it induoId.- New on a persona:
cast({ kind: "single" | "duo", people: [{ name }] }) andsettings.languageMode(follow: it answers in the language people speak to it, starting inlanguage;fixed, the default: it keeps tolanguage).languageModecan be set on create and update, and the website widget follows it. createPersonaandupdatePersonasave through the console's own write path, so both store the same thing for the same input (the engine from the voice, the settings through the same validator, knowledge packs checked against the organisation).settingstakes the documented keys (PersonaSettingsInput; others are ignored, as before) and a key of the wrong type is now400naming it; on an update a setting sent asnullgoes back to its default.avatarIdorvoiceIdsent asnullis400(a persona keeps a face and a voice: send another one); it used to clear them.POST /voicesandPOST /knowledge/bases/{id}/documentshonourIdempotency-Key.- Every error of the face, voice, persona, knowledge and duo operations names the input at fault in
param; a409says why indetails.reason(in_use,duo_studio_only,duo_keeps_picture,duo_picture_not_single,name_taken). A field over its length is413too_largefor keys on 2026-10-01 (invalid_requestwith 413, as before, on 2026-07-08). POST /avatarswith animageUrlthat can't be fetched no longer repeats the fetch's own error text.GET /avatarslists what the console's studio offers: the library as published, then your own pictures that are unused or worn by an active persona (never a retired library picture, one a deleted persona left behind, or a duo's two-person picture). New filterskind,gender,ageBand,heritage, and the facets on every avatar (nullfor your own pictures).GET /avatars/{id}of a duo's picture is404(it belongs to its duo).DELETE /avatars/{id}deletes the picture for good, as the console does (its studio looks and stored copies go too); afterwards the id is404(it used to be410with the picture kept). Still409while an active persona wears it.- A voice says the engine it runs on (
engine:globalfor the studio voices,premierfor cloned voices) and itsgender(studio voices);languagesnow comes from the catalogue by engine: every catalogue language for a studio voice, the ten markedduofor a cloned voice (it used to claim every language for every voice).GET /voicestakeskindandgender. The studio voices are in the database from the release's migration, so the list is complete on a new organisation without a console visit. GET /languages: the engines are described from the catalogue only:global(the studio voices;default: true) with the catalogue's count,premier(cloned voices) with the ten it is promised in; no other count is stated.- New
GETandPUT /personas/{id}/widget(widgets:read,widgets:write): a persona's website widget, as its page in the console has it. The firstPUTmakes it (off); it takes how it shows (layoutfloating or inline,initialStateminimized or expanded,positionone of four corners,label,accent,start),allowedOrigins,requestEmail,purposeandon.on: trueneedsportals:publishand the plan's website assistants and website embedding (duos for a duo); what it still needs is422 go_live_refusedwithdetails.problems([{ field, message }]) and the widget indetails.widget. While it is on, changingrequestEmailorallowedOriginsneedsportals:publishtoo (403). The answer hassteps,live,publicId, thesnippetto paste andconfigUrl; its portal never appears inGET /portals. - New duo operations (
duos:read,duos:write; keys made before 2026-10-01 hold them throughpersonas:*):GET /duos/access(whether duos are open to the organisation and what the plan allows),GET /duos/library(Zentiq's ready-made duos),POST /duos({ source: "library", libraryKey }: a copy, as a draft; it takes one of the plan's custom-avatar places),GET /duos,GET /duos/{id}(duo_…) andPOST /duos/{id}/publish(it is tested and goes live on its own;409says what is missing). While duos are closed to the organisation every one but the access check is404. A duo speaks the ten languages itslanguageslists; change it through/duos, neverPATCH /personas/{id}(409,details.reason: "duo_studio_only"). - A knowledge pack's
documentCountcounts only documents that aren't deleted (it counted deleted ones too); newreadyCount.DELETE /knowledge/bases/{id}detaches the pack from every persona that had it, as the console does, and says how many indetachedFrom; deleting it again is410.
2026-07-08#
The v1.0 contract, and what every key made before 2026-10-01 speaks.
- Engine-neutral voices. A persona's voice tier is
settings.engine(premierorglobal), withsettings.globalVoicefor a global voice; older internal setting names are ignored and never returned. (From 2026-10-01 the voice decides the engine.) - Fields that did nothing were removed:
settings.asr_languageandsettings.multilingual(ignored on input, never returned), andmultilingualon voices and onGET /v1/languages. - Prefixed ids. Every id in answers and webhooks is prefixed (
pa_,av_,vo_,sess_,key_,wh_,kb_,doc_,org_). Earlier prefixes (per_,avt_,voice_) and bare ids keep working on input. Who acts: code that parses an id's prefix. - One error shape:
{ "error": { "code", "message" } }, with the request's id in theX-Request-Idheader (plan refusals kept their own shape until 2026-10-01). Success answers were unchanged:{ "data", "error": null, "requestId" }, withnextCursoron lists. - Not changed: authentication, scopes, rate limits, pagination, idempotency, webhook signing, event names and paths.