Skip to the content
Menu: Changelog

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_required is { "error": { "code": "upgrade_required", "message", "details": { "feature", "plan", "limit"? } } } instead of { "error": "upgrade_required", "feature", "plan", "message", "limit"? }. Who acts: code that reads error === "upgrade_required" at the top level. The SDKs read both shapes.
  • B6: three scopes split out. transcripts:read (conversation words; was part of sessions:read), embed:write (embed tokens) and portals:publish (publishing, opening doors, switching a widget on; both were part of portals:write); also content:* (subject content), duos:* and widgets:* (were part of personas:*) and events:read (was part of webhooks: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:* or inbox:* until an owner adds them to it; a new full-access key reaches everything except candidates:data, candidates:interviews, cases:write and consents:*. 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 keep invalid_request with 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 adds apiVersion and organizationId (org_…); every id in data carries 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); on lead.captured, booking.requested and handoff.requested the visitor's details are contact (camelCase preferredTime), their own words (need, budget, timeline) sit beside it, leadScore is leadBand and inboxItemId (inb_…) names the item; on lead.updated and booking.updated, by is { type, id }. Who acts: receivers of webhooks made with a 2026-10-01 key. A delivery's own id in GET /webhooks/deliveries is whd_….
  • B2: portal and subject ids are prefixed. Every portal id is portal_… and every subject id sub_… in the answers of listPortals (its nextCursor too), createPortal, getPortal, updatePortal and publishPortal, 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. publishPortal answers 422 go_live_refused with details.problems (one entry per thing to finish: subjectId, subjectName, field, message, short) and details.warnings, instead of 200 with the portal unchanged and publishRefused. createPortal with publish: true still answers 201 with publishRefused for every contract (the portal was made). Who acts: code that looks for publishRefused after a publish.
  • sessionTranscript: a refused transcript names why in its code: candidate_interview (any job interview) or learner_conversation (a young learner's conversation), still 403. Keys on 2026-07-08 keep forbidden. Who acts: code that reads forbidden there.
  • getSession, sessionAnalytics: the summary text goes only to a key holding transcripts:read (a key made before 2026-10-01 holds it through sessions:read). Who acts: new keys that read summaries list transcripts:read.
  • B8: inputs are read exactly as documented. On 2026-10-01, null on a field the reference doesn't mark nullable is 400 invalid_request naming 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=) is 400 like any other malformed value. On createEmbedToken, role is exactly learner, leader or observer, and a name or unitName over 80, a gradeBand over 8, an email over 200 or an externalUserId over 120 characters is 400. On sessionSay, interrupt: null means not sent (so true). Keys on 2026-07-08 keep v1.0's reading of all of these: null and an empty parameter are not sent, role is read in any case with anything else a learner, over-long fields are cut (an over-long email is left out), and interrupt: null doesn't interrupt. Who acts: code moving to 2026-10-01 that sends null for "no value", an empty cursor, or text longer than the documented limits.
  • B7: the voice decides a persona's engine. On createPersona and updatePersona, settings.engine and settings.globalVoice are read-only outputs: a studio voice runs on global, your organisation's cloned voices on premier, as in the console; choose a voice by voiceId (GET /voices). Keys on 2026-07-08 may still send them: a globalVoice that names a studio voice picks that voice when no voiceId is sent, and engine never overrides the voice. Who acts: code that chose an engine with settings.engine instead of a voice.

Response times (every contract; an addition; off until an owner or admin switches on "Measure how fast the avatar answers")#

  • Session (and SessionDetail) adds responseTimes: { 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 is null while 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. listSessions and getSession carry it; the answers of steerSession, endSession and deleteSession always say null. 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: sessionTranscript answers { turns: [], kept: false } for every session of the organization (while it runs too), summaries are null wherever they appear, GET /v1/events and every webhook delivery leave conversation words out, and no transcript.final is sent. See What leaves Zentiq.
  • createWebhook and updateWebhook refuse includeConversation: true while it is on: 409 conflict with details.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 only transcript.final is 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 conflict with details.reason case_links_off or switched_off, or 403 forbidden for connections) answers 403 upgrade_required with details.reason: "plan_required" and details.plan, the plan that includes it (keys on 2026-07-08: reason beside feature and plan). 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)#

  • createConnection for shopify takes a Dev Dashboard app's clientId and clientSecret (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 sends token, now with an optional apiSecret (its API secret key), which only order emails need. Both secrets are write-only. A Shopify connection's details adds app: dev_dashboard or admin_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) with source: "api"; their customerId is order-<Shopify order id> and their reference the 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: true on Session (and SessionDetail), SessionAnalytics, Transcript, SessionToken, Avatar, Duo and LibraryDuo; each TranscriptTurn says aiGenerated: true for the avatar's words and false for the person's. A webhook delivery (and a GET /v1/events item) says aiGenerated: true when its data carries an avatar's words (a summary, a report, an avatar's transcript.final turn); the privacy rules for what data holds are unchanged. Who acts: nobody; keep the marker with what you store. See Telling people it's an AI.
  • createSessionToken: disclosure adds label (the short label for the video, in the session's language, or the avatar's own wording), language, display ("whole_session"), rule and ownLabel (true only where an owner has confirmed in the console that your app shows its own AI label). text is now in the session's language when the avatar has no wording of its own. Who acts: apps that draw their own screen keep label visible 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.greeting is 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_token and 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_disclosure is always true: createPersona and updatePersona still accept it, ignore false, and read back true, and a false stored before reads as true. settings.ai_disclosure_text may change only to wording that still says plainly that it's an AI (AI, or "artificial intelligence" in the avatar's language): other wording is 400 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's disclosure.required is always true. Who acts: apps that hid the disclosure when required was 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 still 409 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? } gives 201 { token, expiresAt } for a portal of the key's organisation (portal_… or the bare id; another organisation's is 404, an archived one 410), 10 minutes unless asked and never more than 15. A field of the wrong type, or one the token's rules refuse, is 400 invalid_request naming it in param; 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. A name keeps 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:read opens the event log, and each event also needs the scope that reads its kind of record: sessions, practice, sign-ins and ratings sessions:read; transcript.final and session.transcript_ready transcripts:read; reports, scores, reviews, certificates and completions results:read; assigned work assignments:read; candidate.* candidates:read; leads, bookings, hand-offs, forgotten details and knowledge gaps inbox:read; members, units and consent people:read; portals, subjects and jobs portals:read; the widget widgets:read; switches settings:read; knowledge documents knowledge:read; voices voices:read; usage usage:read; webhook tests webhooks:manage. Anything else is left out of the list (asking for it by type included) and 404 one by one. Webhooks are unchanged. Who acts: a key reading the log that needs more kinds of events adds their scopes.
  • createPortal with personaId, updatePortal pointing it at a persona that isn't the portal's own, and updatePortalPersona on a portal that speaks with such a persona (a standalone persona, a website widget's or another portal's) need personas:write as well as portals:write (403): that persona speaks elsewhere too. A portal's own persona is changed with portals:write as before. Who acts: keys with portals:write but not personas:write that point portals at personas.
  • getCandidate, listCandidates, decideCandidate: reviews.recommendations (how many reviewers recommend advance, hold or do not advance) is null unless the role shares scores (candidates.share_scores), and once a candidate is erased, like score, band and rank.
  • listResults: externalUserId needs people:read (403, param: query.externalUserId), as the externalId it finds people by does.
  • updateInboxItem: the answer carries the visitor's contact, booking and consent only for a key that also holds inbox:read (else null); moving the item on is unchanged.
  • avatarPreview: your organisation's own pictures need a key holding avatars:read (any other key gets 404, 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 session listSessions never shows (a job interview, a builder's preview, a child's session) is 404, not only its words.
  • Every paged list: a cursor must be one this list gave; one naming another organisation's record is 400 invalid_request (param: query.cursor), never a page.
  • createWebhook and createSessionToken: a replayed answer (Idempotency-Key) keeps its signing secret or join token sealed while it is stored. Webhook deliveries, knowledge by url and 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 scope says what was granted, which can be narrower than what was asked. A scope Zentiq doesn't have (or never gives an app) is invalid_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 answers confirmation_required with a preview and a confirmationCode; 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 a notice that it is data, never instructions.
  • Tool annotations: only reads say readOnlyHint; every update, delete, end or removal says destructiveHint; tools that reach outside Zentiq (webhooks, invitations, speaking into a live session, fetching a web page) say openWorldHint.
  • 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, never initialize); 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)#

  • createCustomerToken takes contact: { 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 is 400 invalid_request with param: 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}) and testConnection (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 with authorizeUrl, where an owner approves it while signed in to the console. Connecting and testing are 403 forbidden while the organisation's "Connect your own systems" switch is off; listing (with on: 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 needs connections:write beside events:read.
  • A connection's id is conn_… (listed with the other prefixes); in the path, anything else is 404 not_found.
  • 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_case on 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's reference, kind and summary, optional facts, firstName, email, opening, expiresInDays (7, at most 30) and maxOpens (20, at most 100). The answer's url is shown once, never again. 409 conflict with details.reason case_links_off or door_closed. listCaseLinks (GET /portals/{id}/case-links), getCaseLink (GET /case-links/{id}) and revokeCaseLink (DELETE /case-links/{id}) give a link's state, never the case's details. A link's id is clk_….
  • 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's summary only with includeConversation, and never for a visitor who may be under 18. In the event log it needs cases:write beside events:read.
  • Verified customer: the email on a case link is the customer's contact on record; after the code, a call's verification.contactFrom is "case".

Sessions (every contract; privacy fixes are not versioned)#

  • listSessions lists 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=api lists 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 carries mode, subjectId (sub_…, bare for keys on 2026-07-08) and kind (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, sessionAnalytics and getResultReport: 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; forbidden for keys on 2026-07-08) or has no summary, whoever ran the session.
  • getSession, sessionAnalytics follow 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. New languageMode (follow switches to the person's language, fixed answers 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) carry Retry-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, and interrupt doesn'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's endSession: true doesn't end the session (it keeps running, and using minutes, until you end it with POST /sessions/{id}/end), and turnId and eventId, which bind a line to a turn in external turn mode, don't apply.
  • getSession, sessionAnalytics: the report's score, percent and pass are also null for 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.ended goes 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. createPerson finds or adds by externalId (your own id, the one sync key embed tokens use too: a repeat returns the same person with 200), else by email (an email another account already has is 409 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, createPerson too): people:write alone 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_consent turns active only through guardian consent, and consent is never recorded on a place that is switched off. consent.recorded says 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, addMembership and updateMembership take ageBand: "18+" on a learner's place, and every Membership says ageBand ("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 is 409 conflict (details.reason: "adult_learners_off"); on any other role's place it is 400 (param: ageBand). Every other protection for young learners stays. Who acts: tutoring businesses and schools with adult learners.
  • deleteMembership never 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.externalId needs people:read. Filters: portalId, subjectId, unitId, personId, externalUserId, release, passed, from, to, updatedAfter (anything that changed the result since).
  • getResultReport (beta; results:read and transcripts: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_conversation for 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 been 201 Created.
  • revokeKey (DELETE /keys/{id}): the reference showed { deleted, id }; the answer has always been { revoked: true, id } (now documented as KeyRevoked). Revoking a revoked key no longer moves its revokedAt.
  • sessionTranscript (GET /sessions/{id}/transcript): the reference showed each turn with at; turns have always carried role, text and ts (when the turn was stored), a duo's speaker, and words with timings where speech recognition gave them, plus kept. Only recorded turns are listed (an entry without a role and text is not a turn).
  • listSessions, getSession, steerSession, endSession, deleteSession: a session's status is ACTIVE, COMPLETED or ABANDONED (the reference said ENDED, which was never sent), and it has always carried turnMode and autoRespond.
  • createWebhook (POST /webhooks): the reference said 200; the answer has always been 201 Created.
  • webhookDeliveries (GET /webhooks/deliveries): a delivery's status is null when its last attempt got no answer (a refused address, a timeout, a network error); the reference showed it as always a number.
  • getPortalPersona and updatePortalPersona (/portals/{id}/persona): data is null for a portal nobody speaks on yet; the reference showed it as always a DashboardPersona.

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 an ETag), updateSubjectContent (always a draft; lists replace the whole list, object sections change only the fields sent, facts also by key; If-Match or baseVersion → 409 version_conflict; 400 with the document's own paths), listSubjectVersions, getSubjectGoLive, publishSubject (the go-live check 422, the round lock 409, the plan 403). Every draft saved sends subject.draft_saved.
  • updateSubject (new, content:write): PATCH /subjects/{id} changes a subject's name, its unit (unitId, null for the organisation level) and, for a website subject, its language rule for visitors (visitorLanguage: follow, listed or english; null goes back to the organisation's setting), which applies to the next conversation at once. visitorLanguage on another kind of subject, or where the organisation has locked it, is 409 conflict; a website widget's subject changes with its avatar (409 conflict). Answers the subject. Who acts: nobody.
  • updateSubjectContent: facts.assistant_name is always the name of the avatar that speaks, which is set on each portal (updatePortalPersona, name): a changed value is 400 invalid_request (param: body.set.facts.assistant_name); it used to be stored and never used. The value getSubjectContent gives 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, has subjectId: 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) and updateVisitorDoor (the websites allowed to frame it and start conversations, and opening or closing it: portals:publish).
  • listPortals is paged (limit, cursor, nextCursor; oldest first, as before), takes unitId, and never lists the hidden portals behind personas' website widgets. A portal adds group, addresses, unitId, brand, domain, and each subject template (type stays, the same value), kind, group, screening and hasDraft.
  • listPortalTemplates adds kind, duo, requires and includedInPlan, and lists the duo templates where the duo builder is open for the organisation.
  • createPortal builds 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 holding portals:publish (without it the door stays shut, and visitor in loginMethods is 403, as on updatePortal); a duo persona must be live (409 duo_not_live) where the duo builder is open (404 otherwise). Fields are checked against their documented limits (400 with param: name up to 80, welcome up to 160, known loginMethods, at most 12 templates of up to 60 characters each, loginMethods not empty; they used to be cut or dropped: the first 12 templates were kept).
  • updatePortal adds unitId, personaId, brand.mode and brand.hideZentiqBadge; opening a portal to visitors (adding visitor) needs portals:publish and runs the go-live check on a published portal (422). A website widget's portal is 409 conflict to change (and 404 to read), here and on publish, persona, doors and embed tokens.
  • B4 updatePortalPersona takes the face and voice by id (avatarId, voiceId); face and voice by name still work and are deprecated. Answers add id, avatarId and voiceId.
  • createEmbedToken answers in the standard envelope (error: null, requestId), takes unitId and consent: { by: "organisation" } (the organisation attests a young learner's consent, with people:write as well; not where consent is collected per learner in Zentiq), finds the person by the same externalId as POST /people, and records a unit it makes (unit.created). A retry makes a new token: an answer holding one is never replayed.
  • planSetup plans 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, and createdAt is 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; a 3xx is a failed attempt (redirects are never followed, as the docs said; 307/308 used to be); the address is checked before every attempt; new headers X-Zentiq-Event-Id and X-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.ended goes 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 only candidate.* (no report.produced, visitor.session.ended, session.ended or rating for it).
  • transcript.final must be named in a webhook's events ("*" never includes it) and needs includeConversation. report.produced sends its summary and report.summary with includeConversation, 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.captured or booking.requested, even with includeConversation (and their details are never captured). visitor.detail_removed no longer sends reason (it could say the visitor is a child): only which fields to delete.
  • A reviewer's decision on a scored result is its own assessment.completed (its own evt_ id, reviewed: true, the decision, the marker's result under ai): one per decision, the newest per sessionId is 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.completed no longer carries pass: it was always true, 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.failed adds errorCode; candidate events add externalId (your ATS's id; never an email address) and resent; the three visitor-request events add verified and portalId (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 /webhooks accepts only types a webhook can receive (400 naming anything else; it used to drop unknown names and accept never-sent ones); includeConversation: true needs transcripts:read and every change of it is recorded in the organisation's activity log; on a webhook that receives words, changing its url, events or apiVersion, or active: true switching it back on, needs transcripts:read too (403; switching the words off never does); Idempotency-Key is honoured; new description. A webhook shows description, pausedAt, apiVersion, updatedAt, and active: false while paused. New GET and PATCH /webhooks/{id}; GET /webhooks is paged; GET /webhooks/deliveries adds eventId, state, responseMs, errorCode, nextAttemptAt, filters and real paging.
  • New GET /events and GET /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 holding inbox:read, a candidate's events (candidate.*) only for one holding candidates:read; conversation fields only with ?includeConversation=true and transcripts:read. Webhooks themselves are unchanged. GET /events is limited as a search (120 a minute per key). New GET /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 and shareScores.
  • New POST /roles/{id}/invites (personal links, from an ATS with its own externalId and metadata; 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} and DELETE /invites/{id} (cancel a waiting invite).
  • New GET /roles/{id}/candidates and GET /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, band and rank only where the role's new switch candidates.share_scores is 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: decidedBy names 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.screened the invite (inviteId) and your externalId; candidate.decided the role, the portal, the invite and externalId; candidate.invited and candidate.invite_cancelled your externalId.
  • 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 /keys answers 403); an app or assistant connected to Zentiq never holds it, and no MCP tool needs it.
  • New GET /candidates/{id}/interview and GET /roles/{id}/interviews (candidates:read and candidates:interviews; the heavy rate 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 (practiceQuestion says 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 are 404; an erased candidate, a deleted interview and one the candidate ended themselves are 410 gone (details.reason). GET /roles/{id}/interviews lists oldest first, since for syncing, 20 a page (at most 50). Webhooks still carry no words: fetch the interview on candidate.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 / 404 as before).

The Serve inbox (new; inbox:read / inbox:write, opt-in for full-access keys made before 2026-10-01)#

  • New GET /inbox/items and GET /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; filters portalId, subjectId, personaId, status, kind, overdue, createdAfter/createdBefore, and updatedAfter with order=asc for syncing a CRM. Each item has the visitor's contact details (they are the business's own customers), consent (marketing is 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's reason, a lead's answers) only for a key holding transcripts: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 as lead.updated or booking.updated with by = the key; a stale expectedStatus is 409 conflict with details.currentStatus.
  • lead.captured, booking.requested and handoff.requested record verified (false when the request was read only from the visitor's page, not the server's own record of the conversation) and the portalId the 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 at url with staffToken (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) and 502 upstream_error. Not offered to assistants over MCP.
  • New webhook events handoff.joined and handoff.left: who from your team joined or left a visitor's call (their staffUserId, 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 }: url is 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 on serve.agent_ready and opted that page in (never a page for children or a candidate's interview): any other address is 404 not_found. Limited per caller and per page (429 rate_limited with Retry-After); 503 capacity when 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.json on 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 on serve.site_guide and sets up the widget's guide: it points at, scrolls to and opens the parts and pages you listed, fills in a field marked data-zentiq-fillable after the visitor confirms, and presses one marked data-zentiq-clickable. A single-page app opens its own pages with ZentiqWidget.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 own ZentiqWidget.onAction can't take a site_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 answers 201 { id, url, token, expiresAt }: show the customer url, 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_found and 410 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's reference and the outcome (resolved, handoff, booking, lead, unresolved, no_conversation). A summary only to a webhook made with includeConversation, where the words may leave; never for a candidate's interview. In the event log (listEvents, getEvent) it needs sessions:read beside events: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) with hiring.us_illinois, hiring.us_nyc and hiring.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, with status (open, paused while a legal hold keeps the interview, done), dueAt, autoDeleteAt, overdue and outcome. Filter by status and kind; 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, and RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (a 429 adds Retry-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) and details. An id of another kind (sess_… where a key id belongs) is 404 not_found.
  • The developer API is checked against the plan on every request (403 upgrade_required, feature api.access).
  • GET /me: plan.features, rateLimits, and on apiKey: effectiveScopes, fullAccess, apiVersion.
  • GET /keys is paged (limit, cursor, nextCursor) and lists apiVersion and createdBy (usr_… or key_…).
  • POST /keys: apiVersion (default: the request's contract), every scope of the catalogue, and Idempotency-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 is 400 invalid_request naming it (param), where before some were ignored. null still means "not sent". createSessionToken honours Idempotency-Key per 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. sessionTranscript needs transcripts:read (held by every key made before 2026-10-01 that holds sessions:read).
  • SDKs 1.0.0 (@zentiq/node, @zentiq/client, @zentiq/react, @zentiq/mcp, and zentiq for Python) speak this contract, with methods named after every operation id. Who acts: users of @zentiq/node and @zentiq/client 0.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 /docs address, 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-Key is now honoured on createPersona, createKnowledgeBase, createAvatar, publishPortal and planSetup as well: a retry replays the first answer, and the same key with another request is 409 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 its Idempotency-Key after 10 minutes (createSessionToken: 90 seconds, as before). Who acts: integrations that reuse one Idempotency-Key across 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, deleteSession and every other write), counted across all servers. planSetup is 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 the RateLimit-* headers describe the budgets above: a key can be refused (429, with Retry-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's id is evt_ followed by the recorded event's own id (it was evt_ 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 createKey and apiVersion: "2026-07-08".
  • webhookDeliveries: a limit over 200, or one that isn't a number, is 400 (it was read as 200, or as the default).
  • createKey: a body that isn't JSON, scopes that isn't a list, or a name that isn't text is 400 (such a request used to make a full-access key named "API key").
  • searchKnowledge: topK is a whole number from 1 to 50 and minScore a number from 0 to 1; anything else is 400 (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 deprecated face and voice by name follow the current names. Who acts: code that names a face or voice: use avatarId and voiceId.

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/portals with publish: true answers 201 and stays a draft when the check refuses, with publishRefused saying what to finish first; POST /v1/portals/{id}/publish answers 200, unchanged, with publishRefused (on the 2026-10-01 contract that publish is 422 go_live_refused). An embed token needs a published portal. Who acts: flows that publish and then mint embed tokens: read status or publishRefused.
  • 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's summary is null without it. lead.captured, booking.requested and handoff.requested keep the visitor's contact details; their summary, reason and the visitor's own words need includeConversation. safety.flagged is 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.recorded says by: "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 on includeConversation.
  • Sessions: PATCH /v1/sessions/{id}, POST /v1/sessions/{id}/say, POST /v1/sessions/{id}/end and DELETE /v1/sessions/{id} reach only sessions started through the API (any other is 404 not_found). Transcripts and summaries refuse a child's conversation. POST /v1/sessions/tokens starts sessions exactly as the console does; a persona with a studio voice speaks with that voice's own engine.
  • Personas: GET /v1/personas lists standalone personas only; a portal's own persona is read and changed with GET/PUT /v1/portals/{id}/persona, which answers 410 gone for an archived portal and 413 past the console's limits (character 4,000, greeting 300, name 80 characters).
  • Knowledge: a failed document's error is one plain sentence, never another service's message.

Faces, voices, personas, knowledge and languages (every contract unless it says otherwise)#

  • A persona's settings is 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.engine says the engine the persona really runs on, decided by its voice: global for a studio voice, premier for your organisation's cloned voices (it used to repeat a stored switch that the live session had stopped following for studio voices).
  • GET /personas takes kind (single or duo); a persona made from /duos names it in duoId.
  • New on a persona: cast ({ kind: "single" | "duo", people: [{ name }] }) and settings.languageMode (follow: it answers in the language people speak to it, starting in language; fixed, the default: it keeps to language). languageMode can be set on create and update, and the website widget follows it.
  • createPersona and updatePersona save 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). settings takes the documented keys (PersonaSettingsInput; others are ignored, as before) and a key of the wrong type is now 400 naming it; on an update a setting sent as null goes back to its default. avatarId or voiceId sent as null is 400 (a persona keeps a face and a voice: send another one); it used to clear them.
  • POST /voices and POST /knowledge/bases/{id}/documents honour Idempotency-Key.
  • Every error of the face, voice, persona, knowledge and duo operations names the input at fault in param; a 409 says why in details.reason (in_use, duo_studio_only, duo_keeps_picture, duo_picture_not_single, name_taken). A field over its length is 413 too_large for keys on 2026-10-01 (invalid_request with 413, as before, on 2026-07-08).
  • POST /avatars with an imageUrl that can't be fetched no longer repeats the fetch's own error text.
  • GET /avatars lists 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 filters kind, gender, ageBand, heritage, and the facets on every avatar (null for your own pictures). GET /avatars/{id} of a duo's picture is 404 (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 is 404 (it used to be 410 with the picture kept). Still 409 while an active persona wears it.
  • A voice says the engine it runs on (engine: global for the studio voices, premier for cloned voices) and its gender (studio voices); languages now comes from the catalogue by engine: every catalogue language for a studio voice, the ten marked duo for a cloned voice (it used to claim every language for every voice). GET /voices takes kind and gender. 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 GET and PUT /personas/{id}/widget (widgets:read, widgets:write): a persona's website widget, as its page in the console has it. The first PUT makes it (off); it takes how it shows (layout floating or inline, initialState minimized or expanded, position one of four corners, label, accent, start), allowedOrigins, requestEmail, purpose and on. on: true needs portals:publish and the plan's website assistants and website embedding (duos for a duo); what it still needs is 422 go_live_refused with details.problems ([{ field, message }]) and the widget in details.widget. While it is on, changing requestEmail or allowedOrigins needs portals:publish too (403). The answer has steps, live, publicId, the snippet to paste and configUrl; its portal never appears in GET /portals.
  • New duo operations (duos:read, duos:write; keys made before 2026-10-01 hold them through personas:*): 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_…) and POST /duos/{id}/publish (it is tested and goes live on its own; 409 says what is missing). While duos are closed to the organisation every one but the access check is 404. A duo speaks the ten languages its languages lists; change it through /duos, never PATCH /personas/{id} (409, details.reason: "duo_studio_only").
  • A knowledge pack's documentCount counts only documents that aren't deleted (it counted deleted ones too); new readyCount. DELETE /knowledge/bases/{id} detaches the pack from every persona that had it, as the console does, and says how many in detachedFrom; deleting it again is 410.

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 (premier or global), with settings.globalVoice for 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_language and settings.multilingual (ignored on input, never returned), and multilingual on voices and on GET /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 the X-Request-Id header (plan refusals kept their own shape until 2026-10-01). Success answers were unchanged: { "data", "error": null, "requestId" }, with nextCursor on lists.
  • Not changed: authentication, scopes, rate limits, pagination, idempotency, webhook signing, event names and paths.