Skip to the content
Menu: Portals

Portals

Portals built from templates, and their doors.

GET/v1/portals

List portals

listPortalsportals:readread: 600/min per key

The organisation's portals, oldest first, a page at a time. Published ones unless status says otherwise; archived ones are never listed, and neither are the hidden portals behind personas' website widgets.

Query parameters

  • limitinteger

    Page size, 1-200 (default 50).

  • cursorstring

    The nextCursor of the previous page.

  • status"published" | "draft" | "all"

    published (the default), draft, or all (both). Archived portals are never listed.

  • unitIdstring

    Only the portals of this team or class (unit_…).

Returns

200 A page of portals. A page: data is a list of Portal, and nextCursor fetches the next page.

Fields of data
  • idstringrequired

    Portal id (portal_…).

  • namestringrequired
  • slugstringrequired
  • status"draft" | "published" | "archived"required

    draft until published; people only reach a published portal.

  • group"learn" | "train" | "serve" | "assess" | "mixed" | nullrequired

    Its subjects' group, mixed when they differ; null without subjects.

  • welcomestring | nullrequired
  • addressstringrequired

    Where people open it: the sign-in page, or the verified custom domain.

  • addressesobjectrequired
    • membersstringrequired

      The sign-in page (/d/<org>/<portal>).

    • visitorsstring | nullrequired

      The visitor page (/v/<org>/<portal>) when a subject opens to visitors; null otherwise.

    • embedstringrequired

      What an iframe mounts, with ?t=<embed token>.

  • loginMethods("email" | "login_card" | "class_code" | "visitor")[]required
  • unitIdstring | nullrequired

    The team or class it is for; null = the whole organisation.

  • personaIdstring | nullrequired

    Who speaks on it.

  • brandobjectrequired
    • primarystring | nullrequired

      #rrggbb

    • logoUrlstring | nullrequired
    • mode"light" | "dark" | "system" | nullrequired
  • domainobject | nullrequired

    The organisation's own address for it, when one is set.

    • namestringrequired
    • verifiedbooleanrequired
    • verifiedAtstring (date-time) | nullrequired
  • subjectsPortalSubject[]required
  • createdAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • updatedAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • publishRefusedstring

    Only when a publish was asked for and the go-live check refused it (POST /portals with publish: true; POST /portals/{id}/publish for keys on 2026-07-08): what to finish first. The portal stays a draft.

Errors

invalid_requestunauthorizedforbiddenupgrade_requiredrate_limitedserver_error

Request

curl "https://app.zentiqai.com/v1/portals?limit=20" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY"
Example answer
Response
{
  "data": [
    {
      "id": "portal_cmfq2b7x10000abcd1234",
      "name": "Zoe",
      "slug": "…",
      "status": "draft",
      "group": "learn",
      "welcome": "…"
    }
  ],
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d",
  "nextCursor": null
}
POST/v1/portals

Build a portal from templates

createPortalportals:writewrite: 120/min per keyIdempotency-Key

Builds a portal with one subject per template (GET /portals/templates), and its own persona unless personaId is given. It starts as a draft: nobody meets it half set up. With publish: true (needs portals:publish) it is published once it passes the go-live check, together with its subjects; otherwise it stays a draft and publishRefused says what to finish first. Each template's family must be on the plan and one more portal must fit it (403 upgrade_required). A duo persona speaks only once it is live (409 duo_not_live). Website and screening portals open to visitors (their door opens once published) for a key holding portals:publish; without it they stay closed to visitors, and visitor in loginMethods is 403, as on update. Speaking with an existing persona (personaId) needs personas:write as well: that persona speaks elsewhere too (403).

Body

  • namestringrequired

    1-80 characters

  • templatesstring[]

    Template keys from GET /portals/templates; one subject each.

    at most 12 items

  • templatestringdeprecated

    One template (the 2026-07-08 form of templates).

    1-60 characters

  • personaIdstring

    A persona of yours to speak on every subject; left out = the portal gets its own.

  • unitIdstring | null

    The team or class it is for; left out or null = the whole organisation.

  • welcomestring | null
  • loginMethods("email" | "login_card" | "class_code" | "visitor")[]
  • publishboolean

    Publish it once it passes the go-live check (needs portals:publish); otherwise it stays a draft and publishRefused says why.

Returns

201 The portal (a draft unless it was published). data is Portal.

Fields of data
  • idstringrequired

    Portal id (portal_…).

  • namestringrequired
  • slugstringrequired
  • status"draft" | "published" | "archived"required

    draft until published; people only reach a published portal.

  • group"learn" | "train" | "serve" | "assess" | "mixed" | nullrequired

    Its subjects' group, mixed when they differ; null without subjects.

  • welcomestring | nullrequired
  • addressstringrequired

    Where people open it: the sign-in page, or the verified custom domain.

  • addressesobjectrequired
    • membersstringrequired

      The sign-in page (/d/<org>/<portal>).

    • visitorsstring | nullrequired

      The visitor page (/v/<org>/<portal>) when a subject opens to visitors; null otherwise.

    • embedstringrequired

      What an iframe mounts, with ?t=<embed token>.

  • loginMethods("email" | "login_card" | "class_code" | "visitor")[]required
  • unitIdstring | nullrequired

    The team or class it is for; null = the whole organisation.

  • personaIdstring | nullrequired

    Who speaks on it.

  • brandobjectrequired
    • primarystring | nullrequired

      #rrggbb

    • logoUrlstring | nullrequired
    • mode"light" | "dark" | "system" | nullrequired
  • domainobject | nullrequired

    The organisation's own address for it, when one is set.

    • namestringrequired
    • verifiedbooleanrequired
    • verifiedAtstring (date-time) | nullrequired
  • subjectsPortalSubject[]required
  • createdAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • updatedAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • publishRefusedstring

    Only when a publish was asked for and the go-live check refused it (POST /portals with publish: true; POST /portals/{id}/publish for keys on 2026-07-08): what to finish first. The portal stays a draft.

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundconflictduo_not_liveidempotency_conflictidempotency_in_progressrate_limitedserver_error

Events

portal.createdworkspace.createdportal.updatedworkspace.published

Request

curl -X POST "https://app.zentiqai.com/v1/portals" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Zoe"
}'
Example answer
Response
{
  "data": {
    "id": "portal_cmfq2b7x10000abcd1234",
    "name": "Zoe",
    "slug": "…",
    "status": "draft",
    "group": "learn",
    "welcome": "…"
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
GET/v1/portals/templates

Portal templates (the gallery)

listPortalTemplatesAny keyread: 600/min per key

The templates POST /portals builds subjects from, with what each needs from the plan and whether the organisation's plan includes it. Templates written for two people (duos) are listed where the duo builder is open for the organisation.

Returns

200 The templates. data is a list of PortalTemplate.

Fields of data
  • keystringrequired

    What POST /portals takes in templates.

  • group"Learn" | "Train" | "Serve" | "Assess"required
  • labelstringrequired
  • descriptionstringrequired
  • availablebooleanrequired

    It can be built today.

  • kind"tutor" | "roleplay" | "assessor" | "service"required

    What the avatar does: tutor teaches, roleplay plays someone to practise with, assessor asks and scores, service talks to website visitors.

  • duobooleanrequired

    Written for two people in one conversation (needs a duo).

  • requiresstring[]required

    The plan features it needs (its portal family, candidate screening, ready-made duos).

  • includedInPlanbooleanrequired

    The organisation's plan has every feature it needs.

Errors

invalid_requestunauthorizedforbiddenupgrade_requiredrate_limitedserver_error

Request

curl "https://app.zentiqai.com/v1/portals/templates" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY"
Example answer
Response
{
  "data": [
    {
      "key": "sales-trainer",
      "group": "Learn",
      "label": "…",
      "description": "…",
      "available": true,
      "kind": "tutor"
    }
  ],
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
GET/v1/portals/{id}

Get a portal

getPortalportals:readread: 600/min per key

One portal, draft or published. An archived one is 410 gone.

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Returns

200 The portal. data is Portal.

Fields of data
  • idstringrequired

    Portal id (portal_…).

  • namestringrequired
  • slugstringrequired
  • status"draft" | "published" | "archived"required

    draft until published; people only reach a published portal.

  • group"learn" | "train" | "serve" | "assess" | "mixed" | nullrequired

    Its subjects' group, mixed when they differ; null without subjects.

  • welcomestring | nullrequired
  • addressstringrequired

    Where people open it: the sign-in page, or the verified custom domain.

  • addressesobjectrequired
    • membersstringrequired

      The sign-in page (/d/<org>/<portal>).

    • visitorsstring | nullrequired

      The visitor page (/v/<org>/<portal>) when a subject opens to visitors; null otherwise.

    • embedstringrequired

      What an iframe mounts, with ?t=<embed token>.

  • loginMethods("email" | "login_card" | "class_code" | "visitor")[]required
  • unitIdstring | nullrequired

    The team or class it is for; null = the whole organisation.

  • personaIdstring | nullrequired

    Who speaks on it.

  • brandobjectrequired
    • primarystring | nullrequired

      #rrggbb

    • logoUrlstring | nullrequired
    • mode"light" | "dark" | "system" | nullrequired
  • domainobject | nullrequired

    The organisation's own address for it, when one is set.

    • namestringrequired
    • verifiedbooleanrequired
    • verifiedAtstring (date-time) | nullrequired
  • subjectsPortalSubject[]required
  • createdAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • updatedAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • publishRefusedstring

    Only when a publish was asked for and the go-live check refused it (POST /portals with publish: true; POST /portals/{id}/publish for keys on 2026-07-08): what to finish first. The portal stays a draft.

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundgonerate_limitedserver_error

Request

curl "https://app.zentiqai.com/v1/portals/ID" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY"
Example answer
Response
{
  "data": {
    "id": "portal_cmfq2b7x10000abcd1234",
    "name": "Zoe",
    "slug": "…",
    "status": "draft",
    "group": "learn",
    "welcome": "…"
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
PATCH/v1/portals/{id}

Update a portal

updatePortalportals:writewrite: 120/min per key

Changes its name, the sign-in page's welcome line, how people sign in, its team, who speaks on it or its brand: the changes the console's Settings make, with its rules. Adding visitor to loginMethods opens it to visitors (needs portals:publish; on a published portal, the go-live check runs first: 422 go_live_refused). Publishing is POST /portals/{id}/publish. Pointing it at a persona that isn't its own (personaId: a standalone persona, a website widget's or another portal's) needs personas:write as well (403). An archived portal is 410 gone; a website widget's portal is 409 conflict (change it with PUT /personas/{id}/widget).

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Body

  • namestring

    1-80 characters

  • welcomestring | null
  • loginMethods("email" | "login_card" | "class_code" | "visitor")[]

    Adding visitor opens it to visitors: needs portals:publish, and runs the go-live check on a published portal.

  • unitIdstring | null
  • personaIdstring

    One of your personas to speak on it instead of its own.

  • brandobject
    • primarystring | null

      #rrggbb or a colour name (green); null goes back to the organisation's colour.

    • mode"light" | "dark" | "system" | null
    • hideZentiqBadgeboolean | null

      Honoured when the plan includes removing the badge.

Returns

200 The portal as it now stands. data is Portal.

Fields of data
  • idstringrequired

    Portal id (portal_…).

  • namestringrequired
  • slugstringrequired
  • status"draft" | "published" | "archived"required

    draft until published; people only reach a published portal.

  • group"learn" | "train" | "serve" | "assess" | "mixed" | nullrequired

    Its subjects' group, mixed when they differ; null without subjects.

  • welcomestring | nullrequired
  • addressstringrequired

    Where people open it: the sign-in page, or the verified custom domain.

  • addressesobjectrequired
    • membersstringrequired

      The sign-in page (/d/<org>/<portal>).

    • visitorsstring | nullrequired

      The visitor page (/v/<org>/<portal>) when a subject opens to visitors; null otherwise.

    • embedstringrequired

      What an iframe mounts, with ?t=<embed token>.

  • loginMethods("email" | "login_card" | "class_code" | "visitor")[]required
  • unitIdstring | nullrequired

    The team or class it is for; null = the whole organisation.

  • personaIdstring | nullrequired

    Who speaks on it.

  • brandobjectrequired
    • primarystring | nullrequired

      #rrggbb

    • logoUrlstring | nullrequired
    • mode"light" | "dark" | "system" | nullrequired
  • domainobject | nullrequired

    The organisation's own address for it, when one is set.

    • namestringrequired
    • verifiedbooleanrequired
    • verifiedAtstring (date-time) | nullrequired
  • subjectsPortalSubject[]required
  • createdAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • updatedAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • publishRefusedstring

    Only when a publish was asked for and the go-live check refused it (POST /portals with publish: true; POST /portals/{id}/publish for keys on 2026-07-08): what to finish first. The portal stays a draft.

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundconflictduo_not_livegonego_live_refusedrate_limitedserver_error

Events

portal.updated

Request

curl -X PATCH "https://app.zentiqai.com/v1/portals/ID" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
Example answer
Response
{
  "data": {
    "id": "portal_cmfq2b7x10000abcd1234",
    "name": "Zoe",
    "slug": "…",
    "status": "draft",
    "group": "learn",
    "welcome": "…"
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
GET/v1/portals/{id}/persona

Who speaks on a portal

getPortalPersonaportals:readread: 600/min per key

An archived portal is 410 gone.

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Returns

200 Who speaks. data is PortalPersona.

Fields of data
  • idstringrequired

    Persona id (pa_…).

    pattern `^pa_.*`

  • namestringrequired
  • facestring | nullrequired

    The face's name.

  • voicestring | nullrequired

    The voice's name.

  • avatarIdstring | nullrequired
  • voiceIdstring | nullrequired
  • accentstring | nullrequired
  • greetingstring | nullrequired
  • characterstringrequired
  • languagestringrequired

    The language it speaks (a name from GET /languages): English unless set.

  • readybooleanrequired

    It has a face and a voice: people can talk to it.

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundgonerate_limitedserver_error

Request

curl "https://app.zentiqai.com/v1/portals/ID/persona" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY"
Example answer
Response
{
  "data": {
    "id": "pa_cmfq2b7x10000abcd1234",
    "name": "Zoe",
    "face": "…",
    "voice": "…",
    "avatarId": "av_cmfq2b7x10000abcd1234",
    "voiceId": "vo_cmfq2b7x10000abcd1234"
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
PUT/v1/portals/{id}/persona

Change who speaks on a portal

updatePortalPersonaportals:writewrite: 120/min per key

The console's Persona tab over the API. The face and voice by id (avatarId from GET /avatars, voiceId from GET /voices); face and voice by name still work and are deprecated. accent in plain words (British, Australian). character and greeting are set word for word within the console's limits (character 4,000, greeting 300, name 80 characters; longer is 413). A duo keeps its two people (409 conflict). With portals:write this changes the portal's own persona only; one it borrows (a standalone persona, a website widget's or another portal's) changes wherever else it speaks, so it needs personas:write as well (403). An archived portal is 410 gone.

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Body

  • avatarIdstring

    A face (av_…) from GET /avatars.

  • voiceIdstring

    A voice (vo_…) from GET /voices.

  • facestringdeprecated

    A library face by first name ("Marcus"). Prefer avatarId.

    at most 40 characters

  • voicestringdeprecated

    A studio voice by name ("James"). Prefer voiceId.

    at most 40 characters

  • accentstring

    In plain words ("British").

    at most 60 characters

  • characterstring

    Who they are and how they behave, word for word (up to 4,000 characters; longer is 413).

  • greetingstring

    Their first line (up to 300 characters).

  • namestring

    Up to 80 characters.

  • languagestring

    The language it speaks: a name or code from GET /languages ("German", "es"); one it doesn't know is 400. A duo speaks one of its ten.

    at most 80 characters

Returns

200 Who speaks now. data is PortalPersona.

Fields of data
  • idstringrequired

    Persona id (pa_…).

    pattern `^pa_.*`

  • namestringrequired
  • facestring | nullrequired

    The face's name.

  • voicestring | nullrequired

    The voice's name.

  • avatarIdstring | nullrequired
  • voiceIdstring | nullrequired
  • accentstring | nullrequired
  • greetingstring | nullrequired
  • characterstringrequired
  • languagestringrequired

    The language it speaks (a name from GET /languages): English unless set.

  • readybooleanrequired

    It has a face and a voice: people can talk to it.

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundconflictgonetoo_largerate_limitedserver_error

Events

portal.updated

Request

curl -X PUT "https://app.zentiqai.com/v1/portals/ID/persona" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
Example answer
Response
{
  "data": {
    "id": "pa_cmfq2b7x10000abcd1234",
    "name": "Zoe",
    "face": "…",
    "voice": "…",
    "avatarId": "av_cmfq2b7x10000abcd1234",
    "voiceId": "vo_cmfq2b7x10000abcd1234"
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
POST/v1/portals/{id}/publish

Publish a portal

publishPortalportals:publishwrite: 120/min per keyIdempotency-Key

Opens a draft (or brings back an archived) portal at its address, once it passes the go-live check (every subject set up with the organisation's own details, never a template's example content); its draft subjects are published with it. A refusal is 422 go_live_refused with details.problems for keys on 2026-10-01; keys on 2026-07-08 get the portal back unchanged (200) with publishRefused. Bringing back an archived portal must fit the plan's portal limit (403 upgrade_required). Already published: returned unchanged.

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Returns

200 The portal. data is Portal.

Fields of data
  • idstringrequired

    Portal id (portal_…).

  • namestringrequired
  • slugstringrequired
  • status"draft" | "published" | "archived"required

    draft until published; people only reach a published portal.

  • group"learn" | "train" | "serve" | "assess" | "mixed" | nullrequired

    Its subjects' group, mixed when they differ; null without subjects.

  • welcomestring | nullrequired
  • addressstringrequired

    Where people open it: the sign-in page, or the verified custom domain.

  • addressesobjectrequired
    • membersstringrequired

      The sign-in page (/d/<org>/<portal>).

    • visitorsstring | nullrequired

      The visitor page (/v/<org>/<portal>) when a subject opens to visitors; null otherwise.

    • embedstringrequired

      What an iframe mounts, with ?t=<embed token>.

  • loginMethods("email" | "login_card" | "class_code" | "visitor")[]required
  • unitIdstring | nullrequired

    The team or class it is for; null = the whole organisation.

  • personaIdstring | nullrequired

    Who speaks on it.

  • brandobjectrequired
    • primarystring | nullrequired

      #rrggbb

    • logoUrlstring | nullrequired
    • mode"light" | "dark" | "system" | nullrequired
  • domainobject | nullrequired

    The organisation's own address for it, when one is set.

    • namestringrequired
    • verifiedbooleanrequired
    • verifiedAtstring (date-time) | nullrequired
  • subjectsPortalSubject[]required
  • createdAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • updatedAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • publishRefusedstring

    Only when a publish was asked for and the go-live check refused it (POST /portals with publish: true; POST /portals/{id}/publish for keys on 2026-07-08): what to finish first. The portal stays a draft.

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundconflictidempotency_conflictidempotency_in_progressgo_live_refusedrate_limitedserver_error

Events

portal.updatedworkspace.published

Request

curl -X POST "https://app.zentiqai.com/v1/portals/ID/publish" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
Example answer
Response
{
  "data": {
    "id": "portal_cmfq2b7x10000abcd1234",
    "name": "Zoe",
    "slug": "…",
    "status": "draft",
    "group": "learn",
    "welcome": "…"
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
POST/v1/portals/{id}/embed-tokens

Sign one of your users in (an embed token)

createEmbedTokenembed:writewrite: 120/min per keyIdempotent by its input

Called by your backend after your own login, never from a browser. Returns a five-minute, single-use token for a published portal: embedUrl goes in an iframe (zentiq-embed.js or @zentiq/react's <ZentiqPortal>), url opens the portal as that user. The person is the one POST /people knows by the same externalId. Never someone who runs your Zentiq account. A leader beyond the plan's staff seats is 403 upgrade_required; a learner under the consent age waits for a guardian's consent unless consent: { by: "organisation" } attests it (with people:write as well). A retry makes a new token (nothing is replayed).

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Body

  • externalUserIdstringrequired

    Your own id for this person: the same person as POST /people with this externalId.

    1-120 characters

  • namestring

    at most 80 characters

  • emailstring

    at most 200 characters

  • role"learner" | "leader" | "observer"

    Default learner. A leader takes a staff seat.

  • unitIdstring

    Their team or class (unit_…).

  • unitNamestring

    Or a group by name, made under the portal's team on first sight.

    1-80 characters

  • gradeBandstring

    at most 8 characters

  • consentobject

    { by: "organisation" }: your organisation attests the consent a young learner needs (recorded as yours; needs people:write as well). Without it, a learner under the consent age waits for a guardian's consent.

    • by"organisation"required

Returns

201 The token. data is EmbedToken.

Fields of data
  • tokenstringrequired

    Five minutes, single use.

  • expiresAtstring (date-time)required

    ISO 8601 UTC timestamp.

  • urlstringrequired

    Opens the portal as that person (a full page).

  • embedUrlstringrequired

    What an iframe mounts (zentiq-embed.js, @zentiq/react's <ZentiqPortal>).

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundconflictidempotency_conflictidempotency_in_progressrate_limitedserver_error

Request

curl -X POST "https://app.zentiqai.com/v1/portals/ID/embed-tokens" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "externalUserId": "…"
}'
Example answer
Response
{
  "data": {
    "token": "…",
    "expiresAt": "2026-09-25T12:00:00.000Z",
    "url": "https://www.example.com",
    "embedUrl": "https://www.example.com"
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
GET/v1/portals/{id}/go-live

Check a portal can go live

getPortalGoLiveportals:readread: 600/min per key

Runs the go-live check a publish runs, and says what each subject still needs (its content's field, in plain words). Changes nothing.

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Returns

200 Whether it can go live, and what to finish first. data is PortalGoLive.

Fields of data

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundrate_limitedserver_error

Request

curl "https://app.zentiqai.com/v1/portals/ID/go-live" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY"
Example answer
Response
{
  "data": {
    "ready": true,
    "problems": [
      {
        "field": "hours",
        "message": "…",
        "short": "…",
        "subjectId": "sub_cmfq2b7x10000abcd1234",
        "subjectName": "…"
      }
    ],
    "warnings": [
      {
        "field": "hours",
        "message": "…",
        "short": "…",
        "subjectId": "sub_cmfq2b7x10000abcd1234",
        "subjectName": "…"
      }
    ]
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
POST/v1/portals/{id}/customer-tokens

Mint an identity token for a signed-in customer (Actions)

createCustomerTokencustomers:tokensread: 600/min per key

Called by your backend, where your customer is signed in, never from a browser. Returns a short-lived token naming that customer, for this portal only: give it to the website widget (data-customer-token, or ZentiqWidget.identify(token)) or to a conversation's start (customerToken), and the avatar's actions for signed-in customers run with that customer's id, never an id the visitor said. 10 minutes unless you ask; never more than 15 (a longer ttlSeconds is cut to 15: expiresAt says when). Mint one per page view. A token works for one conversation only: the first conversation that uses it keeps it, and any other refuses it even while it lives. A portal of another organisation is 404; an archived one 410. Each mint is recorded for your organisation with the portal, the key and how long the token lives, never who it names. A key that lives on your website's servers needs only this scope. Verified customer: contact.email is the address on record where Zentiq sends a one-time code before a sensitive action (when your organisation switches it on); it is signed into the token and never shown to the avatar or kept in a transcript or log. For a visitor who isn't signed in, an action's answer can name it instead, in the reserved field zentiq_verify: { email }.

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Body

  • customerIdstringrequired

    Your own id for the signed-in customer: sent to your system as customer.id on every call.

    at most 128 characters

  • namestring | null

    Shown to them as "Signed in as …".

    at most 80 characters

  • emailstring | null
  • authTimenumber | null

    When they signed in, seconds since 1970 (within the last 30 days).

  • ttlSecondsnumber | null

    How long the token lasts, in seconds. At most 900: a longer one is cut to 900 (expiresAt says when).

    at least 30 · default 600

  • contactobject | null

    Verified customer: the contact on record, where Zentiq sends a one-time code before a sensitive action (when the organisation switches it on). Signed into the token; never shown to the avatar, the conversation or a log, only as a masked read-back ("s***@example.com"). Use the address you would send a password reset to.

    • emailstring | null

      The customer's email address on record: where a one-time code is sent.

Returns

201 The token. data is CustomerToken.

Fields of data
  • tokenstringrequired

    zct1.…: for this portal and one conversation only. Give it to the website widget or a conversation's start; never put it in a URL.

  • expiresAtstring (date-time)required

    ISO 8601 UTC timestamp.

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundgonerate_limitedserver_error

Request

curl -X POST "https://app.zentiqai.com/v1/portals/ID/customer-tokens" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "customerId": "…"
}'
Example answer
Response
{
  "data": {
    "token": "…",
    "expiresAt": "2026-09-25T12:00:00.000Z"
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
POST/v1/portals/{id}/escalations

Hand a chat to the video avatar

createEscalationescalations:writewrite: 120/min per keySince 2026-10-01

Called by your chat tool's server (Intercom, Zendesk or your own), never from a browser. Returns a link to show the customer: it opens the portal's website avatar, which starts knowing the chat so far (as information, never as instructions) and opens with a one-line recap. The link works for one conversation, for 30 minutes. When that conversation ends, escalation.completed goes to your webhooks with the outcome (the summary only with includeConversation), and the visitor is offered returnUrl. Needs the switch serve.chat_escalation on and the portal's public page open (409 conflict, details.reason: switched_off, door_closed or no_website_subject). Chat handover is on Starter and up: on a plan without it, 403 upgrade_required with details.reason plan_required and details.plan. A portal of another organisation is 404; an archived one 410. A retry makes a new link (nothing is replayed).

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Body

  • transcriptChatTurnInput[]required

    The chat so far, oldest first: at most 50 turns and 20,000 characters in all (send the newest). The avatar reads it as information, never as instructions.

    at most 50 items

  • customerobject | null

    The customer, when your chat knows them.

    • externalIdstring | null

      Your own id for the customer: it comes back on escalation.completed as customerRef.

      at most 128 characters

    • firstNamestring | null

      The avatar greets them by it.

      at most 80 characters

    • emailstring | null

      Kept with the escalation only; never read out, never sent back.

  • reasonstring | null

    A short phrase finishing "your chat about …" ("the refund on order 1042"): the avatar opens with it.

    at most 120 characters

  • returnUrlstring | null

    An https address the visitor is offered at the end (back to your chat).

    at most 2,000 characters

Returns

201 The link. data is Escalation.

Fields of data
  • idstringrequired

    Escalation id (esc_…).

    pattern `^esc_.*`

  • urlstringrequired

    The link to show the customer (or open in the widget). It works once, for 30 minutes; the token is in its fragment, never sent to a server.

  • tokenstringrequired

    zesc_…: the link's own token (already in url). For one conversation only; never put it in a query string or a log.

  • expiresAtstring (date-time)required

    ISO 8601 UTC timestamp.

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundconflictgonerate_limitedserver_error

Request

curl -X POST "https://app.zentiqai.com/v1/portals/ID/escalations" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "transcript": [
    {
      "role": "visitor",
      "text": "…"
    }
  ]
}'
Example answer
Response
{
  "data": {
    "id": "esc_cmfq2b7x10000abcd1234",
    "url": "https://www.example.com",
    "token": "…",
    "expiresAt": "2026-09-25T12:00:00.000Z"
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
GET/v1/portals/{id}/doors

Every way in to a portal

listPortalDoorsportals:readread: 600/min per key

The addresses to hand out: the members' sign-in page, the visitors' page (whether it is open now, and what would open it), the embed for your signed-in users, and the custom domain.

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Returns

200 The doors. data is PortalDoors.

Fields of data
  • membersobjectrequired
    • urlstringrequired

      The sign-in page.

    • loginMethods("email" | "login_card" | "class_code" | "visitor")[]required
    • unitIdstring | nullrequired
  • visitorsVisitorsDoorrequired

    The visitors' door: people come in without signing in.

  • embedobjectrequired
    • urlstringrequired

      What an iframe mounts, with ?t= and a token from POST /portals/{id}/embed-tokens.

    • scriptstringrequired

      The snippet for a signed-in embed (your backend gets the token).

  • customDomainobject | nullrequired
    • namestringrequired
    • verifiedbooleanrequired
    • verifiedAtstring (date-time) | nullrequired

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundrate_limitedserver_error

Request

curl "https://app.zentiqai.com/v1/portals/ID/doors" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY"
Example answer
Response
{
  "data": {
    "members": {
      "url": "https://www.example.com",
      "loginMethods": [
        "email"
      ],
      "unitId": "unit_cmfq2b7x10000abcd1234"
    },
    "visitors": {
      "url": "https://www.example.com",
      "open": true,
      "readiness": {
        "open": true,
        "text": "…",
        "fix": "publish"
      },
      "allowedOrigins": [
        "…"
      ],
      "script": "…"
    },
    "embed": {
      "url": "https://www.example.com",
      "script": "…"
    },
    "customDomain": {
      "name": "Zoe",
      "verified": true,
      "verifiedAt": "2026-09-25T12:00:00.000Z"
    }
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}
PUT/v1/portals/{id}/doors/visitors

Open or close the visitors' door

updateVisitorDoorportals:writewrite: 120/min per key

Sets the websites that may show the visitor page and start conversations from it (allowedOrigins, at most 20; [] allows any), and opens or closes the door (open). Opening needs portals:publish; on a published portal it runs the go-live check on what the door opens (422 go_live_refused), and it lets outside people talk to the organisation's avatars (its public website switch). Only a portal with a website assistant or a screening role has a visitors' door (409 conflict).

Path parameters

  • idstringrequired

    Portal id (portal_…); the id without its prefix is accepted too.

Body

  • allowedOriginsstring[]

    Website addresses (https://www.example.com); [] allows any website again.

    at most 20 items

  • openboolean

    true opens the portal to visitors (needs portals:publish; runs the go-live check on a published portal, and lets outside people in); false closes it.

Returns

200 The visitors' door as it now stands. data is VisitorsDoor.

Fields of data
  • urlstring | nullrequired

    The visitor page to hand out (or the custom domain that leads there); null when no subject opens to visitors.

  • openbooleanrequired

    Visitors can come in now.

  • readinessDoorReadiness | nullrequired

    Null when no subject opens to visitors (a website assistant or a screening role).

  • allowedOriginsstring[]required

    The only websites that may show the visitor page in a frame and start conversations from it; [] = any.

  • scriptstring | nullrequired

    The snippet that puts the visitor page on your website.

Errors

invalid_requestunauthorizedforbiddenupgrade_requirednot_foundconflictgo_live_refusedrate_limitedserver_error

Events

portal.updatedsetting.changed

Request

curl -X PUT "https://app.zentiqai.com/v1/portals/ID/doors/visitors" \
  -H "Authorization: Bearer $ZENTIQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
Example answer
Response
{
  "data": {
    "url": "https://www.example.com",
    "open": true,
    "readiness": {
      "open": true,
      "text": "…",
      "fix": "publish"
    },
    "allowedOrigins": [
      "…"
    ],
    "script": "…"
  },
  "error": null,
  "requestId": "req_4f1c2e9a7b3d5e6f7a8b9c0d"
}