Skip to the content
wetalk
Start free

Reference · 1.0.0

WeTalk API

The REST surface the WeTalk console and every customer integration share.

Generated from the API's own OpenAPI document at build time, so it cannot describe an endpoint the API does not have. Operations tagged operator are left out: they belong to WeTalk's internal console, on a separate deployment no API key can reach.

378 operations documented

322 paths

28 areas

Servers

Base URL What it is
https://api.wetalk.io Public API, authenticated with a scoped bearer key.
https://app.wetalk.io Console origin, authenticated with the session cookie.

Authentication

bearerApiKey — A scoped API key. A read-only key cannot start a campaign or spend credit — that is enforced by scope, not by role. Keys are shown exactly once at creation.

sessionCookie — The console's session, set `Secure; HttpOnly; SameSite=Lax; Path=/`. A non-GET request authenticated this way must also carry `X-WeTalk-Csrf`; a bearer request must not, because it carries no ambient credential.

Keys, scopes and limits

Send your key on every request as Authorization: Bearer wtk_live_.... Keys are made and revoked in the console, under Settings → Developers. A key cannot make, list or revoke keys: that needs a signed-in person.

What each scope lets a key do
Scope What it allows
agent:read Read your agents and how they are set up.
agent:write Create, change, publish and pause agents.
conversation:read Read conversations and their transcripts.
recording:read Play call recordings.
campaign:write Read and change contact lists and outbound campaigns.
credit:spend Read the balance, top up and spend credit.
order:read Read orders from the order book.
order:write Take, change and cancel orders.
conversation:dial Start outbound conversations through your SIP trunk — billed talk time.
billing:read Read your organization's balances and the offers it can buy (organization keys).
usage:read Read your organization's usage, by workspace (organization keys).
document:read Read and download your organization's invoices, receipts and statements (organization keys).

Every operation below names the scopes a key must hold to call it, or says any key. A key without them is refused with 403 scope_insufficient, naming the scope. 81 operations say signed-in person only: the console uses them, and a key is refused there whatever it holds (403 permission_denied).

Requests a minute for a key made with the default limit of 60
Kind of request Requests a minute
Reads 60
Changes 60
Heavy requests (marked “heavy” below) 6

A key made with its own limit gets the same shares of that number. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; past the limit the API answers 429 rate_limited.

Catalogue

The platform catalogue — templates, languages, voices and tiers, as data.

GET /v1/languages listLanguagePacks
  • any key

The languages a VoiceAgent may be configured in.

Only packs whose state is complete are returned. An incomplete pack makes that language unavailable — there is no reference-locale fallback anywhere in the platform, so an incomplete pack must not be offerable in a picker either.

  • 200
  • 401
  • 403
  • 429
GET /v1/languages/suggestion getLanguageSuggestion
  • any key

The language a new agent's wizard opens on, from the account's country.

The account's country (the answer given at sign-up) and its main language, when that language's pack is complete. language_code is null when it is not, or when the country has no main language listed: the wizard then asks, with nothing chosen. A suggestion, never a fallback — there is no English fallback anywhere.

  • 200
  • 401
  • 403
  • 429
GET /v1/templates listAgentTemplates
  • any key

The agent templates a customer may pick from.

Only templates marked available are returned. The set is bounded and unpaginated.

AMENDED 2026-10-09 (plan-015 INT-B): beside data, whether a call may be forwarded to a phone and the two facts the forward copy states — the values getVoiceAgent serves — so the New agent wizard, which reads the templates before any agent exists, offers forwarding as soon as it is switched on.

  • 200
  • 401
  • 403
  • 429
GET /v1/tiers listTiers
  • any key

The sellable tiers and their channel-unit ceilings.

  • 200
  • 401
  • 403
  • 429
GET /v1/voices listVoices
  • any key

The auditioned voices, with the languages each one speaks.

Only voices that speak at least one language are returned: a candidate voice that no published language's audition names cannot be chosen by any agent, so it is not offered.

  • 200
  • 401
  • 403
  • 429
POST /v1/voices/{voice_id}/greeting-sample createGreetingSample
  • any key

Hear a voice say your greeting in one language.

The answer is a WAV file (16-bit mono PCM at the voice's own rate) of the voice saying text in language_code, exactly as a caller would hear it. The greeting travels in the body: it never appears in a URL, a log line or a stored file's name. Any signed-in member and any API key of the account may ask.

The first time a greeting is heard it is synthesised and kept for your organization; hearing the same greeting in the same voice and language again is a stored read. Editing the greeting and pressing Play synthesises the new text. WeTalk pays for the speech; nothing is billed.

404 voice_sample_unavailable — the voice does not speak the language. Nothing is synthesised. 429 rate_limited — this person has synthesised many new greetings in the last hour; Retry-After says when to try again. Replaying a greeting already heard is never limited. 503 voice_sample_unavailable — the greeting cannot be produced right now (the synthesiser or the store did not answer). Try again later; no other voice or language is offered instead.

Parameters
Name In Required Description
voice_id path yes A voice_id from GET /v1/voices.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
  • 503
GET /v1/voices/{voice_id}/sample getVoiceSample
  • any key

Hear a voice say a short sample line in one language.

The answer is a WAV file (16-bit mono PCM at the voice's own rate) of the voice saying the language's sample line, exactly as a caller would hear it on a conversation. Any signed-in member and any API key may fetch one, as any may read the catalogue; it is cacheable by the browser for a day.

404 voice_sample_unavailable — the language has no sample line, or the voice does not speak the language. There is no fallback to another language or another voice. 503 voice_sample_unavailable — the sample exists in principle but cannot be produced right now (the synthesiser or the store did not answer). Try again later.

Parameters
Name In Required Description
voice_id path yes A voice_id from GET /v1/voices.
language query yes The language code the voice should speak, e.g. el. One of the voice's supported_language_code.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
  • 503

Agents and versions

GET /v1/agents listVoiceAgents
  • agent:read

Every VoiceAgent in this account, newest first.

Unpaginated, deliberately. Design §5.1 asks for cursors on the append-heavy collections — conversations, the ledger, the delivery log — and a workspace's VoiceAgents are none of those: the screen is a short list a person reads top to bottom, and the count above it ("N agents · M answering now") is a count of the whole set rather than of one page.

Every member role may read this, including Viewer: product decision Q10 makes a Viewer read-only over analytics and outcomes, and that is what this is. What a Viewer does not get is the caller's number on a conversation, which is enforced by the database view rather than by narrowing this list.

  • 200
  • 401
  • 403
  • 429
POST /v1/agents createVoiceAgent
  • agent:write

Create a VoiceAgent and its first build job, which waits for the documents.

Four rows, in one transaction: voice_agent (state building), agent_version v1 (state building), build_job (state queued) and its six build_step rows. It does NOT enqueue build.start (plan-003, design §16.8): the build reads the version's documents, which exist only once the wizard has uploaded and confirmed each one (createAgentDocumentUpload, confirmAgentDocument). startVoiceAgentBuild then enqueues build.start, once, in its own transaction — the outbox contract lives there. The build job stays queued until then.

Three rules are checked against the platform catalogue, because that is the only place they are true: the template must be available, every language pack must be complete, and each language's voice must speak it — voice_by_language names one voice per language, voice_id one voice for every language (exactly one of the two; plan-015, 2026-10-09: a pair that fails is 400 validation_failed on voice_by_language.<language>, the language named). Version 1 stores the map and, as voice_id, the opening language's voice. A delivery agent (reference schema pizzabot-menu-md-v1) speaks one language for now, and is refused with more than one. Every refusal's message is written for the person in the wizard; its code and field say which rule it was.

Accepts Idempotency-Key. A wizard whose reply was lost must be able to retry without creating a second VoiceAgent.

"Start from a shared configuration" (plan-014): with resource_share_id, the new agent copies an agent configuration another workspace shared and this one accepted (listSharedVoiceAgentConfigurations). Its kind of agent, languages, voice, greeting, recording switch and concurrency fill whatever the request leaves out; the kind of agent cannot change. The copy is recorded as the share's provenance in the same transaction, once per workspace (409 conflict for a second copy); a revoked share is 409 share_revoked, a share this workspace cannot use 404 share_unavailable. The organization's agent limit applies as to every creation (409 resource_limit_reached). The copy also takes the pinned version's time zone, special dates and answering choices where the request leaves them out.

The answering answers (plan-015, 2026-10-09): the VoiceAgent's own time_zone (the workspace's when left out, written into the version), its special_date[], and what happens when it is closed or busy (after_hours_choice, busy_choice, longest_wait_minute, forward_to). A forward is checked before anything is written: 422 forward_unavailable while forwarding is not available, 400 forward_destination_refused for a number outside the reviewed countries, a premium-rate, emergency or short number, or one that answers through WeTalk.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/agents/{voice_agent_id} getVoiceAgent
  • agent:read

One VoiceAgent, with today's and this month's figures.

The VoiceAgent, with what its page draws (plan-015, 2026-10-09): the voice of each language, the live version's answering answers (its time zone, special dates, what happens when it is closed or busy, and the hold callers are really given), whether online payment is unavailable for a VoiceAgent that accepts only online payment, and whether forwarding to a phone is available with its ring and time limit.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 404
  • 429
PATCH /v1/agents/{voice_agent_id} updateVoiceAgent
  • agent:write

Turn "Record calls" on or off, by publishing a new version (plan-003 RC-47).

Whether a VoiceAgent records is pinned in its versions, like everything else about how it answers: a version is immutable and a conversation keeps the version it was admitted onto. So this does not edit the live version. It copies it — the same validated configuration with recordingEnabled changed and the same measured prompt prefix — as a new ready version, and publishes that through the same compare-and-set, audit entry and outbox message as publishAgentVersion, in one transaction. A conversation already in progress keeps the setting it started with; the next one gets the new version.

recording_enabled is the only field this route changes. expected_live_version_seq is required, as on publish. Asking for the value the live version already has publishes nothing and answers changed: false. A VoiceAgent with no live version yet is 422 agent_version_not_ready: its first build uses the value it was created with.

With recording_enabled: false the runtime speaks no recording notice, captures no audio and leaves conversation.recording_consent at not_asked.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/agents/{voice_agent_id}/analytics getVoiceAgentAnalytics
  • conversation:read
  • heavy

Everything the Analytics tab draws, over one window, in one read.

Calls per day, the four headline figures, the outcome breakdown, language shares, the by-channel table, the busiest hours, a per-version comparison, and the ranked list of questions the agent could not answer.

Every breakdown is a count, never a percentage: the denominator ships once, in figure.conversation_count, and the screen divides. Two percentages rounded separately do not add to 100, and a percentage of a window the caller cannot see is not checkable.

Buckets are cut in the workspace's time zone, which the response names, so "busiest hours" means the hours the business was busy rather than the hours the server was. Only answered, non-test conversations are counted, and the response says so in counts_answered_only and excludes_test rather than leaving it to be inferred from a number that looks low.

since and until are optional and have no default: absent means unbounded on that side. An unparseable value is a 400, never a silently widened range.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
since query no Inclusive lower bound on answered_at, RFC 3339.
until query no Exclusive upper bound on answered_at, RFC 3339.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/agents/{voice_agent_id}/answering getVoiceAgentAnswering
  • agent:read

When this VoiceAgent answers, and what it does when it cannot.

Read from the live agent_version's validated configuration document, which is immutable. There is deliberately no write counterpart: changing the weekly pattern, a closed date, the out-of-hours behaviour or the overflow behaviour means publishing a new agent_version, and a save button here would have to lie about which conversations it affected.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/agents/{voice_agent_id}/answers getVoiceAgentAnswers
  • agent:write

The answers a built version was made from, read back for the Edit form (customer-portal review

Read from the version's validated configuration document (the wizard's draft is replaced when a build succeeds): the opening hours as a week and as the sentence the build reads, the delivery fee and free-delivery threshold, how callers pay, the languages, voice, greeting and "Record calls", and the version's documents. A template text field the configuration does not carry is listed in unrecovered_field and must be answered again. The live version unless agent_version_id names another built one. edit_refusal says why an edit cannot be saved right now (a build is running). Owner or Admin.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
agent_version_id query no A built (ready or archived) version to edit from instead of the live one.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
POST /v1/agents/{voice_agent_id}/archive archiveVoiceAgent
  • agent:write

Archive a VoiceAgent that no longer answers (customer-portal review

Sets archived_at, audited (voice_agent.archived): it leaves listVoiceAgents — the list, the sidebar count and every picker — while its conversations, orders and history are kept and getVoiceAgent still answers for it. Nothing is deleted.

Refused (409 voice_agent_archive_refused) while it is live (pause it first), while a number or endpoint still routes to it (numbers are held by WeTalk: ask WeTalk to move or release it), or while its build is running. A build still waiting for documents is closed with it. Archiving an archived VoiceAgent changes nothing (changed: false).

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/agents/{voice_agent_id}/browser-test-calls createBrowserTestCall
  • agent:write
  • heavy

Talk to the agent's live version from the browser's microphone.

A real conversation on the live version over WeTalk's browser voice relay, marked is_test and billed like any other minute from the moment it is answered (AC-38), for at most max_duration_second (15 minutes). The console asks for the microphone first and sends nothing when it is refused. Read the test with readTestCall ?conversation_id= and end it with endTestCall — the same two routes a phone test uses.

Checked in the phone test's order and words, without "has a number": the agent (404 voice_agent_not_found); its live version (422 agent_version_not_ready); the language (422 language_unavailable); the agent answers conversations (422 voice_agent_paused); the live balance and the workspace budget pay a second of it (402 trial_exhausted, credit_exhausted, hard_stop_reached, budget_exceeded); the model is running (503 model_pool_unavailable). Every refusal ends "Nothing was charged." and nothing is written.

One browser test per member per agent: starting one ends the one already running for you on this agent, and names it in replaced_conversation_id.

The answer carries webrtc — the relay's address, a join token (two minutes, this one conversation's room, microphone only), join_by, and the two participant identities — and the conversation's id, in state = connecting.

Parameters
Name In Required Description
voice_agent_id path yes The agent to talk to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 402
  • 403
  • 404
  • 422
  • 429
  • 503
POST /v1/agents/{voice_agent_id}/build startVoiceAgentBuild
  • agent:write

Start building the VoiceAgent from its confirmed documents.

Queues the build of the VoiceAgent's building version, once. A second start is 409 build_already_started; a template built from documents with none confirmed is 422 build_no_document. Requires Idempotency-Key.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 202
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/agents/{voice_agent_id}/channel-bindings listVoiceAgentChannelBindings
  • agent:read

The endpoints that reach this VoiceAgent.

One row per channel_binding: which medium it carries, what it is addressed by, and how many conversations arrived on it in the last seven days.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 429
GET /v1/agents/{voice_agent_id}/concurrency getVoiceAgentConcurrency
  • agent:read

How many conversations this VoiceAgent is holding, and how many it may hold.

in_use counts conversations with no end time. channel_unit_ceiling is the effective ceiling admission holds new conversations to: the VoiceAgent's own channel_unit_limit when set, else the live agent_version's version_ceiling. It is null when nothing is published — a VoiceAgent with no live version admits nobody, which is not the same as a ceiling of zero. entitlement_ceiling is the organization's concurrent-conversation entitlement, the most channel_unit_limit may be set to.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 404
  • 429
PUT /v1/agents/{voice_agent_id}/concurrency setVoiceAgentChannelUnitLimit
  • agent:write

Change how many conversations this VoiceAgent takes at once.

Sets the VoiceAgent's own line limit, channel_unit_limit, without publishing a new version. null returns it to the published version's own ceiling. Anyone who may change the agent (Owner, Admin, Member, Developer) may change it, up to the organization's concurrent-conversation entitlement; above it the answer is 422 channel_unit_limit_above_entitlement with error.detail.entitlement_ceiling, and the way up is "Request more lines" (POST /v1/quota-requests).

New conversations meet the limit within a few seconds. A lower limit never ends a conversation in progress: new callers meet the agent's busy behaviour until fewer are talking than the new limit. Each change is recorded in the audit log (voice_agent.channel_unit_limit_changed, old and new); setting the value it already has changes nothing and records nothing.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/agents/{voice_agent_id}/connection getVoiceAgentConnection
  • agent:read

How calls reach this VoiceAgent, and what cannot be promised about it.

The number, the carrier, the SIP address with whose domain it is on, the events endpoint, the most recent registration observation — or null when there is none — and the inbound-failover verdict.

inbound_failover.possible is always false. A number is homed on one carrier and moving it is a porting operation measured in days; the alternative is named (byo_sip_dual_home) so the answer is never merely "no".

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/agents/{voice_agent_id}/documents listVoiceAgentLiveDocuments
  • agent:read

The file names the agent's live version answers from (plan-015 TC1).

The documents the live version was built from, oldest upload first: those of the newest built version at or before the live one that has documents (an edit builds without uploading again). An agent with nothing live answers an empty list. Names only — the stored files are not reachable from here. The Test console's text hint names them ("The agent replies from …").

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/agents/{voice_agent_id}/documents createAgentDocumentUpload
  • agent:write

A short-lived, write-only upload link for one document of the building version.

Answers a link the browser PUTs one file's bytes to, straight into storage, and a signed upload grant to confirm it with. Nothing is recorded yet: the document exists once it is confirmed.

Only while the VoiceAgent's build waits for documents (queued, not yet started): otherwise 409 build_already_started. At most the platform's per-version document count (422 document_count_exceeded). Requires Idempotency-Key.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 413
  • 415
  • 422
  • 429
POST /v1/agents/{voice_agent_id}/documents/{agent_document_id}/confirm confirmAgentDocument
  • agent:write

Record an uploaded document after checking its stored size and SHA-256.

Verifies the upload grant, reads the stored object back, and checks that its size, its content type and its SHA-256 are the declared ones. A mismatch discards the stored bytes and answers 422 document_mismatch. A match records the document and an audit entry.

Confirming the same document twice answers the same document. A grant that is forged, altered, or minted for another document or account is 400 upload_grant_invalid; one past its expiry is 410 upload_grant_expired.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
agent_document_id path yes The document id createAgentDocumentUpload answered.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 410
  • 422
  • 429
POST /v1/agents/{voice_agent_id}/edit editVoiceAgent
  • agent:write

Open the next version from edited answers, and build it (customer-portal review

Inserts the next agent_version (state building, the edited answers as its draft), a queued build_job and its six steps. The live version keeps answering: a VoiceAgent that already has a live version is not published by its build (decision D14 (a)); the new version is tested on the Versions tab and published with publishAgentVersion, which keeps conversations in flight on the version they started on (design §7.5).

documents: "keep" copies the base version's documents to the new version and starts the build (202). documents: "replace" opens the version with no documents (201); upload them with createAgentDocumentUpload / confirmAgentDocument, then startVoiceAgentBuild.

Checked before anything is written with the same rules as createVoiceAgent and then the build's own reader, so unreadable hours or an unreadable fee are a 400 naming the field. 409 build_in_progress while a build runs (one only waiting for documents is closed and superseded); 409 live_version_seq_stale; 409 voice_agent_archived; 422 agent_version_not_ready with nothing built to edit from; 422 build_no_document for "keep" with no document; 422 edit_unchanged when nothing changed; 422 edit_needs_no_build when only recording_enabled changed — that switch publishes on its own through updateVoiceAgent, without a build. Owner or Admin; audited (voice_agent.edit_requested); requires Idempotency-Key.

The answering answers (plan-015, 2026-10-09) may be changed here, each one left out keeping the base version's; each change needs a build. A forward — stated or kept — is checked as on create: 422 forward_unavailable, 400 forward_destination_refused.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 202
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/agents/{voice_agent_id}/flags listConversationFlags
  • conversation:read

Conversations somebody or something raised a concern about.

One table, two populations. A member flags a conversation from History, an operator from the staff console, the runtime automatically, a carrier by report — all four land in conversation_flag and are told apart by raised_by_kind. This list returns all four, because hiding the operator-raised ones would leave a customer looking at a list that does not match what WeTalk can see.

state defaults to open, which is the unhandled ones.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
state query no Defaults to open.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/agents/{voice_agent_id}/flags/{conversation_flag_id}/resolve resolveConversationFlag
  • agent:write

Mark a raised concern dealt with.

Sets handled_at and records which population handled it, in the matching column. Resolving an already-resolved concern is not an error — two people can click at once — but the first resolver is the one recorded.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
conversation_flag_id path yes One raised concern about a conversation.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/agents/{voice_agent_id}/gaps listGaps
  • conversation:read

Questions callers asked that the documents do not answer, grouped and counted.

Ranked by how many callers asked. state selects which of the four a gap is in and defaults to open; a value that is present but unknown is a 400 rather than a silently widened list.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
state query no Defaults to open.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/agents/{voice_agent_id}/gaps/{gap_id}/answer answerGap
  • agent:write

Queue an answer for the next draft.

Fills answer_text and leaves answered_into_agent_version_id null, which is what "queued" means. Nothing is published and nothing is built — publishAnswers saves the queued answers as a draft version, and the agent keeps saying what the live version says until somebody publishes that draft from Versions.

A gap already folded into a version — a draft included — is refused: that answer is part of an immutable agent_version, and a correction is answered on a new gap and goes into the next draft.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
gap_id path yes One unanswered-question cluster.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/agents/{voice_agent_id}/gaps/{gap_id}/dismiss dismissGap
  • agent:write

Mark a gap not worth answering.

Sets dismissed_at. The row is kept rather than deleted: the cluster is evidence that callers asked, and deleting it would let the same question be rediscovered as new next month.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
gap_id path yes One unanswered-question cluster.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/agents/{voice_agent_id}/gaps/publish publishAnswers
  • agent:write

Save the queued answers as a draft version.

Creates a draft; publishes nothing (plan-015 DRF, ADR 0043). Folds every answer not yet live — each queued answer (a gap with answer_text and no answered_into_agent_version_id) and each answer already in a draft that was never published — into a new ready version built on the live one: the live version's configuration with its answers extended, its prompt prefix measured again. The draft has no published_at, its base_agent_version_id is the live version, and each folded gap names it from then on. A queued question equal to one already live replaces it.

There is no agent_version.published outbox message and the live version does not change: live_version_id and live_version_seq come back as they were, and every conversation keeps reaching the live version until somebody who may publish publishes the draft from Versions (publishAgentVersion, which refuses 409 draft_base_changed once another version went live meanwhile, unless acknowledged). The audit entry is agent_version.draft_created. Running this again after another version went live is the answers' "Recreate on the current version".

The operationId keeps its earlier name so generated clients do not break.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/agents/{voice_agent_id}/numbers listVoiceAgentNumbers
  • agent:read

The numbers this VoiceAgent answers on.

Carrier numbers and your own SIP trunk's numbers alike. A trunk number has provider: sip_trunk, carrier: null, and names its trunk.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 429
POST /v1/agents/{voice_agent_id}/optimisation-reviews createOptimisationReview
  • agent:write
  • credit:spend

Start an optimisation review. It is recorded and queued for the worker; it does not run here.

Records the review with state = idle and its price (price_minor, currency = EUR), queues it on the optimisation_review.run outbox topic and writes the audit entry optimisation_review.requested, all in one database transaction. The review runs behind the frontier wall in the factory, never on the model pool; the worker that runs it is not deployed yet, so the review stays idle and the response says so in queued.

No money moves at creation. The charge is taken when the review completes, in the same transaction that marks it done; a review that fails is never charged.

payer = customer charges the account and requires the live available credit to cover the price. payer = wetalk absorbs the cost and is a WeTalk operator's decision only: it needs absorbed_reason, at least twelve characters. A WeTalk operator acting as a member may not charge the account (403 impersonation_forbidden, and the refusal is written to the account's audit log); a person or an API key may not absorb (403 permission_denied).

Idempotency-Key is required: a repeated request returns the first review instead of queuing a second paid one.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/agents/{voice_agent_id}/optimisation-reviews/quote getOptimisationReviewQuote
  • agent:read

What each depth of optimisation review would cost for a window, and whether it can run.

Counts the conversations a review would read — this VoiceAgent's, in the credential's workspace, ended and not test conversations, started inside the window (since_last_version is since the most recent publish) — and how many of them still have a recording under the retention policy. Recordings are visible to owners and admins only, so for a member recording_available_count is 0.

Each depth's price is the account's effective price book (review_standard_minor, review_ultra_minor), EUR in integer minor units. unavailable_reason is null when the depth can run, otherwise: no_conversations_in_scope; for ultra only, recording_consent_refused (every caller in the window refused recording) or recordings_expired; insufficient_credit when the live available credit is below the price. Nothing is recorded or spent.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
scope query yes The window of conversations the review would read.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/agents/{voice_agent_id}/pause pauseVoiceAgent
  • agent:write

Stop a live VoiceAgent answering, until it is resumed (customer-portal review

live → paused, audited (voice_agent.paused). From the next conversation on, a caller hears the language pack's "we can't take your call right now, please try again later" and the conversation ends as voice_agent_paused — never silence. A conversation already in progress finishes. Only a live VoiceAgent can be paused (409 voice_agent_not_pausable); pausing a paused one changes nothing (changed: false).

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/agents/{voice_agent_id}/rebuild rebuildVoiceAgent
  • agent:write

Try a failed build again, with the same documents or new ones (customer-portal review

For a VoiceAgent whose newest version failed. documents: "reuse" builds that version again from the documents already uploaded and starts at once (202). documents: "replace" opens a new version with the same answers and no documents (201); upload them with createAgentDocumentUpload / confirmAgentDocument, then startVoiceAgentBuild. Audited (voice_agent.rebuild_requested). Requires Idempotency-Key.

409 rebuild_refused when the newest version did not fail (it is building, waiting for documents, or built); 409 voice_agent_archived for an archived VoiceAgent; 422 build_no_document for "reuse" when the failed version has no document and the template needs one.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 202
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
POST /v1/agents/{voice_agent_id}/resume resumeVoiceAgent
  • agent:write

Let a VoiceAgent its owner paused answer again (customer-portal review

paused → live, audited (voice_agent.resumed). A VoiceAgent WeTalk paused is resumed only by WeTalk (409 voice_agent_paused_by_operator); one that is not paused is 409 voice_agent_not_paused; resuming a live one changes nothing (changed: false).

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/agents/{voice_agent_id}/sandbox-turns createSandboxTurn
  • agent:write
  • heavy

Type one line as a caller and get the live version's reply.

A text test against the agent, answered by the agent_runtime from the version's own prompt through the model pool — what a caller would hear, as text. Free (AC-38): no balance is judged and nothing is billed. turn is what this request produced, in order — the line as recorded, the reply, and a system line when the agent would have carried something out (a text test carries nothing out: no order is placed) — not the whole chat.

The chat is a conversation (medium = web_chat, is_test), so it shows in History as a test. Send the answered conversation_id back to continue it; the chat stays on the version its first turn used. Omit it to start a new chat on the live version, or send agent_version_id to start one on a built version that is not published yet (an edit, customer-portal review #75): 422 agent_version_not_ready unless that version is ready.

Checked in this order: text (non-empty, at most 2000 characters); the agent (404 voice_agent_not_found); its live version (422 agent_version_not_ready); the language (422 language_unavailable); the chat (404 conversation_not_found). A resting model is 503 model_pool_unavailable and a runtime that does not answer is 503 agent_runtime_unavailable — said before anything is written.

plan-015 TC2: a survey with two question groups needs survey_arm (A = group 1, B = group 2) on every turn of its chat; it is refused for one group or no survey (400 validation_failed, field survey_arm). opening: true on a new chat of an agent that makes calls answers turn 0 — its greeting, before the tester types anything — stored like every line and free.

Parameters
Name In Required Description
voice_agent_id path yes The agent to test.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
  • 503
GET /v1/agents/{voice_agent_id}/signalling listVoiceAgentSignallingEvents
  • agent:read

Recent SIP signalling for this VoiceAgent.

Rows WeTalk synthesised rather than observed carry synthesised: true, because some carriers do not expose a REGISTER or a response code and a manufactured fact presented as an observed one is worse than a gap.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 429
GET /v1/agents/{voice_agent_id}/sip getVoiceAgentSip
  • agent:read

The bring-your-own-SIP address for this VoiceAgent (legacy).

Legacy (P10). The per-VoiceAgent SIP credential of the carrier era. It stays and answers as before, but it is not a customer SIP trunk and the portal no longer shows it: connect your own trunk with /v1/sip-trunks.

The address is on the carrier's domain, not WeTalk's, and sip_address_owner says so. Telnyx's vanity SIP domains exist only on IP, FQDN and Teams-auth connections, and the credential connections that per-VoiceAgent usernames require cannot carry one.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/agents/{voice_agent_id}/sip/rotation requestSipRotation
  • agent:write

Request a new SIP password for this VoiceAgent (legacy).

Legacy (P10). Rotates the carrier-era per-VoiceAgent SIP credential; a customer trunk's password is replaced with PUT /v1/sip-trunks/{sip_trunk_id}/password.

Accepted, not applied. The worker issues the new secret at the carrier and writes it to the key store; until it does, the existing password keeps working. The SIP user-part does not change, so nothing needs re-pointing.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 202
  • 401
  • 403
  • 404
  • 429
GET /v1/agents/{voice_agent_id}/test-calls readTestCall
  • agent:read

How a voice test call — phone or browser — is going.

A phone test: after createTestCall, the console reads this with party_number and since until the tester's call has arrived and ended: state = idle while nothing has come in from party_number since since, then dialing, live and ended, with the conversation's id (for its live transcript stream) and, once ended, the billed seconds and amount.

A browser test (plan-004, since 2026-09-26): after createBrowserTestCall, read it with conversation_id instead. state = connecting until admission carries the conversation — before the browser has joined (no row yet) and while it has joined but is not yet answered — then live and ended, as for a phone test. An id this workspace did not start is 404.

Name the test one way: conversation_id, or party_number and since — both is 400.

Parameters
Name In Required Description
voice_agent_id path yes The agent being tested.
party_number query no A phone test — the tester's phone, as sent to createTestCall. With since.
since query no A phone test — the waiting_since that createTestCall answered. With party_number.
conversation_id query no A browser test — the conversation_id that createBrowserTestCall answered.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/agents/{voice_agent_id}/test-calls createTestCall
  • agent:write
  • heavy

Place a voice test call on the agent's live version.

agent_calls_me rings party_number from the agent; i_call_the_agent answers state = idle and treats the next inbound conversation from party_number, within 120 seconds, as the test. Either way the conversation runs on the live version over the real connections, is marked is_test, and is billed like any other minute (AC-38).

Checked in this order: the body (party_number is required in both directions — dialling in, it is how the call is recognised as the test); the agent (404 voice_agent_not_found); its live version (422 agent_version_not_ready, in words that say whether the build failed or is still running); the language (422 language_unavailable).

Then, in both directions: the agent must answer calls (422 voice_agent_paused) and have a number (422 tenant_resolution_failed, "has no phone number yet" — the number to dial, or the number to call from); for agent_calls_me, party_number must not be on the account's do-not-call list for every agent or for this one (422 contact_suppressed); the live balance (402 trial_exhausted, credit_exhausted or hard_stop_reached) and the workspace's monthly budget (402 budget_exceeded) must pay a second of it.

i_call_the_agent: the agent_runtime arms the tester's number — 503 model_pool_unavailable when the model is resting, 503 agent_runtime_unavailable when the runtime does not answer — and the answer is 201 with state = idle, dial_number (the number to dial), waiting_since and waiting_until.

agent_calls_me (since 2026-09-25): the agent_runtime rings party_number from the agent's own number — dial_number in the answer, so the console can say which number will ring. Refused before anything rings with 503 model_pool_unavailable when the model is resting or when every free line is kept for inbound callers (inbound beats outbound), 503 agent_runtime_unavailable when the runtime does not answer or is restarting, 502 carrier_failed when the carrier refuses the call, 422 tenant_resolution_failed when the number no longer reaches the agent. The answer is 201 with state = dialing, dial_number, waiting_since and waiting_until (when the ringing stops). A phone that is not answered, or is busy or declines, ends the conversation with end_reason no_answer or busy, unbilled.

Read the call's progress with GET on this path. Nothing is written here: the runtime writes the conversation when the carrier reports the leg, marked is_test, and it is billed like any other minute from the moment it is answered.

A number on the account's own SIP trunk (plan-008, since 2026-09-30). The agent's number is its first enabled carrier number; when it has none, its enabled trunk number (a sip_trunk_number binding, one allowed as caller ID first). A carrier number keeps priority when the agent has both. A trunk number is reached through the trunk, by the same rules as createSipTrunkTestCall:

- agent_calls_me rings party_number through the trunk from the trunk number (origin: test_call, the trunk's default ring time). Before anything rings: a key also needs conversation:dial (403 scope_insufficient); the per-minute dial limits shared with startOutboundConversation (10 per account, 3 per key: 429 rate_limited, with Retry-After); the trunk is not disabled (422 sip_trunk_disabled); the number is allowed as caller ID (422 caller_id_not_bound); the destination rules (422 destination_refused); the do-not-call list (422 contact_suppressed); the balance (402). The runtime's own refusals: 429 daily_ceiling_reached, 422 sip_trunk_unverified, 503 all_lines_busy, 503 sip_edge_unavailable, 503 model_pool_unavailable, 503 agent_runtime_unavailable. The runtime opens the conversation before it answers, so the 201 carries its conversation_id with state = dialing, dial_number (the trunk number), waiting_since and waiting_until; GET on this path reads it as it reads a carrier test call, and endTestCall hangs it up through the trunk. A leg that could not reach the phone ends unreachable, unbilled. - i_call_the_agent needs WeTalk to take calls through customer trunks: while the platform's SIP inbound is off it is 422 sip_trunk_inbound_off ("Calls into … come through your own phone system, and WeTalk is not taking calls through phone systems yet."), before the balance is read. While it is on, the trunk number itself is armed (waiting_until is when the arm lapses), and a trunk not ready for inbound is 422 sip_trunk_inbound_off.

Parameters
Name In Required Description
voice_agent_id path yes The agent to test.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 402
  • 403
  • 404
  • 422
  • 429
  • 503
POST /v1/agents/{voice_agent_id}/test-calls/arm/release releaseTestCallArm
  • agent:write

Stop "My phone" waiting for the tester's call (plan-015 TC1).

Drops the arm when the test panel closes. A call from that phone afterwards is an ordinary conversation, not a test. Idempotent: 204 whether or not the number was armed. The tester's number is in the body, never in the URL.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.

Takes a JSON request body.

  • 204
  • 400
  • 401
  • 403
  • 404
  • 429
  • 503
POST /v1/agents/{voice_agent_id}/test-calls/arm/renew renewTestCallArm
  • agent:write

Keep "My phone" waiting for the tester's call (plan-015 TC1).

Moves the end of an armed "I call the agent" window by the platform's arm window again (ten minutes), keeping its start: the answer is waiting_since (unchanged) and the new waiting_until. The Test console renews at half the window while its panel is open. An arm that lapsed or that a call has already taken is not brought back: 404 test_call_arm_not_found, and the console arms afresh with createTestCall. The tester's number is in the body, never in the URL. Nothing is dialled or charged.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
  • 503
GET /v1/agents/{voice_agent_id}/test-suites getVoiceAgentTestSuites
  • agent:read

The test suites for this VoiceAgent, with each suite's most recent run.

Per-case pass and fail, with the result text and the transcript excerpt that shows the failure. A failing case does not block publishing (AC-15) — there is no constraint tying the live version to a passing run — so the run records which agent_version_id it was against and the failure stays retrievable against the version that was published anyway.

Reading a run is not running one. Placing a test conversation needs the runtime and belongs to the Test console (P11-09); this route reads what has already been run.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/agents/{voice_agent_id}/versions listAgentVersions
  • agent:read

Every version of this VoiceAgent, newest first.

The configuration document itself is not in the list projection: it holds the whole extracted catalogue, and the versions screen renders a label, a date, an author, a note and a what-changed list. Read one version to get its document.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 429
GET /v1/agents/{voice_agent_id}/versions/{agent_version_id} getAgentVersion
  • agent:read

One version, with the configuration document it was built into.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
agent_version_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/agents/{voice_agent_id}/versions/{agent_version_id}/documents listAgentVersionDocuments
  • agent:read

The source documents this version was built from.

Documents are pinned per version, so what a live version answers from can always be downloaded exactly as it was uploaded.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
agent_version_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/agents/{voice_agent_id}/versions/{agent_version_id}/publish publishAgentVersion
  • agent:write

Make this version the one new conversations resolve to.

One row update. Conversations already in flight hold the configuration they resolved at admission and finish on the outgoing version; the next new conversation gets this one. expected_live_version_seq is the sequence you last read — a mismatch is a 409 rather than a silent overwrite of somebody else's decision.

A DRAFT (plan-015, ADR 0043) — a version made from answers, a paid review's fixes or an edit, never published — records the version that was live when it was made. If that is no longer the live version, publishing it would replace what went live since, so it answers 409 draft_base_changed (with error.detail.base_agent_version_id, base_label, live_agent_version_id, live_label) unless the body says acknowledge_base_change: true. A stale expected_live_version_seq is judged first.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
agent_version_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/agents/{voice_agent_id}/versions/{agent_version_id}/restore restoreAgentVersion
  • agent:write

Go back to an earlier version. The same operation as publishing.

Identical to publish, with an older version id, and with the same guarantee for conversations already in flight. There is no separate rollback path.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
agent_version_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/builds/{build_job_id} getBuildJob
  • agent:read

One build as it stands now — its steps, its progress, or why it failed (customer-portal review

What the Building screen reads on load and whenever its stream says something changed, so a reload, a second tab or a link opened later shows the real state. The failure carries the owner's sentence and the fault, never the operator's technical detail.

Parameters
Name In Required Description
build_job_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/connections listConnections
  • agent:read

The systems one version of a VoiceAgent may call, with their allowlists.

Parameters
Name In Required Description
agent_version_id query yes Connections belong to one immutable version.
  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/connections createConnection
  • agent:write

Register a system the VoiceAgent may call, and the operations it may use.

The uploaded OpenAPI 3 or Swagger 2 document is parsed here, and operation_id must name operations that document declares. The resulting list is the allowlist: the runtime may call nothing else, and an attempt to call anything else is blocked and written to the conversation's own event timeline.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/connections/{connection_id} getConnection
  • agent:read

One connection and its allowlist.

Parameters
Name In Required Description
connection_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/connections/read-spec readConnectionSpec
  • agent:write
  • heavy

Read an uploaded specification and list the operations it offers.

Stores nothing. This is the wizard's "we read your file, here is what it offers" step, and it is what the customer picks the allowlist from.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/document-previews createDocumentPreviewUpload
  • agent:write

Stage a menu for a preview — or answer the preview of the same file already read.

Names the template, the language the menu is written in and the file (its size and SHA-256). When this workspace already has a preview of the very same file for the same template and language — read, or being read — it is answered at once (200, upload: null): attaching the same file again costs nothing and never reads it again. Otherwise (201) the preview is uploading and upload is a short-lived, write-only link the browser PUTs the bytes to with exactly the headers given; then call confirmDocumentPreview.

Each new preview is a reading by the model: at most the platform's number per person per hour (429 rate_limited, with Retry-After). Only a template that reads a menu has previews (422 validation_failed on agent_template_code). A preview is kept for the platform's retention and then removed.

Takes a JSON request body.

  • 200
  • 201
  • 400
  • 401
  • 403
  • 413
  • 415
  • 422
  • 429
GET /v1/document-previews/{document_preview_id} readDocumentPreview
  • agent:write

One menu preview — poll it while it is being read.

Parameters
Name In Required Description
document_preview_id path yes The preview id createDocumentPreviewUpload answered.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/document-previews/{document_preview_id}/confirm confirmDocumentPreview
  • agent:write

Check the uploaded menu and start reading it.

Reads the stored file back and checks that its size, its content type and its SHA-256 are the declared ones; a mismatch discards it (422 document_mismatch). Then, when a preview of the same file was read already, this upload is folded into it and that preview is answered; otherwise the preview is queued and WeTalk reads the menu in the background. Poll readDocumentPreview until it is done or failed. Confirming a preview that is no longer uploading answers it as it is.

Parameters
Name In Required Description
document_preview_id path yes The preview id createDocumentPreviewUpload answered.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
GET /v1/messaging/conversations listMessagingConversations
  • agent:read

Conversations on the workspace's website widgets, typed or spoken.

The Messaging page's conversation table (plan-004 WC-90, design §17.10.7): every conversation on a web_chat_widget channel binding — web_chat and widget voice — with the visitor (visitor_tag, never the key), the channel label, the last line said, the outcome and when. The console's own test chat has no binding and is not listed.

Read through the reader's lens: party_address comes back null for a Member and a Viewer — conversation_visible NULLs it in the database — and last_message is null for a Viewer, whose role does not include what was said (transcript_withheld).

  • 200
  • 401
  • 403
  • 429
GET /v1/messaging/media listMessagingMedia
  • agent:read

The four media, and whether each can carry a conversation today.

  • 200
  • 401
  • 403
  • 429
GET /v1/messaging/media/{medium_code} getMessagingMedium
  • agent:read

One medium in full — connected, coming soon or not connected.

Decodes against MessagingMediumDetail for all four media. For web_chat it also carries every website widget (widget, each with its snippet, CSP lines and state) and install (the script URL and the billing sentence); for the other media widget is empty and install is null.

Parameters
Name In Required Description
medium_code path yes One of the four media of the medium enum.
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/messaging/media/{medium_code}/connection connectMessagingMedium
  • agent:write

Bind a medium to a VoiceAgent.

Refused with 409 for a waitlisted medium, and for voice — a number, a trunk and a rotating SIP password are Numbers & SIP's surface, and binding a number in two places is how one of them ends up wrong. The refusal says which of the two it is.

For web_chat the body also needs site_origin — the address of the website the chat goes on (https://host[:port]; plain http only for localhost) — which becomes the widget's first allowed origin. A voice agent that already has a widget gets that widget back, re-enabled, with the address added (200); otherwise a new one is made (201). The widget key is the binding's id: a public identifier pasted into the customer's own page, carrying no scope and no secret.

Parameters
Name In Required Description
medium_code path yes One of the four media of the medium enum.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/messaging/media/{medium_code}/disconnection disconnectMessagingMedium
  • agent:write

Stop answering on a medium, keeping its history.

Disables the workspace's bindings on this medium. Nothing is deleted: conversations reference the binding, and a delete would have to choose between breaking the foreign key and taking the transcripts with it. Each website widget it turns off is recorded in the audit log.

Parameters
Name In Required Description
medium_code path yes One of the four media of the medium enum.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 200
  • 401
  • 403
  • 404
  • 429
PATCH /v1/messaging/media/web_chat/widgets/{channel_binding_id} updateWebChatWidget
  • agent:write

Turn a website widget on or off, and its voice on or off.

{ enabled?, voice_enabled? } — at least one. Turning either on needs at least one allowed origin (422 origin_list_empty). Turning the chat off stops it answering at once. Each change writes one audit entry; a request that changes nothing writes none. Owners and Admins only.

Parameters
Name In Required Description
channel_binding_id path yes The website widget's channel_binding_id.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
POST /v1/messaging/media/web_chat/widgets/{channel_binding_id}/disconnection disconnectWebChatWidget
  • agent:write

Disconnect a website widget, keeping its history.

Turns the widget off, turns its voice off and deletes its allowed origins; the page then shows it as not connected. Nothing is deleted from History, and connecting the same voice agent again brings back the same widget key. Owners and Admins only.

Parameters
Name In Required Description
channel_binding_id path yes The website widget's channel_binding_id.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
PUT /v1/messaging/media/web_chat/widgets/{channel_binding_id}/origins replaceWebChatWidgetOrigins
  • agent:write

Replace the sites a website widget answers on.

{ allowed_origin: [...] } replaces the whole list. Each value is normalised to scheme://host[:port] (lower-case, default port dropped); https only, except http://localhost:<port>. At most 20 after removing duplicates. A value that is not a site address → 400 origin_invalid naming allowed_origin[<i>]; an empty list while the chat or its voice is on → 422 origin_list_empty. An origin check stops the widget being embedded on another site; it is not authentication. Owners and Admins only.

Parameters
Name In Required Description
channel_binding_id path yes The website widget's channel_binding_id.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
GET /v1/numbers listNumbers
  • agent:read

Every number in the workspace, bound or not.

The per-VoiceAgent list cannot serve this screen: a number with nothing bound to it has no VoiceAgent to be listed under, and that is exactly the row a customer opens Numbers & SIP to fix. Your own SIP trunk's numbers are listed beside carrier numbers, with provider: sip_trunk and the trunk's display name. meta.sip_trunk_count counts the workspace's customer SIP trunks (deleted ones excluded) — not the legacy per-VoiceAgent SIP credentials.

The kicker deliberately counts numbers and trunks and not a WebRTC endpoint: the browser media plane is unresolved (plan spike S8, risk R15), and a count of something that cannot carry audio would be a claim rather than a figure.

  • 200
  • 401
  • 403
  • 429
GET /v1/numbers/{phone_number_id}/activity getNumberActivity
  • agent:read

One number's last conversation, this month's count and its five latest conversations.

What the Numbers & SIP screen shows when a number is opened: "Last call", "This month" and "Recent activity". A conversation counts when it came in — or, for an outbound leg through a SIP trunk, went out — through a voice binding of this number while the workspace held it (between the number's purchased_at and released_at), which is exactly what GET /v1/conversations?phone_number_id= lists ("See all calls on this number").

"This month" starts at the first instant of the current month in the workspace's own time zone. Test conversations count: they are conversations on this number. Read through the caller's own lens, so a Member and a Viewer get party_address as null, as in History.

Parameters
Name In Required Description
phone_number_id path yes A number this workspace holds or held. Another workspace's number is a 404.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/numbers/availability getNumberAvailability
  • agent:read

Whether WeTalk can issue a number today.

Answers waitlisted. Which countries WeTalk issues numbers in, and who bears the regulatory bundle, is an open product decision: a Greek +30 number needs proof of identity from an EU member state and takes days, a US number needs none. The detail names the two paths that exist: a number request WeTalk fulfils, and "Connect a trunk" for numbers you already have with your own VoIP provider (/v1/sip-trunks).

  • 200
  • 401
  • 403
  • 429
GET /v1/numbers/region-latency getRegionLatency
  • agent:read

Region and round-trip latency, measured by WeTalk.

No carrier API reports this. Neither Telnyx nor Twilio exposes a per-region latency figure to a customer, so the design's us-west · 4 ms is a WeTalk measurement or it is decoration. Samples come from region_latency_sample, the platform table design §4.7 created for them; when nothing has probed inside the window the response says measured: false and carries no figure. A plausible number with nothing behind it is the number a customer would quote back during an incident.

  • 200
  • 401
  • 403
  • 429
GET /v1/numbers/requests listNumberRequests
  • agent:read

This workspace's number requests, newest first, with whether each has been met.

state is derived at read time: connected once the requested agent has a number bound to it at or after the request, pending until then.

  • 200
  • 401
  • 403
  • 429
POST /v1/numbers/requests requestPhoneNumber
  • agent:write

Ask WeTalk to set up a phone number for one of your agents.

Numbers are set up by WeTalk for now. This records the request — the agent that should answer, the country, and anything WeTalk should know (a number you already own, your own phone system) — as an entry in your audit log (voice_agent.number_requested); the entry's id is the request's. An operator reads it from the staff API and assigns the number; the request then reads state: connected.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/optimisation-reviews/{optimisation_review_id} getOptimisationReview
  • agent:read

One optimisation review, with its findings and their decisions.

recording_available_count is recounted over the review's window as it stood when it was requested, against today's retention. next_agent_version_label is the label the draft would take. queued is set while the review waits for the worker.

Parameters
Name In Required Description
optimisation_review_id path yes
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
PUT /v1/optimisation-reviews/{optimisation_review_id}/findings/{optimisation_finding_id}/decision decideOptimisationFinding
  • agent:write

Accept, reject or un-decide one finding.

Findings are decided one at a time and nothing is accepted by default. Allowed only while the review is done and its fixes are not yet in a draft (409 optimisation_review_state_conflict otherwise). Writes the audit entry optimisation_finding.decided.

Parameters
Name In Required Description
optimisation_review_id path yes
optimisation_finding_id path yes

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/optimisation-reviews/{optimisation_review_id}/publish publishOptimisationReview
  • agent:write

Save the accepted fixes as a draft version — a compare-and-set on the live sequence.

Creates a draft; publishes nothing (plan-015 DRF, ADR 0043 — "Create draft vN with N fixes"). Applies each accepted finding's configuration patch to the live version's configuration document (a patch may change only the greeting, the extracted catalogue, the opening hours, the overflow and the after-hours behaviour — never transfers, connections, consent wording, languages, voice or concurrency), validates the result and inserts it as a ready version with no published_at and base_agent_version_id = the live version, provided live_version_seq still equals expected_live_version_seq. The review names the draft from then on.

The live version does not change: there is no agent_version.published outbox message, live_version_seq comes back as it was, and every conversation keeps reaching the live version until somebody who may publish publishes the draft from Versions (publishAgentVersion). Writes the audit entries agent_version.draft_created and optimisation_review.published. The charge is unchanged: the review was charged when it completed, and saving its fixes as a draft moves no money.

A review whose draft was never published and is no longer built on the live version (another version went live meanwhile) may run this again: the same accepted fixes are applied to the current live document and the review names the new draft ("Recreate on the current version"). A draft still built on the live version, or one already published, is 409 optimisation_review_state_conflict.

A live_version_seq that moved since you read it is 409 live_version_seq_stale with nothing saved: reload and decide again, never retry with the same number.

The operationId keeps its earlier name so generated clients do not break.

Parameters
Name In Required Description
optimisation_review_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
POST /v1/public/widget-voice/{widget_key}/grants createWidgetVoiceGrant
  • no key needed

Start a voice conversation from a website widget.

Unauthenticated. A real conversation with the widget's agent over WeTalk's browser voice relay, billed to the site's owner like any other minute from the moment it is answered, for at most max_duration_second (10 minutes).

Checked in this order; each refusal writes nothing and creates no room: the request's Origin is WeTalk's voice frame (403 origin_not_allowed); the body (400 validation_failed); the proof of work — genuine, unexpired, solved and not submitted before (400 proof_of_work_failed); at most ten grants from one network address per hour (429 rate_limited); the widget exists, is on and has voice switched on — one answer for all three (404 widget_voice_disabled); embedding_origin is one of the widget's allowed sites (403 origin_not_allowed); the agent can take the conversation now (503 widget_voice_unavailable — one answer whatever the reason, so a visitor never learns the owner's billing state); every one of the agent's lines busy (503 all_lines_busy).

Joining the chat. When the visitor is already chatting, the frame sends the chat's single-use join_proof; the voice then joins that conversation instead of starting a new one, after every check above. The agent runtime judges the proof: one it refuses (expired, already used, another widget's or another visitor's) is 503 widget_voice_unavailable like any other reason. Without a proof the grant behaves exactly as before.

An allowed-sites check stops a widget being embedded on another site; it is not authentication. The proof of work, the per-address limit and the agent's own concurrency are the abuse bounds.

Parameters
Name In Required Description
widget_key path yes The widget's public key, as the site's snippet carries it.

Takes a JSON request body.

  • 201
  • 400
  • 403
  • 404
  • 429
  • 502
  • 503
GET /v1/public/widget-voice/challenge issueWidgetVoiceChallenge
  • no key needed

A fresh proof-of-work challenge for the widget's voice frame.

Unauthenticated. An ALTCHA challenge — the same self-hosted check, key and cost as the public opt-out page (product decision A6) — signed by this API and valid for five minutes. The voice frame solves it and sends the solution as altcha in createWidgetVoiceGrant; each solved challenge is accepted once.

Counted per source address by the API's public limiter, so challenges cannot be stockpiled. Never cached. The body's data is ALTCHA's own challenge shape (camelCase field names — the solver reads exactly these).

  • 200
  • 429
  • 502
GET /v1/sip listSipCredentials
  • agent:read

The workspace's SIP credentials, one per VoiceAgent (legacy).

Legacy (P10). The carrier-era per-VoiceAgent SIP credentials. The route stays and answers as before; the Numbers & SIP screen no longer shows them (the customer trunk card, /v1/sip-trunks, replaced the credentials card), and they are not counted in listNumbers.meta.sip_trunk_count.

The password is not on this wire and never will be: secret_ref is a key-store secret NAME. Rotation is POST /v1/agents/{voice_agent_id}/sip/rotation, which is accepted and applied by the worker.

  • 200
  • 401
  • 403
  • 429
POST /v1/test-calls/{conversation_id}/end endTestCall
  • agent:write

Hang up a voice test call.

Only a voice test conversation (is_test, medium = voice) of the caller's workspace can be addressed; any other id — a customer's real conversation included — is 404 conversation_not_found. A test call that has already ended is answered with its stored facts (state = ended), so ending twice is harmless. An open one is hung up by the agent_runtime, which holds the carrier leg; the answer is the call as stored at that moment (usually still live — the carrier's hangup ends the row a moment later), so read it again with readTestCall. The API never marks a call ended that it did not end: when the runtime no longer carries the call, or does not answer, it is 503 agent_runtime_unavailable and the row is left as it is. billed_amount is what the billed seconds are worth at the account's rate; charged_amount is what billing charges once the free trial has covered what it covers (review #233); the invoice is the day close (design §7.9).

A browser test (plan-004, since 2026-09-26) is ended through the agent_runtime's WebRTC leg, not a carrier: an open conversation on the agent's browser binding, or — in the seconds before the runtime opens its row — a test the caller started a moment ago (state = ended, end_reason = caller_hangup, nothing billed). Another member's just-started browser test is 404, as an unknown id is.

A test call on a trunk number (plan-008, since 2026-09-30) is ended through the trunk's own end route, as endSipTrunkTestCall ends it. A call the tester placed into a trunk number has no end route: 422 validation_failed — hang up on the phone.

Parameters
Name In Required Description
conversation_id path yes The test call's conversation.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
  • 503
GET /v1/test-calls/availability readTestCallAvailability
  • agent:read

What can be tested right now, and why not.

Read when the Test console or a Test tab opens, so a test that cannot run is said before anyone types or presses a button. model_running is false outside the model pool's scheduled windows; direction_available lists the voice test directions that can be placed (dialling in only: the platform places no outbound calls yet); message says why something cannot run, in words for a person. country_calling_code is the account's own (+30), for the phone field's example. browser_test_call_available (plan-004) says whether a browser test can start — the runtime answered, the model is running and WeTalk's browser voice relay is reachable — with browser_test_call_message saying why not.

  • 200
  • 401
  • 403
  • 429
GET /v1/workspace/shared-agent-configurations listSharedVoiceAgentConfigurations
  • agent:write

The shared agent configurations this workspace may start a new agent from.

plan-014 (B5's hand-off): every agent configuration another workspace of the organization shared, that this workspace accepted and has not copied yet, while the share stands and both workspaces are active — exactly what createVoiceAgent accepts as resource_share_id. Each carries the pinned version's kind of agent, languages, voice, greeting, recording switch and concurrency, which the wizard starts from. Calls, recordings, numbers and documents never travel with a configuration. Owner or Admin, like creating an agent.

  • 200
  • 401
  • 403
  • 429

Conversations

GET /v1/conversations listConversations
  • conversation:read

Conversations, newest first, with the figures for the whole filtered set.

The History tab's list. Every filter is a query parameter so a filtered view is a link somebody can paste into a message.

figure describes the whole filtered set, not the page: the conversation count and the talk time must not change as somebody scrolls. Paging is by cursor and never by offset — a conversation that ends mid-page would otherwise produce a duplicate row or skip one.

billed_amount is indicative. Design §7.9 prices a day at close, per (voice_agent, day, medium, is_test) grouping, so that hundreds of per-conversation roundings never accumulate into a balance that does not reconcile; the figure here is what one conversation's seconds are worth at the account's rate, and the invoice is the day close.

Parameters
Name In Required Description
voice_agent_id query no One VoiceAgent's conversations. Omit for every VoiceAgent in the account.
since query no Only conversations that started at or after this instant.
until query no Only conversations that started strictly before this instant.
outcome_state query no Whether an outcome was reached. conversation.outcome is free text drawn from the agent_template's own vocabulary — "Order placed" for a delivery agent, "Appointment booked" for a clinic — so a filter written over the words would mean something different for every template. These four are written over what the schema holds: whether an outcome exists at all, and whether the conversation ended in a transfer.
outcome query no One outcome exactly as the agent_template words it — "Order placed". The page's outcome_option lists the words this VoiceAgent's conversations have reached, so a console never has to guess them. Narrows within outcome_state.
medium query no Voice, web chat, WhatsApp or Viber. Never a carrier's own word. A mixed widget conversation (channel_label web_chat_with_voice) carried both voice and web chat, so either value lists it; its medium is the mode it started in.
channel_label query no How the conversation came in — see the ChannelLabel schema. "Website voice" and a phone call are both medium=voice; this is what tells them apart. Narrows within medium. web_chat and website_voice each also list the mixed conversations (web_chat_with_voice), which were both (plan-005 D9); web_chat_with_voice lists only them.
party_of query no One party's conversations: those sharing the named conversation's party on its channel binding, that conversation included — a visitor's earlier chats on the same widget. Named by a conversation id rather than an address, so the link carries no caller detail and works for a Member, who never reads the address.
phone_number_id query no One number's conversations: those that came in — or, for an outbound leg through a SIP trunk, went out — through a voice binding of the named number while the workspace held it (numbers-sip-sync; the same set GET /v1/numbers/{phone_number_id}/activity counts). Named by the number's id, never its digits.
search query no Matched against the far end's address, the outcome and the conversation id. A search made only of digits and phone separators (at least four digits) is also matched on the address's digits alone, so "694 713 0688" finds "+306947130688". A Member and a Viewer read party_address as null, so for them the address term never matches — which is the caller-detail predicate working, not a separate rule.
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 429
GET /v1/conversations/{conversation_id} getConversation
  • conversation:read

One conversation with its transcript, its event timeline and its recording reference.

The four sections the History detail panel renders, which are the four questions a customer asks about a finished conversation: what did it sound like (recording), what happened in a sentence (transcript.summary), what was said (turn), and what the platform did (event).

recording is a reference, not a playable URL. Design §8.8 makes playing a recording an audited act — POST /v1/conversations/{id}/recording/access writes the audit entry and only then issues a short-lived URL — so handing one out here, on a route that only requires conversation:read, would be the window that design closes.

recording is null in three situations a reader cannot tell apart, deliberately: there is no recording, retention has already deleted it, or the reader is a Member or a Viewer and the database returned no row.

sip_leg (plan-008) is present only for a conversation that came through your own SIP trunk: the trunk, the codec it negotiated and whether that was HD, the transport and media encryption as observed, and an outbound conversation's origin and reference.

Parameters
Name In Required Description
conversation_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/conversations/{conversation_id}/recording/access accessConversationRecording
  • recording:read

Write the audit entry, then return a short-lived URL for one recording.

Requires the recording:read scope on a key, and an Owner or Admin role on a session — the latter enforced by the database rather than by this endpoint.

Every call is audited. An audit_entry with action_code = "recording.played" is committed before the URL is created, in the account that owns the conversation. When an operator of WeTalk plays a recording — including while impersonating one of your people — the entry carries that operator, so your own audit log shows who listened.

An account can switch operator access to recordings off entirely; while it is off, an operator receives recording_operator_access_disabled and no URL is minted.

The URL expires. It is signed for exactly one object, read-only, over HTTPS, and it is not a permanent address: fetch a new one rather than storing this.

Parameters
Name In Required Description
conversation_id path yes
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/conversations/live listLiveConversations
  • conversation:read

One VoiceAgent's conversations in flight, and today's figures beside them.

A live conversation is one with ended_at IS NULL (glossary C11). voice_agent_id is required: the channel-unit ceiling, the outcome label and the meter are all properties of one VoiceAgent, and answering without one would be answering a different question.

"Today" is the workspace's day, in the workspace's own time zone, because a usage day is the customer's day rather than the server's.

last_event_kind is the most recent conversation_event on that conversation, which is how far it has got. It is null until the first event lands.

Each conversation also carries media_codec, media_sample_rate_hz and latency_ms (each nullable): the leg's observed media format, written once when it was answered, and its latency so far. Null means not reported, or not measured yet — never a guess.

Parameters
Name In Required Description
voice_agent_id query yes The VoiceAgent whose live board this is.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/conversations/live-activity listLiveActivity
  • conversation:read

Every conversation in flight, across every VoiceAgent the reader may see.

The account-wide Live activity page (2026-09-29). A live conversation is one with ended_at IS NULL (glossary C11). Unlike listLiveConversations, no voice_agent_id is taken: the answer is every VoiceAgent's, oldest conversation first, each row naming its VoiceAgent and workspace so the page can link to that agent's own Live tab.

Visibility is History's: a person reads the workspaces they belong to, and a Member or a Viewer gets each row with party_address null.

  • 200
  • 401
  • 403
  • 429

Developer

API keys, webhook endpoints and the delivery log.

GET /v1/accounts/{account_id}/api-keys listAccountApiKeys
  • signed-in person only

The organization's keys (account scope).

The organization keys — billing:read, usage:read, document:read — newest first, revoked ones included. Needs account.api_key.manage (organization Owner, Admin): 403 capability_missing otherwise. Another organization's id is 404 not_found.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/accounts/{account_id}/api-keys createAccountApiKey
  • signed-in person only

Issue an organization key. It is shown exactly once.

An organization key reads the organization's finance — balances and offers (billing:read), attributed usage (usage:read), financial documents (document:read) — and nothing else: no workspace content, no membership, no change of any kind. Needs account.api_key.manage (organization Owner, Admin); every scope must be one the caller holds (403 scope_insufficient). Otherwise as POST /v1/workspaces/{workspace_id}/api-keys; api_key.created is audited in the same transaction.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/api-keys listApiKeys
  • signed-in person only

The keys of the workspace the console has open (kept for one release).

GET /v1/workspaces/{workspace_id}/api-keys for the auth_session's own workspace. Use that route.

  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/api-keys createApiKey
  • signed-in person only

Issue a key in the workspace the console has open (kept for one release).

POST /v1/workspaces/{workspace_id}/api-keys for the auth_session's own workspace, with the same body and rules (expires_at required). Use that route.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
DELETE /v1/api-keys/{api_key_id} revokeApiKey
  • signed-in person only

Revoke a key.

revoked_at is set; the row stays. It is what answers "which key made this call" for every audit entry that already names it. The key stops working on the next request, and a job acting for it re-checks it before acting. Writes an api_key.revoked audit entry.

Needs api_key.manage in the key's workspace (or api_key.integration_manage for a key holding only integration scopes); an organization key, or an organization integration key, also yields to account.api_key.manage. A key that is absent, already revoked or of another organization is 404 not_found.

Parameters
Name In Required Description
api_key_id path yes
  • 204
  • 401
  • 403
  • 404
  • 429
POST /v1/api-keys/{api_key_id}/rotate rotateApiKey
  • signed-in person only

Rotate a key — a new value, the old one revoked. Shown exactly once.

Issues a new key with the same name, scopes, owner, networks and rate limit, and revokes the old one in the same database transaction (revoked, never restored — revoked_reason says it was replaced). The new key names the old one in rotated_from_api_key_id. expires_at is asked for again: a rotation is when the next end date is set. Needs what issuing that key needs; the caller must still hold every scope it carries (403 scope_insufficient). A revoked key is 409 conflict. Writes api_key.rotated.

AMENDED 2026-10-06 (security review M-8): a person-owned key is rotated by its owner alone — anyone else who may manage it is refused 403 permission_denied (revoke it instead, then create a key of your own); an integration key by whoever may manage integration keys there.

Parameters
Name In Required Description
api_key_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/webhook-endpoints listWebhookEndpoints
  • agent:read

The webhook endpoints configured in this account.

Parameters
Name In Required Description
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/webhook-endpoints createWebhookEndpoint
  • agent:write

Register a destination for outbound events.

Delivery is at-least-once from the transactional outbox, retried with exponential backoff for seven days, and every attempt is recorded. The receiver must be idempotent on the event id.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 409
  • 429
GET /v1/webhook-endpoints/{webhook_endpoint_id}/attempts listWebhookDeliveryAttempts
  • agent:read

The per-attempt delivery log for one endpoint.

One row per attempt, including the response status and an excerpt of the response body. This is what the console's delivery log renders, and it is the evidence that a delivery was attempted when a customer says it never arrived.

Parameters
Name In Required Description
webhook_endpoint_id path yes
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/workspaces/{workspace_id}/api-keys listWorkspaceApiKeys
  • signed-in person only

The keys issued for one workspace.

Newest first, revoked ones included. Needs api_key.manage (Owner, Admin) or api_key.integration_manage (also Developer) in that workspace: 403 capability_missing to a member holding neither; a workspace the caller has no role in, or another organization's, is 404 not_found.

No row carries the key. Only its digest is stored, so there is nothing to return.

Parameters
Name In Required Description
workspace_id path yes The workspace whose keys are read or issued.
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/workspaces/{workspace_id}/api-keys createWorkspaceApiKey
  • signed-in person only

Issue a workspace key. It is shown exactly once.

key is present in this response and nowhere else, ever.

No key exceeds its issuer. Every scope must be one the caller holds in this workspace right now: 403 scope_insufficient names the ones they do not. A person-owned key (owner_kind: person, the default) is bounded again on every use by its owner's CURRENT role, and stops working when its owner leaves the workspace. owner_kind: account_integration issues an organization integration key, bounded by its scopes alone; it needs account.api_key.manage (organization Owner, Admin) as well as api_key.manage here.

Needs api_key.manage (Owner, Admin), or api_key.integration_manage (also Developer) when every scope is agent:read, agent:write or conversation:read. expires_at is required and in the future. allowed_network (optional) lists the addresses or ranges the key may be used from; the address judged is the one WeTalk's ingress saw, never a header the caller wrote. rate_class may only be standard (WeTalk support raises a key to elevated). name is at most 60 characters and no other live key in the workspace may already have it (compared without regard to case): 409 conflict.

The key and an api_key.created audit entry (its name, scopes and settings — never the key) are written in one database transaction.

Parameters
Name In Required Description
workspace_id path yes The workspace whose keys are read or issued.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429

Billing

GET /v1/billing-accounts/{billing_account_id}/postpaid getPostpaid
  • any key

A balance's postpaid contract, what it owes and what is still available.

The credit limit WeTalk approved, owed now (billed on unpaid monthly invoices plus usage not billed yet, open conversations included — nothing counted twice), still available (the live balance plus the credit limit and overage; new paid conversations stop at zero), the terms (invoice due 14 days after the month ends, card or bank transfer), the "if a payment fails" schedule (due date, +7, +14), each unpaid invoice with its collection steps, the last invoice settled and the collection state. A prepaid balance answers contract: null. Reading never advances anything. Needs account.finance.read over the balance.

Parameters
Name In Required Description
billing_account_id path yes The balance (billing account) read — always named, never "the first".
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/billing-accounts/{billing_account_id}/postpaid/pay payPostpaid
  • any key

Pay an unpaid postpaid invoice by card — open the processor's payment page.

Starts a statement_payment billing operation for the invoice's gross (frozen at the month's close) and answers with the hosted payment page to send the person to (next_action), or hands back the page an open payment of the same invoice already waits on. Never a second attempt (409 payment_pending); an invoice already paid or with nothing due is 409 operation_state_conflict; a contract paid by bank transfer only is 409 conflict. The outcome arrives from the processor; this answer reports nothing. Needs account.finance.manage.

Parameters
Name In Required Description
billing_account_id path yes The balance (billing account) read — always named, never "the first".
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 202
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/billing-accounts/{billing_account_id}/refund-requests createRefundRequest
  • any key

Ask for unused purchased credit back; a finance operator decides.

A draft refund operation awaiting an operator with finance rights (owner decision OD-13). Approved, the credit goes back to the original cards, newest purchase first, the remainder by bank transfer. One request may wait per balance (409 operation_conflict); more than is refundable is 422 refund_not_refundable.

Parameters
Name In Required Description
billing_account_id path yes The balance (billing account) read — always named, never "the first".
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 200
  • 202
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/billing-accounts/{billing_account_id}/refundable getRefundable
  • any key

Unused purchased credit on a balance.

Parameters
Name In Required Description
billing_account_id path yes The balance (billing account) read — always named, never "the first".
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/billing-operations/{billing_operation_id}/pay payOperation
  • any key

Pay now — open (or re-open) the processor's payment page.

Confirms an automatic top-up waiting for a click (collection mode hosted_notification) or hands back the page an operation already waits on. Never a second attempt for an operation that has one (409 payment_pending).

Parameters
Name In Required Description
billing_operation_id path yes The durable billing operation.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/billing-operations/{billing_operation_id}/payment getOperationPayment
  • any key

What a billing operation is waiting on, and how to resume it.

Reads, never advances: a page the person returns to from the processor reads this. "Waiting on the bank" (an unknown outcome being looked up) and "paid, not applied" (resumed exactly once) are named.

Parameters
Name In Required Description
billing_operation_id path yes The durable billing operation.
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/billing/auto-reload getAutoReloadPolicy
  • credit:spend

The automatic top-up rule.

An account with no policy is not a 404 — configured: false is a real state and the screen renders the switch as off.

  • 200
  • 401
  • 403
  • 429
PUT /v1/billing/auto-reload putAutoReloadPolicy
  • credit:spend

Set the automatic top-up rule.

A change to an existing policy names the revision it read in If-Match (400 validation_failed without it) and is refused 409 revision_stale when the policy changed since — two saves from one revision cannot both win (plan-014 review I-9, 2026-10-06). A first save needs none; if another first save got there first it is refused 409 revision_stale too. One policy per balance (migration 0114).

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
If-Match header no The policy's revision, quoted (e.g. "3"). Required once a policy exists.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 409
  • 429
GET /v1/billing/balance getBillingBalance
  • credit:spend

The balance, and what is actually left to spend.

Returns the settled balance and the live one. They answer different questions: the settled figure is what has been charged and paid, the live figure subtracts today's unbilled seconds and what open conversations have reserved, and it is the figure the hard stop is evaluated against.

  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/billing/budget getBudget
  • credit:spend

The workspace's spend controls.

The monthly budget, the percentage that sends the warning, and the hard stop. An unconfigured workspace answers configured: false with a null budget rather than a 404: "no budget has been set" is a state the screen renders, not a missing resource.

  • 200
  • 401
  • 403
  • 429
PUT /v1/billing/budget putBudget
  • credit:spend

Set the monthly budget, the alert percentage and the hard stop.

The hard stop is an instruction, not a verdict. Writing it records that agents should stop answering rather than overspend; whether they have is decided on every admission by billingRefusalOf against design §7.9's live balance — the settled balance less today's unpriced seconds less what conversations in flight may still cost. Freezing that judgement at write time would use a number that is stale by the next call.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 429
GET /v1/billing/charge-attempts listChargeAttempts
  • credit:spend

Recent charge attempts, newest first.

What the declined banner is made of. It is a separate read from the balance on purpose: "your card was declined" and "you are out of credit" are different states with different remediations, and a funded account can be in the first without being in the second — state on the balance cannot express it, because a decline is a fact about a card rather than about the live balance.

requires_reauth and declined are distinct: one asks the cardholder to authenticate again, the other asks for a different card. ambiguous means the outcome is unknown and the reconciliation poller owns it — it never means "retry".

Parameters
Name In Required Description
limit query no
  • 200
  • 400
  • 401
  • 403
  • 429
GET /v1/billing/funding getWorkspaceFunding
  • credit:spend

Which balance pays for the open workspace, since when, and what the person may do with it.

The balance in force for the open workspace (scope account is the organization's shared balance, workspace its own), the start of its unbroken run on that balance (since; null for a workspace not moved to effective-dated funding yet), and the workspaces the balance pays for — named only for someone who may read the organization's workspaces (funded_workspace_names_complete), otherwise the count and the open workspace alone.

finance_access is what the person may do with the balance: manage or read (organization Owner, Admin or Finance, or the delegate of a dedicated balance) or none — a workspace member without it sees the workspace's spend only, and the balance's payment methods, automatic top-up, charge attempts, invoices and ledger answer 403 capability_missing.

  • 200
  • 401
  • 403
  • 429
GET /v1/billing/invoices listInvoices
  • credit:spend

The issued invoices, newest period first.

blob_name is the stored reference, not a URL. Handing out a signed link is an access grant and belongs to the download route, not to a listing.

Parameters
Name In Required Description
limit query no
  • 200
  • 400
  • 401
  • 403
  • 429
GET /v1/billing/ledger listLedgerEntries
  • credit:spend

Transactions — every money movement, newest first.

Cursor-paginated on sequence, which is monotonic per billing account. Never offset-paginated: the ledger is append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.

Parameters
Name In Required Description
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 429
GET /v1/billing/payment-methods listBillingPaymentMethods
  • credit:spend

Saved cards — brand, expiry and the last four digits.

  • 200
  • 401
  • 403
  • 429
DELETE /v1/billing/payment-methods/{payment_method_id} deleteBillingPaymentMethod
  • credit:spend

Remove a saved card.

The row is stamped rather than deleted, so a historical payment still resolves to the card it was made against. Removing the default promotes the most recently added remaining card.

Parameters
Name In Required Description
payment_method_id path yes
  • 204
  • 401
  • 403
  • 404
  • 429
POST /v1/billing/payment-methods/{payment_method_id}/default setBillingDefaultPaymentMethod
  • credit:spend

Make this card the default.

Parameters
Name In Required Description
payment_method_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/billing/payment-methods/card-purchase completeCardPurchase
  • credit:spend

Buy the credit pack with the form's token, and keep the card.

Charges the single-use token the processor's form produced for the amount of the quote the form was opened for (billing_quote_id: the pack's net + VAT, FIX-MONEY C1) — never an amount computed from the request; a form amount (amount_due_minor) that is not that quote's total is 409 quote_mismatch and nothing is charged — through the same charge-intent ledger every top-up uses. On success the account is credited and the card is stored from the processor's own answer: its stored-customer token, card token, mandate, brand, expiry, holder name and last four digits. Nothing card-shaped is ever accepted from the request; card_token must be the form's ctn_ token, and a stored customer or card token is refused.

Idempotency-Key is required. The reference is generated before the call and gives duplicate *rejection*, not idempotency: when the outcome is unknown the processor is asked what happened to that same reference, once, and if it still cannot say, the answer is pending_reconciliation — never a second charge.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 409
  • 429
POST /v1/billing/payment-methods/hosted-form createHostedCardForm
  • credit:spend

What the payment form opens with, to add a card by buying credit.

A card is added by buying credit (product decision 2026-09-23). The processor documents no card-saving mode without a payment: its hosted Payform needs an amount, and the authorisation an automatic top-up charges later (the mandate) is issued only by a real first payment made from a form opened with frequency_type: recurring.

Name one of the credit packs; the answer carries the amount in integer minor units, the processor's public key, the processor's own script and the frequency type. The amount is the server's — there is no amount in the request. Nothing is charged: the browser mounts the processor's form with these values, the cardholder enters the card there (never into WeTalk), and the form hands back a single-use token for completeCardPurchase.

AMENDED 2026-10-06 (FIX-MONEY C1): prices exclude VAT (OD-8), so the pack is NET credit and this route writes one row — a top_up quote of the pack's net + VAT (billing_quote_id, net_minor, tax_minor, the total). The form opens, and 3-D Secure authenticates, that total; the console shows net, VAT and total first. 409 terms_acceptance_required until an Owner or Finance member has accepted the purchase terms (PD-8); 422 legal_profile_required without the organization's legal details; 503 tax_rate_unset when no VAT rate is in force for the payer's treatment.

The free trial minutes need no card.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 409
  • 429
POST /v1/billing/top-ups createTopUp
  • credit:spend

Buy credit with a saved or newly entered card.

The body carries a token the hosted payment form produced — card data never reaches WeTalk, which is what keeps PCI scope at SAQ A.

A reference is generated deterministically from the account and the attempt before the call and is reused on any retry. It gives duplicate *rejection*, not idempotency: an error outcome means the result is unknown and must be looked up, never that the charge can be tried again.

AMENDED 2026-10-06 (FIX-MONEY C1): amount_minor is the NET credit; the card is charged the net + VAT of the quote the card form was opened for (billing_quote_id, amount_due_minor — createHostedCardForm). A form amount that is not that quote's total is 409 quote_mismatch and nothing is charged.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 409
  • 429
GET /v1/billing/usage listBillingUsage
  • credit:spend

Talk time, summed per VoiceAgent per day.

The roll-up FR-BIL-1 asks for. Seconds are summed per (voice_agent, day, medium, is_test) and priced once, at day close — which is what stops a hundred and forty-two separate roundings accumulating into a balance that does not reconcile.

Parameters
Name In Required Description
from query yes First day, inclusive, in the workspace's own time zone.
to query yes Last day, inclusive.
  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/billing/webhooks/everypay/new-payment receivePaymentWebhookNewPayment
  • no key needed

The payment processor's "new payment" webhook.

The processor posts the bare payment object here when a payment is created on the billing merchant. The route is the event kind; nothing in the body is read to decide it.

Public: the credential is the signature. X-Signature-SHA256 is base64(hmac_sha256(raw body, secret)) and is verified against the raw bytes, constant-time, before any JSON parsing. A request from an address outside the processor's documented list is refused with 403 and nothing is written. Every other request is recorded with its raw bytes and its verdict, including a forged one, which is acknowledged with 200 and processed by nothing.

Nothing depends on this endpoint being reached. The processor's retry behaviour is undocumented, so a reconciliation poller closes every payment from WeTalk's own side as well. A webhook is a trigger, never a fact.

Takes a JSON request body.

  • 200
  • 403
POST /v1/billing/webhooks/everypay/notification-expired receivePaymentWebhookNotificationExpired
  • no key needed

The payment processor's "notification expired" webhook.

The processor posts the bare payment notification object here when one lapses unpaid. Verification, the address check and the acknowledgement are exactly those of the "new payment" route.

Takes a JSON request body.

  • 200
  • 403
POST /v1/billing/webhooks/everypay/notification-paid receivePaymentWebhookNotificationPaid
  • no key needed

The payment processor's "notification paid" webhook.

The processor posts the bare payment notification object here when one is paid; its payment field names the payment as a token string. Verification, the address check and the acknowledgement are exactly those of the "new payment" route.

Takes a JSON request body.

  • 200
  • 403
POST /v1/billing/webhooks/everypay/refund receivePaymentWebhookRefund
  • no key needed

The payment processor's "refund" webhook.

The processor posts here when a payment on the billing merchant is refunded. Its body is undocumented, so it is read as a trigger only: the worker re-reads the payment's refunds from the processor before it records anything. Verification, the address check and the acknowledgement are exactly those of the "new payment" route.

Takes a JSON request body.

  • 200
  • 403
GET /v1/online-payment/status readOnlinePaymentStatus
  • order:read

Whether callers are offered online payment now, and what is missing.

For the new-agent wizard's payment step and the voice agent's Payments setting. mode is the account's online-payment mode, which only WeTalk sets (off until then). offered_to_callers is whether a phone caller in a real conversation would be offered online payment now; offered_in_web_chat the same for a website widget. missing lists, in the order someone has to act, what stands in the way: mode_off (WeTalk has not enabled it), merchant_binding (WeTalk's payment merchant is not yet in the mode this account needs), destination and destination_unverified (a payout destination, verified by WeTalk, is required for pilot and live; a sandbox account needs none), general_live (real-money payments are not yet open to every account) and sms_unavailable (no SMS sender, so phone callers cannot get a link). Whether the shop accepts online payment and whether a language has the payment words are the voice agent's own settings and are not judged here.

  • 200
  • 401
  • 403
  • 429
GET /v1/payouts readPayoutStatement
  • order:read

The payable statement for one book and one period.

What WeTalk owes the shop for its paid online orders, for the live book (or, marked "Sandbox" in the console, the sandbox book, which is never paid out). The period is whole days in Europe/Athens, the time zone every payout cut-off uses.

balance is the book now: held_minor is still inside the holding period (each held credit is listed with its release_at), available_minor is what a payout may take. On the live book holds says what holds the next payout and why, and next_payout is the next cut-off and the amount expected at it (0 while anything holds it). entries is the period's ledger in order; an order line reads gross · WeTalk fee absorbed · commission 0 · net = gross. opening_balance_minor + entry_total_minor = closing_balance_minor, and the closing balance is the book's balance at the end of the period. More than 5000 entries in the period is 422 export_too_large; choose a shorter period.

Owners, Admins and Viewers read it; Members do not.

Parameters
Name In Required Description
from query yes The first day of the period, YYYY-MM-DD, in Europe/Athens.
to query yes The last day of the period (inclusive), YYYY-MM-DD; at most 366 days after from.
provider_mode query yes live — the book WeTalk pays out; sandbox — the test merchant's book, never paid out.
  • 200
  • 400
  • 401
  • 403
  • 422
  • 429
GET /v1/payouts/destination readPayoutDestination
  • credit:spend

Where payouts go, masked.

null until the Owner adds one. The IBAN is shown as its last four characters and the tax identifier as its last three; the full values are never returned. verified turns true once WeTalk has checked the destination; a changed destination is unverified again, and payouts wait until hold_until. Owners and Admins, signed in; never an API key.

  • 200
  • 401
  • 403
  • 429
PUT /v1/payouts/destination setPayoutDestination
  • credit:spend

Change where payouts go (the Owner, with a fresh step-up).

Owner only, signed in, and within a few minutes of re-entering a password, an authenticator code or a passkey (POST /v1/session/step-up): otherwise 401 step_up_required. Refused 403 step_up_unavailable while WeTalk support is acting as a member, and 403 for any other role or an API key. The IBAN must belong to a SEPA country and pass its check digits; a BIC is required only outside the EEA; a Greek tax identifier is the 9-digit ΑΦΜ. An invalid field is 422 payout_destination_invalid naming the field — the value is never echoed.

The IBAN is kept in WeTalk's payout key store, never in the database: the answer carries its last four characters. The new destination is unverified until WeTalk checks it, and payouts wait for the destination-change hold (hold_until). Audited as payout_destination.changed.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 422
  • 429
  • 502
GET /v1/payouts/export.csv exportPayoutStatement
  • order:read
  • heavy

The period's payable entries as a spreadsheet.

The same entries as GET /v1/payouts, one row each, built when asked and never stored: UTF-8 with a byte-order mark, CRLF records, amounts as signed decimals in euros. A text field beginning =, +, - or @ is prefixed with a single quote.

Parameters
Name In Required Description
from query yes The first day of the period, YYYY-MM-DD, in Europe/Athens.
to query yes The last day of the period (inclusive), YYYY-MM-DD; at most 366 days after from.
provider_mode query yes live — the book WeTalk pays out; sandbox — the test merchant's book, never paid out.
  • 200
  • 400
  • 401
  • 403
  • 422
  • 429
GET /v1/quota-requests listQuotaRequests
  • agent:read

This workspace's requests for more concurrent conversations, newest first.

  • 200
  • 401
  • 403
  • 429
POST /v1/quota-requests requestConcurrencyQuota
  • agent:write

Ask WeTalk for more concurrent conversations.

Records a quota_request (state: pending) and an audit entry (billing.quota_requested) in one db_transaction. An operator reads pending requests from the staff API and raises the account's ceiling; nothing changes until they do.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 429

Workspaces

GET /v1/accounts/{account_id}/workspace-creation-policy getWorkspaceCreationPolicy
  • agent:read

Who may create workspaces in this organization.

Parameters
Name In Required Description
account_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
PUT /v1/accounts/{account_id}/workspace-creation-policy putWorkspaceCreationPolicy
  • signed-in person only

Change who may create workspaces.

account.workspace.manage (Owner, Admin); while it is the Owner only, only the Owner may change it (403 workspace_creation_forbidden). A WeTalk operator acting as a member is refused. If-Match names the organization's revision (409 revision_stale when it moved).

Parameters
Name In Required Description
account_id path yes
If-Match header yes "<revision>" — the revision the change was based on (409 revision_stale when it moved).
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/accounts/{account_id}/workspace-lifecycle listWorkspaceLifecycle
  • signed-in person only

Every workspace of the organization with its lifecycle state.

account.workspace_metadata.read. Active and draining workspaces first, archived last, each with the archive in flight (open_operation_id) — the Archive and close page.

Parameters
Name In Required Description
account_id path yes
  • 200
  • 401
  • 403
  • 404
POST /v1/accounts/{account_id}/workspaces createAccountWorkspace
  • signed-in person only

Create a workspace in this organization and make the caller its Owner.

plan-014 B3 (FR12-WSP-01). A name, an optional stable slug (never _account; one this organization has used is 409 slug_taken), a time zone and how it is paid for: the shared balance, or a balance of its own starting at €0.00 — which needs account.finance.manage (Owner, Admin, Finance). The workspace, its founding Owner membership and its funding (billing_assignment) are one unit of work.

"Who may create workspaces" decides who may: the Owner and Admins, or the Owner only — 403 workspace_creation_forbidden for an Admin under the second. The organization's workspace entitlement is asked first (409 resource_limit_reached; pay-as-you-go has no limit). An API key cannot call this.

plan-015 WSK (G25): an optional kind — standard (when absent) or test, a label that bills like any workspace.

Parameters
Name In Required Description
account_id path yes The organization — the request's own; another one is 404 not_found.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/audit-entries listAuditEntries
  • agent:read

The workspace's audit log, newest first.

Entries from the last retention_month calendar months (24), newest first, optionally filtered. matched_count counts the filtered set, not the page, up to 10 000 (matched_count_capped says when there are more). Entries a WeTalk operator made — a recording played, a transcript read, time spent acting as a member — carry origin: wetalk_support, name the operator as the entry recorded it, and name the member acted as in effective_actor. The staff-side log is never read.

Parameters
Name In Required Description
event_class query no One of the five classes. Leave it out for everything.
actor query no A person's id — the entries they made, and those made AS them (an operator acting as them, their key).
action query no One action code, exactly, e.g. account.sign_in_policy_changed.
result query no What came of the action.
from query no The earliest instant, inclusive (ISO-8601 with a zone). The retention window applies regardless.
to query no The latest instant, exclusive (ISO-8601 with a zone); after from.
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/audit-entries/exports exportAuditEntries
  • agent:read
  • heavy

Export the audit log as a CSV.

Writes every entry matching the filters inside the retention window to a CSV (UTF-8 with a byte-order mark, CRLF records, a field beginning =, +, - or @ prefixed with ') and answers a short-lived download link. More than WETALK_API_EXPORT_MAX_ROW entries is 422 export_too_large with the count; the file is never truncated. The CSV carries the list's facts — the real and effective actor, the result, the target scope, the reason and the safe before/after values — never anything else of an entry's detail, and names the operator on every entry a WeTalk operator made. Exporting is itself audited (audit_entry.exported): that entry is written BEFORE the rows are read, in the same database transaction, and is not in the file.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 422
  • 429
GET /v1/workspaces listWorkspaces
  • agent:read

The live workspaces the caller may see.

Each row carries the two figures the cards render — how many people are in it, and what it has spent this month — computed against the same snapshot as the row itself, so a card never shows a name from one instant and a figure from another. Archived workspaces are absent; deleted_at is what archives one.

plan-014 B3: the organization's Owner, Admin, Finance and Viewer see every workspace (account.workspace_metadata.read), with or without a workspace open; anyone else sees the workspaces where they hold a role — a sibling never appears, not even as a count. Each row names its funding, its revision and the caller's own role. q searches the name (case-insensitive); the list is keyset-paged, oldest first.

plan-015 BAL (G24): a row funded by a balance of its own carries dedicated_balance (its amount) only for a caller who may read that balance — the organization's Owner, Admin and Finance, or a workspace person with a delegation of that balance. For anyone else the field is absent.

Parameters
Name In Required Description
q query no A part of the workspace's name, case-insensitive (at most 120 characters).
cursor query no The previous page's next_cursor.
limit query no Rows per page, 1 to 100; a page holds 100 when absent.
  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/workspaces createWorkspace
  • signed-in person only

Create a workspace and make the caller its Owner (the pre-plan-014 path).

Kept for one release beside POST /v1/accounts/{account_id}/workspaces, with the same rules; a body without funding means the shared balance, as it always has.

The workspace and the founding membership are one unit of work. A workspace with no membership is unreachable by the person who just made it, so the two writes are one transaction rather than two requests with a window between them.

The new workspace points at the account's default billing account — the design's "share the account balance". A separate balance is a different billing account and is not created here: a workspace pointing at one with no payment method would look funded and refuse every call.

An API key cannot call this. The founding membership needs a person to own it, and a key is not one.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 409
  • 429
DELETE /v1/workspaces/{workspace_id} deleteWorkspace
  • agent:write

Archive a workspace. The workspace's Owner, or the organization's Owner or Admin.

Who. The workspace's own Owner (workspace.lifecycle), or the organization's Owner or Admin — member of the workspace or not, with a workspace open or the organization alone (ADR 0041, 2026-10-09). Anyone else is refused against the target workspace.

Archive, not delete. Conversations, ledger entries and audit entries all point at this row; removing it would either cascade a customer's billing history away or fail on a foreign key. deleted_at with state = 'archived' removes the workspace from every live listing — which stops every agent in it answering — and leaves the evidence of what it did intact.

The account's last live workspace cannot be archived: an account with none has nowhere to create an agent, bind a number or hold a conversation.

Only an empty workspace is archived (409 otherwise): no VoiceAgents, and nobody in it but the person archiving it. The workspace the caller is signed in to is refused (switch first). Archiving withdraws its open invitations and ends the remaining memberships.

Parameters
Name In Required Description
workspace_id path yes
  • 204
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/workspaces/{workspace_id} getWorkspace
  • agent:read

One workspace, with the VoiceAgents in it.

Parameters
Name In Required Description
workspace_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
PATCH /v1/workspaces/{workspace_id} updateWorkspace
  • signed-in person only

Rename a workspace or move it to another time zone.

The slug is not changed by a rename. It appears in storage paths, so changing it would orphan every blob already written under the old one.

name is trimmed and at most 60 characters (as on create), and no other live workspace of the account may already have it (compared without regard to case): 409 conflict otherwise. A changed name writes workspace.renamed, a changed zone workspace.time_zone_changed, each with the value before and after.

Session only. An API key is 403 permission_denied: the audit entry names the person who made the change (customer-portal review item 182). A draining or archived workspace keeps its name (422).

plan-015 WSK (G25, ADR 0050): an optional kind (standard | test) relabels the workspace — the organization's Owner or Admin only, as for a rename — and a changed kind writes workspace.kind_changed with { old, new }. Absent, the kind is kept.

Parameters
Name In Required Description
workspace_id path yes
If-Match header no "<revision>" (plan-014 B3): the change applies only while the workspace is still at it (409 revision_stale otherwise). Absent, the change is unconditional, as before.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/workspaces/{workspace_id}/access-policy getWorkspaceAccessPolicy
  • agent:read

How people join this workspace.

Parameters
Name In Required Description
workspace_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
PUT /v1/workspaces/{workspace_id}/access-policy putWorkspaceAccessPolicy
  • signed-in person only

Change how people join this workspace.

The workspace's Owner or Admin, or the organization's. verified_domain names one of the organization's VERIFIED domains and the role people join in — never Owner or Admin (422 validation_failed). A draining or archived workspace keeps its rules (422). A WeTalk operator acting as a member is refused.

Parameters
Name In Required Description
workspace_id path yes
If-Match header yes "<revision>" — the revision the change was based on (409 revision_stale when it moved).
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
POST /v1/workspaces/{workspace_id}/archive archiveWorkspaceWithPreflight
  • signed-in person only

Archive the workspace when its conversations finish.

The workspace becomes draining: new conversations and campaign attempts are refused (workspace_draining), campaigns pause and will not restart on restore, numbers and widgets disconnect (the numbers stay the organization's), API keys are revoked for good, pending invitations are withdrawn. Conversations in progress finish; the workspace archives when the last one ends — at once when none is in progress. 202 with the operation (200 for the same Idempotency-Key again). The organization's only active workspace is refused 409 operation_conflict; one already draining or archived 422 workspace_draining / workspace_archived. in_progress_action is finish (ending conversations at once is not available).

Parameters
Name In Required Description
workspace_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 200
  • 202
  • 400
  • 401
  • 403
  • 404
  • 409
GET /v1/workspaces/{workspace_id}/archive-preflight getWorkspaceArchivePreflight
  • signed-in person only

What archiving this workspace would do, item by item.

workspace.lifecycle in the workspace or account.workspace.manage in the organization. One row each for conversations in progress, running campaigns, numbers and web chat, API keys, a dedicated balance and recurring add-ons: now (what stands) and on_archive (what archiving does to it), in the console's words, with the count or the amount (amount_minor + currency). open_operation is the archive in flight, if any.

Parameters
Name In Required Description
workspace_id path yes
  • 200
  • 401
  • 403
  • 404
POST /v1/workspaces/{workspace_id}/invitations inviteWorkspaceMember
  • agent:write

Invite somebody to a workspace.

The token is returned exactly once. invitation.token_hash is all the database holds, so there is no second chance to show it and no route that could recover it — the same contract as an API key, for the same reason. Delivering the link is the caller's act; the console renders a copy-once dialog.

An invitation does not create a person. Accepting one requires an authenticated session, and the person either already exists or signs up first: an invitation that could mint a person row would be a way to create accounts for addresses nobody controls.

An Admin may not invite an Owner. Any of the seven workspace roles may be offered (plan-014 B3). A draining or archived workspace takes no invitation (422 workspace_draining / workspace_archived), and the seat entitlement is asked (409 seat_limit_reached; pay-as-you-go has no seat limit).

An address that already belongs to a member of the workspace is refused with 409, naming their role: change it in the people list instead.

Parameters
Name In Required Description
workspace_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
DELETE /v1/workspaces/{workspace_id}/invitations/{invitation_id} revokeWorkspaceInvitation
  • agent:write

Revoke an invitation that has not been accepted.

Who. The workspace's Owners and Admins (membership.invite), and the organization's Owner and Admin on every workspace of the organization, member there or not, who hold membership.remove there without being able to invite (ADR 0052, 2026-10-10). The audit entry carries authority: "organization" when that is the authority used.

Parameters
Name In Required Description
workspace_id path yes
invitation_id path yes
  • 204
  • 401
  • 403
  • 404
  • 429
GET /v1/workspaces/{workspace_id}/lifecycle-operations/{workspace_lifecycle_operation_id} getWorkspaceLifecycleOperation
  • signed-in person only

An archive's or a restore's durable status.

Parameters
Name In Required Description
workspace_id path yes
workspace_lifecycle_operation_id path yes
  • 200
  • 401
  • 403
  • 404
POST /v1/workspaces/{workspace_id}/lifecycle-operations/{workspace_lifecycle_operation_id}/cancel cancelWorkspaceArchive
  • signed-in person only

Cancel an archive still waiting for its last conversation.

The workspace is active again and the numbers and widgets the archive disconnected are connected again; revoked API keys stay revoked and paused campaigns stay paused (disclosed). 409 operation_state_conflict once the archive completed.

Parameters
Name In Required Description
workspace_id path yes
workspace_lifecycle_operation_id path yes
  • 200
  • 401
  • 403
  • 404
  • 409
GET /v1/workspaces/{workspace_id}/members listWorkspaceMembers
  • agent:read

The people in a workspace, and its invitations — pending, expired and revoked.

One response rather than two, because the screen renders them as one list with a Status column — and because a member and an invitation to the same address arriving in two separately paged responses is how a duplicate row reaches a screen.

Who. The workspace's Owners, Admins, Members and Viewers (membership.read), and the organization's Owner and Admin on every workspace of the organization, member there or not, with a workspace open or the organization alone (ADR 0041, 2026-10-09).

display_name and email come from the identity role, which is a second read: membership is tenant-scoped and person is not, so there is no role that can join them. They are null when the person row no longer exists, because inventing a name for a dangling membership would hide a real state.

Parameters
Name In Required Description
workspace_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
DELETE /v1/workspaces/{workspace_id}/members/{membership_id} removeWorkspaceMember
  • agent:write

Remove someone from a workspace.

removed_at is set; the row stays. Audit entries name the membership, and deleting it would erase the meaning of entries that still reference it. The last Owner cannot be removed, for the same reason they cannot be demoted, and only an Owner may remove an Owner (403 member_role_insufficient for an Admin or an API key; review #132).

Who. The workspace's Owners and Admins (membership.remove), and the organization's Owner and Admin on every workspace of the organization, member there or not (ADR 0052, 2026-10-10). By the organization's authority they may remove an Owner, the last one included; the organization's Admin never removes the organization's Owner. The audit entry carries authority: "organization".

Parameters
Name In Required Description
workspace_id path yes
membership_id path yes
  • 204
  • 401
  • 403
  • 404
  • 409
  • 429
PATCH /v1/workspaces/{workspace_id}/members/{membership_id} setWorkspaceMemberRole
  • agent:write

Change a member's role.

The last Owner cannot be demoted. An account with no Owner has nobody who can pay the bill, change the tier or delete it, and recovering from that ends with support writing SQL.

Only an Owner may promote anyone to Owner, or change an Owner's role (403 member_role_insufficient otherwise; customer-portal review 2026-09-25 #132). Otherwise a role change is a privilege-escalation primitive: an Admin could demote every Owner but one. An API key is refused both acts, since a key holds no role.

Who. The workspace's Owners and Admins (membership.role_change), and the organization's Owner and Admin on every workspace of the organization, member there or not, with a workspace open or the organization alone (ADR 0052, 2026-10-10). By the organization's authority they act with a workspace Owner's limits and may demote the last Owner (the organization still oversees the workspace); nobody changes their own role that way, and the organization's Admin never changes the organization's Owner (403 member_role_insufficient). The audit entry carries authority: "organization".

Parameters
Name In Required Description
workspace_id path yes
membership_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/workspaces/{workspace_id}/restore restoreWorkspace
  • signed-in person only

Restore an archived workspace.

Rechecks, in one db_transaction: "Who may create workspaces" (403 workspace_creation_forbidden), the funding (a retired balance 409 operation_conflict; one suspended for nonpayment 422 account_suspended_nonpayment) and the organization's workspace limit (409 resource_limit_reached). Campaigns, API keys and numbers stay as the archive left them: start campaigns and reconnect a number when ready.

Parameters
Name In Required Description
workspace_id path yes
  • 200
  • 401
  • 403
  • 404
  • 409

Order book

GET /v1/orders listCustomerOrders
  • order:read

One page of the order book, newest first.

Cursor-paginated, never offset. shop names the counter; with one shop in the workspace it may be omitted.

Parameters
Name In Required Description
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
shop query no
status query no
awaits_payment query no Filters on the order's payment, on the server (amended 2026-09-27). true — only received orders whose online payment has not arrived (payment_state awaiting_online_payment or online_payment_not_completed): the "Awaiting payment" chip. false — every other order; with status=received, the kitchen's "New". Left out, no payment filter. Combined with status; true with any status but received is an empty page.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/orders createCustomerOrder
  • order:write

Record an order.

The delivery fee is the shop's and is decided on what is left after the discount, so a coupon can push an order back under a free-delivery threshold. The caller's total is not trusted to have applied it.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
shop query no

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 409
  • 429
GET /v1/orders/{customer_order_reference} getCustomerOrder
  • order:read

One order, with the conversation it came from.

Parameters
Name In Required Description
customer_order_reference path yes The human-facing code the VoiceAgent speaks — PZ- plus four uppercase hex characters — and not the row's UUID. It is unique per shop, which is why every route that takes one resolves a shop first.
shop query no
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
PATCH /v1/orders/{customer_order_reference} patchCustomerOrder
  • order:write

Advance the status, cancel with a reason, undo the last move, or mark it paid.

The move is a compare-and-set against the status the order is currently in, so two people advancing the same order answer one success and one 409 rather than appending two transitions out of a status the order has already left.

Parameters
Name In Required Description
customer_order_reference path yes The human-facing code the VoiceAgent speaks — PZ- plus four uppercase hex characters — and not the row's UUID. It is unique per shop, which is why every route that takes one resolves a shop first.
shop query no

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
PUT /v1/orders/{customer_order_reference}/payment-method setCustomerOrderPaymentMethod
  • order:write

Switch the order to pay on hand-over.

Only a method the voice agent that took the order accepts. An open payment link is cancelled. Refused on a paid order.

Parameters
Name In Required Description
customer_order_reference path yes The human-facing code the VoiceAgent speaks — PZ- plus four uppercase hex characters — and not the row's UUID. It is unique per shop, which is why every route that takes one resolves a shop first.
shop query no

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/orders/{customer_order_reference}/refunds refundCustomerOrder
  • order:write

Refund an online payment, in full or in part.

Owner and Admin only. amount_minor: null refunds everything still refundable. The refund is recorded at once and carried out by the worker; its state moves on the Order screen as the processor answers. A test payment is refunded at once, and no money moves.

Parameters
Name In Required Description
customer_order_reference path yes The human-facing code the VoiceAgent speaks — PZ- plus four uppercase hex characters — and not the row's UUID. It is unique per shop, which is why every route that takes one resolves a shop first.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
shop query no

Takes a JSON request body.

  • 202
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
POST /v1/orders/{customer_order_reference}/ticket printOrderTicket
  • order:write

Record a kitchen-ticket print, and return what to print.

Parameters
Name In Required Description
customer_order_reference path yes The human-facing code the VoiceAgent speaks — PZ- plus four uppercase hex characters — and not the row's UUID. It is unique per shop, which is why every route that takes one resolves a shop first.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
shop query no
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/orders/export.csv exportCustomerOrdersCsv
  • order:read
  • heavy

The order book as a spreadsheet.

One row per order, not per line — a spreadsheet of orders is what a shop owner reconciles against the till, and one row per line would make every total wrong by however many pizzas were on it.

Capped rather than paged: a CSV with a cursor is not a CSV. Narrow the window with since; a window holding more than the cap is refused with the number rather than silently truncated.

A field beginning =, +, - or @ is prefixed with a single quote. Excel, LibreOffice and Sheets all treat such a field as a formula, and the order book stores three free-text fields a caller can dictate.

plan-005 OP-35: four columns follow every existing one — payment_state, paid_amount, refunded_amount (major units) and payment_is_test.

Parameters
Name In Required Description
shop query no
since query no RFC 3339. Defaults to 30 days ago.
status query no
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/orders/summary summariseCustomerOrders
  • order:read

The figures above the Orders table.

Parameters
Name In Required Description
shop query no
since query no
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429

Order book, ported surface

GET /v1/orders-api/api/coupons listPortedCoupons
  • order:read

Ported: every coupon this shop has.

  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/orders-api/api/coupons createPortedCoupon
  • order:write

Ported: create a coupon.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 409
  • 429
DELETE /v1/orders-api/api/coupons/{coupon_code} deletePortedCoupon
  • order:write

Ported: delete a coupon.

A real delete, as in the reference. Orders that already used it keep their coupon_code and their discount — that is what the caller was charged.

Parameters
Name In Required Description
coupon_code path yes Uppercased, with every non-[A-Z0-9] character stripped.
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/orders-api/api/coupons/{coupon_code} validatePortedCoupon
  • order:read

Ported: is this coupon usable right now?

The five-rung ladder, first failure wins: unknown or inactive → 404 unknown_code; expired → 422; below the minimum → 422 (skipped when subtotal is absent or zero, because a caller who has not ordered anything yet has not failed a minimum); the per-customer cap → 422 (skipped when no phone is supplied, because the cap cannot be checked against an unknown caller — it is re-checked at redemption).

Parameters
Name In Required Description
coupon_code path yes Uppercased, with every non-[A-Z0-9] character stripped.
subtotal query no Major units, pre-discount and pre-delivery-fee.
phone query no
  • 200
  • 401
  • 403
  • 404
  • 422
  • 429
PATCH /v1/orders-api/api/coupons/{coupon_code} patchPortedCoupon
  • order:write

Ported: change a coupon's five mutable fields.

Parameters
Name In Required Description
coupon_code path yes Uppercased, with every non-[A-Z0-9] character stripped.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/orders-api/api/customers listPortedShopCustomers
  • order:read

Ported: every recognised caller, most recent first.

  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/orders-api/api/customers/{phone} getPortedShopCustomer
  • order:read

Ported: caller recognition by phone number.

One of the two calls a VoiceAgent makes in parallel the moment a known number rings. It degrades rather than failing: the reference client answers null on any error and carries on taking the order, because an order-system outage must not block a phone call (design §9.7 item 1).

Parameters
Name In Required Description
phone path yes Normalised to digits, then a leading 0030, then a leading 30 in front of 69, are stripped. That normaliser IS the caller's identity — UNIQUE (account_id, shop_id, phone_digits) — so it is the store's and not one of the reference's other two.
  • 200
  • 401
  • 403
  • 404
  • 429
PUT /v1/orders-api/api/customers/{phone} putPortedShopCustomer
  • order:write

Ported: remember a caller and an address.

Parameters
Name In Required Description
phone path yes Normalised to digits, then a leading 0030, then a leading 30 in front of 69, are stripped. That normaliser IS the caller's identity — UNIQUE (account_id, shop_id, phone_digits) — so it is the store's and not one of the reference's other two.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/orders-api/api/customers/{phone}/orders listPortedShopCustomerOrders
  • order:read

Ported: what this caller ordered before.

Recent-order recall, and the read behind "where is my order?" — which the VoiceAgent answers from these rows without a model call. active=1 narrows to live orders, which is what that question needs.

Parameters
Name In Required Description
phone path yes Normalised to digits, then a leading 0030, then a leading 30 in front of 69, are stripped. That normaliser IS the caller's identity — UNIQUE (account_id, shop_id, phone_digits) — so it is the store's and not one of the reference's other two.
limit query no Default 5, capped at 20.
active query no The literal 1 excludes delivered and cancelled orders.
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/orders-api/api/orders listPortedCustomerOrders
  • order:read

Ported: every order, newest first.

Parameters
Name In Required Description
status query no
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/orders-api/api/orders createPortedCustomerOrder
  • order:write

Ported: record an order.

A duplicate id answers 409 with the existing order in the body, which the reference client reads as success — that is what makes a retried dispatch from a VoiceAgent mid-sentence safe.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 409
  • 429
DELETE /v1/orders-api/api/orders/{customer_order_reference} cancelPortedCustomerOrder
  • order:write

Ported: cancel. It never deletes a row.

Sets the status to cancelled and returns the order. One deliberate divergence: the reference forces cancelled from any status including delivered, because it checks legality only for {"status":"next"}. Here the state machine has the final word, so cancelling a delivered order answers the reference's own cannot move from delivered rather than silently un-delivering a paid order.

Parameters
Name In Required Description
customer_order_reference path yes The human-facing code the VoiceAgent speaks — PZ- plus four uppercase hex characters — and not the row's UUID. It is unique per shop, which is why every route that takes one resolves a shop first.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/orders-api/api/orders/{customer_order_reference} getPortedCustomerOrder
  • order:read

Ported: one order.

Parameters
Name In Required Description
customer_order_reference path yes The human-facing code the VoiceAgent speaks — PZ- plus four uppercase hex characters — and not the row's UUID. It is unique per shop, which is why every route that takes one resolves a shop first.
  • 200
  • 401
  • 403
  • 404
  • 429
PATCH /v1/orders-api/api/orders/{customer_order_reference} patchPortedCustomerOrder
  • order:write

Ported: advance the status, or mark it paid.

Parameters
Name In Required Description
customer_order_reference path yes The human-facing code the VoiceAgent speaks — PZ- plus four uppercase hex characters — and not the row's UUID. It is unique per shop, which is why every route that takes one resolves a shop first.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 429

Shop counter

GET /v1/shop/{counter_access_key}/board getShopBoard
  • no key needed

The counter board — every live order in one shop.

One page, no cursor: the board is the whole live state of one counter, and a kitchen that has to scroll for its next ticket has a worse screen than a paper spike.

The caller's phone number is not on a card. A tablet left on a counter is the one surface in the platform a stranger can read over your shoulder; a name and a street are what handing the bag over needs, and a phone number is not.

The response carries shop_id so the tablet can open GET /v1/shop/{shop_id}/order-stream and repaint on every change rather than polling. That id is the only identifier the tablet ever learns — the account id never crosses this boundary.

Parameters
Name In Required Description
counter_access_key path yes The capability. 32 bytes of randomness, base64url — 256 bits, in a URL a tablet is left open on. Scoped to one shop, revocable by rotation, and never exchanged for a session.
  • 200
  • 404
  • 429
PUT /v1/shop/{counter_access_key}/board/wait-time setShopBoardWaitTime
  • no key needed

The counter changes the wait times — in a rush, from the shop screen.

The counter's capability, like the board: whoever holds the shop-screen link may say how long orders take now. Nothing else about the shop can be changed from the tablet.

Parameters
Name In Required Description
counter_access_key path yes

Takes a JSON request body.

  • 200
  • 400
  • 404
  • 429
POST /v1/shop/{counter_access_key}/order/{customer_order_reference}/advance advanceShopBoardCustomerOrder
  • no key needed

The single tap — move one order one step along the flow.

No body. The only legal move is the flow's next step, so there is nothing to say; a body carrying a target status would be a capability that can put an order into any state, and this one can only push it forward one notch.

Cancelling is deliberately absent. A counter that can cancel a paid order, with no login and no name against the act, is not a capability to hand to a tablet — the console can cancel, and the console knows who did it.

The move is a compare-and-set, so two tablets by one oven tapping the same card produce one success and one 409 rather than two transitions out of one status.

Parameters
Name In Required Description
counter_access_key path yes The capability. 32 bytes of randomness, base64url — 256 bits, in a URL a tablet is left open on. Scoped to one shop, revocable by rotation, and never exchanged for a session.
customer_order_reference path yes The human-facing code the VoiceAgent speaks — PZ- plus four uppercase hex characters — and not the row's UUID. It is unique per shop, which is why every route that takes one resolves a shop first.
  • 200
  • 400
  • 404
  • 409
  • 429
POST /v1/shop/{shop_id}/counter-access-key/rotate rotateShopCounterAccessKey
  • order:write

Revoke the counter link and issue a new one.

Owner or admin, from the console — not from the tablet. The old link stops working the moment this commits, which is what "revocable" in the schema's comment means, so the tablet on the counter has to be re-opened with the new one.

The new key is returned once. It is a URL rather than a secret anybody can hash, but a console that redisplayed it on every page load would leave it in every screenshot of the Orders tab.

Parameters
Name In Required Description
shop_id path yes
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/shop/{shop_id}/order-stream streamShopCustomerOrderBoard
  • no key needed

The counter board's live stream — one notice per change, never an order.

A server-sent event stream the counter tablet holds open so every tablet by one oven repaints within a frame. Authorised by the shop's counter link, not by a login: the key must resolve and must belong to shop_id. It sits under /v1/shop/**, so the public site's cross-origin grant applies to it unchanged.

The key is a query parameter because an EventSource cannot send a header. The API never logs the query string, and the Shop view carries the key only in its URL fragment with referrer: no-referrer.

Events are notices, not data. customer_order and snapshot, each with data: {} and a UUID v7 id a reconnect may present as Last-Event-ID; nothing else on the shop's channel is relayed. The tablet re-reads getShopBoard on each one — the only read surface the counter link has — so no frame ever carries a caller's details. A comment frame every 15 seconds keeps idle proxies from closing the connection.

Revocation. A rotated key is refused with 401 on the next connect, and an open stream is closed after five minutes so the browser reconnects and the key is checked again.

Parameters
Name In Required Description
shop_id path yes The shop_id the board read returned.
counter_access_key query yes The same capability as the board's path segment, carried in the query because an EventSource cannot send a header. Never logged by the API.
Last-Event-ID header no
  • 200
  • 401
GET /v1/shop/{shop_id}/wait-time getShopWaitTime
  • order:write

The shop's delivery and pickup wait times.

Owner or admin, from the console.

Parameters
Name In Required Description
shop_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
PUT /v1/shop/{shop_id}/wait-time setShopWaitTime
  • order:write

Set the shop's delivery and pickup wait times.

Owner or admin, from the console. The voice agent tells callers the new time from its next conversation; orders already placed keep theirs.

Parameters
Name In Required Description
shop_id path yes

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 429

Streams

Server-sent event channels.

GET /v1/stream/agent/{voice_agent_id}/live streamVoiceAgentLive
  • conversation:read

Live conversations for one VoiceAgent.

Events: conversation.started, conversation.updated, conversation.ended, channel_unit.changed.

Parameters
Name In Required Description
voice_agent_id path yes
Last-Event-ID header no
  • 200
  • 401
  • 403
  • 404
GET /v1/stream/build/{build_job_id} streamBuildJob
  • agent:read

Build progress for one build job.

Events: build.step, build.progress, build.check, build.succeeded, build.failed.

build.check announces a completed document check using only its round and outcome. Read GET /v1/builds/{build_job_id} for the current persisted state.

Each frame carries an id that is a UUID v7 cursor; a reconnect may present it as Last-Event-ID. A comment frame every 15 seconds keeps idle proxies from closing the connection. If the server cannot keep up with a slow consumer it emits event: resync and closes rather than dropping events, because a Live view that has silently missed events is a Live view that lies.

Parameters
Name In Required Description
build_job_id path yes
Last-Event-ID header no
  • 200
  • 401
  • 403
  • 404
GET /v1/stream/conversation/{conversation_id}/transcript streamConversationTranscript
  • conversation:read

The live transcript of one conversation.

Events: turn.partial, turn.final, conversation.event, customer_order_payment.

customer_order_payment (plan-005 OP-37, design §18.14.3) is the browser test panel's payment element: a payment link's state for an order placed in this conversation (its amount, expiry, link and whether it is a test), sent again whenever that state changes. The Orders screens and the shop board keep their own customer_order event.

Parameters
Name In Required Description
conversation_id path yes
Last-Event-ID header no
  • 200
  • 401
  • 403
  • 404
GET /v1/stream/shop/{shop_id}/order streamShopCustomerOrder
  • order:read

A shop's order events, for a signed-in reader with order:read (the console).

Events: snapshot, customer_order, shop_customer, coupon. Signed-in readers only (order:read). The public counter tablet — the Shop view — does not use this stream: its live board is streamShopCustomerOrderBoard (GET /v1/shop/{shop_id}/order-stream?counter_access_key=…, plan-003 RC-72), authorised by the shop's counter access key.

Parameters
Name In Required Description
shop_id path yes
Last-Event-ID header no
  • 200
  • 401
  • 403
  • 404
GET /v1/stream/workspace/{workspace_id}/activity streamWorkspaceActivity
  • conversation:read

Everything happening in one workspace.

Events: conversation.started, conversation_flag.raised, notification.created, billing.alert.

Parameters
Name In Required Description
workspace_id path yes
Last-Event-ID header no
  • 200
  • 401
  • 403
  • 404

account

GET /v1/accounts listAccounts
  • no key needed

The organizations the signed-in person may enter.

Plan-014 B1 (design §27.9.2). Every organization the person holds an organization role in, or a workspace membership in, with that role and the workspaces they may open (enterable is false for a workspace that is not active). Read through account_access_for_person (migration 0078); another person's organizations are never listed. An unscoped or account-only auth_session is the normal caller.

  • 200
  • 401
  • 429
GET /v1/accounts/{account_id} getAccountProfile
  • signed-in person only

The organization's profile.

Plan-014 B2. Name, state, country, default language, the billing timezone in force and any change waiting for its billing month, and the revision the next change must name (ETag carries it too) — with what the Overview shows beside it: every workspace's metadata (account.workspace_metadata.read; never its content), how many people have access and how many hold an organization role, and the number of balances (null unless the caller holds account.finance.read). Capability account.profile.read — every organization role. Refusals: 403 capability_missing (a workspace member with no organization role), 404 not_found, the 401s, 429 rate_limited.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
  • 200
  • 401
  • 403
  • 404
  • 429
PATCH /v1/accounts/{account_id} updateAccountProfile
  • signed-in person only

Rename the organization or change its default language.

Plan-014 B2. If-Match: "<revision>" is required (409 revision_stale when the organization changed since it was read). Capability account.profile.write — Owner, Admin. The billing timezone is changed by scheduleAccountBillingTimezone, never here. Audited (account.profile_changed).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
If-Match header yes "<revision>" — the revision the change was based on (409 revision_stale when it moved).

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/accounts/{account_id}/allowed-networks createAccountAllowedNetwork
  • signed-in person only

Allow console sign-in from a network range.

Plan-014 C2 (FR12-SEC-04/06). Console sign-in only: API keys carry their own network rules, and calls and webhooks are unaffected. A range written with host bits is normalised to its network. The change bumps the policy revision (every auth_session is judged again on its next request) and applies from the next request; one that would leave the address WeTalk sees for this browser outside every range is refused 409 sign_in_policy_lockout; an active duplicate is 409 conflict. Capability account.sign_in_policy.write with a fresh step-up. Audited (allowed_network.added).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
DELETE /v1/accounts/{account_id}/allowed-networks/{account_allowed_network_id} deleteAccountAllowedNetwork
  • signed-in person only

Remove an allowed network range.

Plan-014 C2. Bumps the policy revision. Refused 409 sign_in_policy_lockout when the ranges left would not contain the address WeTalk sees for this browser. Capability account.sign_in_policy.write with a fresh step-up. Audited (allowed_network.removed).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
account_allowed_network_id path yes The range.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/accounts/{account_id}/audit-entries listAccountAuditEntries
  • signed-in person only

The organization's audit log, newest first.

Plan-014 C4b (FR12-SEC-08). Every entry of the organization — each workspace's, the organization's own and its balances' — from the last retention_month calendar months, newest first, with the workspace log's filters plus scope and workspace_id. account.audit.read (Owner, Admin) reads every entry; account.audit.financial_read alone (Finance) reads only the entries the catalogue marks as financial, in the page, the count and the export alike, and the answer says so (finance_only). Served with the organization alone open. Never an API key.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
event_class query no One of the five classes. Leave it out for everything.
actor query no A person's id — the entries they made, and those made AS them (an operator acting as them, their key).
action query no One action code, exactly, e.g. account.sign_in_policy_changed.
result query no What came of the action.
from query no The earliest instant, inclusive (ISO-8601 with a zone). The retention window applies regardless.
to query no The latest instant, exclusive (ISO-8601 with a zone); after from.
scope query no The entry's target scope (organization log only).
workspace_id query no One workspace's entries (organization log only).
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/accounts/{account_id}/audit-entries/exports exportAccountAuditEntries
  • signed-in person only
  • heavy

Export the organization's audit log as a CSV.

Plan-014 C4b. The organization log's export, bounded and audited exactly as the workspace log's: the count first (422 export_too_large over WETALK_API_EXPORT_MAX_ROW, naming it), then audit_entry.exported written before the rows are read, then the file and a short-lived link. Finance exports the financial entries only. Until the organization has a file location of its own the file is filed under the workspace the person has open, so a session with the organization alone open is refused 409 scope_required.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
POST /v1/accounts/{account_id}/billing-timezone scheduleAccountBillingTimezone
  • signed-in person only

Change the billing timezone from the next billing month.

Plan-014 B2 (design §27.8.5; owner input 12). The change applies from the start of the next billing month in the zone in force now (effective_from) and never rewrites a period already open; naming the zone in force withdraws a pending change. If-Match: "<revision>" is required. Capability account.finance.manage — Owner, Admin, Finance. Refusals: 400 time_zone_invalid, 409 revision_stale, 403 capability_missing. Audited (account.billing_timezone_scheduled).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
If-Match header yes "<revision>" — the revision the change was based on (409 revision_stale when it moved).

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/accounts/{account_id}/closure-preflight getAccountClosurePreflight
  • signed-in person only

What blocks closing the organization, what is refundable, and what is kept.

The Owner's read (account.close; a stale step-up is enough to read). blocker[] lists each thing that stops a closure now — a conversation in progress, an unpaid statement, a balance below zero, a payment still processing, a payout obligation — each with what to do about it. refundable[] is each balance's unused PURCHASED credit (trial and promotional credit are never refunded, and are used first). retention states the owner's figures: content is deleted content_purge_after_day days after access ends (the undo grace), financial records are kept financial_keep_year calendar years after closure. latest_closure is the most recent closure, whatever its state.

Parameters
Name In Required Description
account_id path yes
  • 200
  • 401
  • 403
  • 404
POST /v1/accounts/{account_id}/closures startAccountClosure
  • signed-in person only

Start closing the organization (asynchronous).

The Owner only (account.close), with a fresh step-up (step_up_required otherwise) and never a WeTalk operator acting as a member. Blockers are stored and answered 409 closure_blocked with error.detail.blocker[]. Otherwise the organization becomes closing — no new conversations or purchases — and the closure walks drain → refunds decided → plan and postpaid terms ended → access revoked → the grace → content purged → closed; follow status_url. refund_choice is refund (one refund request per balance, each approved by a finance operator), waive (with refund_waiver, the owner's own words) or none (when nothing purchased is unused). Closing this organization never removes the person's memberships in others.

Parameters
Name In Required Description
account_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 202
  • 400
  • 401
  • 403
  • 404
  • 409
GET /v1/accounts/{account_id}/closures/{account_closure_operation_id} getAccountClosure
  • signed-in person only

A closure's durable status.

Parameters
Name In Required Description
account_id path yes
account_closure_operation_id path yes
  • 200
  • 401
  • 403
  • 404
POST /v1/accounts/{account_id}/closures/{account_closure_operation_id}/undo undoAccountClosure
  • no key needed

Undo a closure during its grace (the former Owner).

An identity entry point: after access ended the former Owner holds no membership, so the auth_session cookie is presented to this route itself (with X-WeTalk-Csrf). Only the person who asked for the closure may undo it, only during the grace (409 operation_state_conflict otherwise); anyone else's closure is 404. The organization returns to its prior state with the former Owner as its Owner and as the Owner of the workspaces the closure archived; nobody else's access returns, and keys, numbers and campaigns stay off.

Parameters
Name In Required Description
account_id path yes
account_closure_operation_id path yes
  • 200
  • 401
  • 403
  • 404
  • 409
POST /v1/accounts/{account_id}/closures/{account_closure_operation_id}/withdraw withdrawAccountClosure
  • signed-in person only

Withdraw a closure before access is being revoked.

Allowed while the closure is draining, refunding or ending the plan (409 operation_state_conflict after). The organization returns to the state it had; refund requests not yet decided are cancelled. Campaigns and automatic top-ups stay off until someone starts them again.

Parameters
Name In Required Description
account_id path yes
account_closure_operation_id path yes
  • 200
  • 401
  • 403
  • 404
  • 409
GET /v1/accounts/{account_id}/invitations listAccountInvitations
  • signed-in person only

The organization's invitations, every state, newest first.

Plan-014 B2 (FR12-TEAM-05). Pending, expired, revoked, superseded and accepted invitations — the console shows them apart from the members. Capability account_membership.read.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/accounts/{account_id}/invitations createAccountInvitation
  • signed-in person only

Invite someone into the organization.

Plan-014 B2 (FR12-TEAM-01/02). Offers an organization role (Admin, Finance or Viewer — never Owner, 403 owner_change_forbidden), named workspace roles in live workspaces of this organization, or both; the offer is frozen — a workspace created later is never in it. Only an Owner of the organization may offer the workspace Owner role. The link lives for the configured lifetime (7 days). One pending invitation per address (409 conflict); someone who already holds an organization role is refused rather than invited twice. Capability account_membership.invite — Owner, Admin. delivery says whether the invitation was emailed or — while no email sender is bound — its invitation_path is returned ONCE for the inviter to pass on. Audited (account_invitation.sent).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/accounts/{account_id}/invitations/{account_invitation_id}/resend resendAccountInvitation
  • signed-in person only

Resend an invitation with a fresh link; the old link stops working.

Plan-014 B2 (FR12-TEAM-03). A pending or expired invitation is superseded and a new one with the same frozen offer is issued in the same database transaction. An accepted, revoked or superseded one is 409 conflict. Capability account_membership.invite. Audited (account_invitation.resent).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
account_invitation_id path yes The organization invitation.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/accounts/{account_id}/invitations/{account_invitation_id}/revoke revokeAccountInvitation
  • signed-in person only

Revoke a pending invitation.

Plan-014 B2 (FR12-TEAM-03). Only a pending invitation; anything else is 409 conflict. Capability account_membership.invite. Audited (account_invitation.revoked).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
account_invitation_id path yes The organization invitation.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/accounts/{account_id}/members listAccountMembers
  • signed-in person only

Everyone with access to the organization, with their roles.

Plan-014 B2 (FR12-TEAM-01). One row per person holding an organization role, workspace roles, or both: the organization role and its revision, each workspace role with the workspace's name, how they sign in (which kinds exist — never a credential) and when they were last seen here. Capability account_membership.read — Owner, Admin.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
  • 200
  • 401
  • 403
  • 404
  • 429
DELETE /v1/accounts/{account_id}/members/{account_membership_id} removeAccountMember
  • signed-in person only

Remove someone's organization role.

Plan-014 B2 (FR12-TEAM-04, FR12-AUTH-08). Ends the ORGANIZATION role — a state, never a deletion; the person and their workspace roles are untouched (the two memberships are independent). If-Match is required. Capability account_membership.remove: an Admin never removes an Owner (403 owner_change_forbidden); the last active Owner is never removed (409 last_owner_protected). Audited.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
account_membership_id path yes The organization membership.
If-Match header yes "<revision>" — the revision the change was based on (409 revision_stale when it moved).
  • 204
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
PATCH /v1/accounts/{account_id}/members/{account_membership_id} updateAccountMember
  • signed-in person only

Change someone's organization role.

Plan-014 B2 (design §27.5.2; FR12-AUTH-08). If-Match: "<revision>" names the membership's revision. Capability account_membership.role_change. Nobody is changed TO Owner (ownership is transferred); an Admin changes non-owner members only and never raises their own role (403 owner_change_forbidden); the last active Owner is never demoted (409 last_owner_protected, judged under a lock on every active Owner row). The person's open pages re-read where they may go at their next request. Audited.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
account_membership_id path yes The organization membership.
If-Match header yes "<revision>" — the revision the change was based on (409 revision_stale when it moved).

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/accounts/{account_id}/ownership-transfers transferAccountOwnership
  • signed-in person only

Transfer ownership of the organization.

Plan-014 B2 (FR12-AUTH-08). Capability account.ownership.transfer — Owner only — with a fresh step-up (401 step_up_required otherwise). The new Owner must already be an active member of the organization (400 validation_failed) with a verified email (403 invitation_identity_mismatch). In one database transaction, under a lock on every active Owner row, the target becomes Owner and the caller keeps former_owner_role: concurrent transfers end in exactly one Owner outcome and never in none. Accepts Idempotency-Key. Audited (account.ownership_transferred).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/accounts/{account_id}/resource-shares listAccountResourceShares
  • signed-in person only

What is shared between this organization's workspaces, and what the caller could share.

Plan-014 B5. An Owner or Admin of the organization (account.resource_share.read) sees every share; a person without it who holds resource_share.publish or resource_share.accept in some workspace sees only the shares touching those workspaces (a sibling's use of an open offer stays the sibling's). Each share names its revision, from → to, its state and every acceptance with the VoiceAgent versions that use it. shareable lists what the caller could publish (the live agents and their documents in the workspaces where they may publish); do_not_call is the always-combined list's size.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/accounts/{account_id}/security getAccountSecurity
  • signed-in person only

The organization's sign-in policy, verified domains and allowed networks.

Plan-014 C2. The enforced policy and any draft, policy_revision (what the next change names in If-Match, 0 while nothing is enforced), every verified domain with the TXT record to publish, the active allowed ranges, the address WeTalk sees for this browser (from its own proxy, never a header the browser wrote) and whether the ranges contain it, and the lockout preflight of each policy — who would be affected and whether an Owner keeps a tested way in. Capability account.security.read.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
  • 200
  • 401
  • 403
  • 404
  • 429
PUT /v1/accounts/{account_id}/sign-in-policy updateAccountSignInPolicy
  • signed-in person only

Set how people sign in.

Plan-014 C2 (FR12-SEC-04/06). If-Match: "<policy_revision>" names the enforced revision the change was based on ("0" for none; 409 revision_stale when it moved). Enforcing supersedes the previous policy, draws the next revision and is judged on every auth_session's next request; a policy that would leave no Owner a tested way in is refused 409 sign_in_policy_lockout and nothing is saved. Single sign-on only while no domain is verified is saved as a DRAFT (state: draft, draft_reason: sign_in_policy_domain_unverified) and asks nothing of anyone. Saving the policy already enforced changes nothing (unchanged: true). Capability account.sign_in_policy.write with a fresh step-up. Audited (account.sign_in_policy_changed).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
If-Match header yes "<policy_revision>" — the enforced revision the change was based on ("0" for none).

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
POST /v1/accounts/{account_id}/sign-in-policy/preflight previewAccountSignInPolicy
  • signed-in person only

Who a sign-in policy would affect, before anything is saved.

Plan-014 C2 (FR12-SEC-04). The lockout preflight for one choice: the people affected (asked to add an authenticator, out of recovery codes, or losing access because they cannot sign in with single sign-on on a verified domain) and whether at least one Owner keeps a tested way in. Saves nothing. Capability account.security.read.

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/accounts/{account_id}/terms-acceptance readAccountTerms
  • no key needed

The purchase terms in force for an organization, and whether it accepted them.

Plan-014 (E4's hand-off; design §27.8.15; PD-8). The purchase terms revision in force, its terms and privacy documents, whether the organization has accepted that revision, and whether the signed-in person may accept it (an Owner or a Finance member). The Plans page reads it before "Confirm and pay", which sends the revision as terms_revision. Any person who can enter the organization reads it. Refusals: 404 not_found (an organization the person cannot enter); the three 401s; 429 rate_limited.

Parameters
Name In Required Description
account_id path yes The organization (account) whose purchase terms are read.
  • 200
  • 401
  • 404
  • 429
POST /v1/accounts/{account_id}/terms-acceptance acceptAccountTerms
  • no key needed

Accept the terms in force for an organization's purchases.

Plan-014 B1 (design §27.8.15; owner decision PD-8). An Owner or a Finance member of the organization accepts the terms revision in force; until one has, a purchase is refused 409 terms_acceptance_required. Recorded with the person and the time, and audited; accepting a revision the organization already accepted changes nothing.

Requires X-WeTalk-Csrf. Refusals: 409 terms_acceptance_required (the revision is not the one in force — reload); 403 capability_missing (no Owner or Finance role here); 404 not_found (an organization the person cannot enter); 400 validation_failed; 403 impersonation_forbidden; the three 401s; 429 rate_limited.

Parameters
Name In Required Description
account_id path yes The organization (account) whose terms are accepted.

Takes a JSON request body.

  • 204
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/accounts/{account_id}/verified-domains createAccountVerifiedDomain
  • signed-in person only

Add a domain to verify.

Plan-014 C2 (FR12-SEC-05). The domain is added pending with a fresh challenge: publish txt_value as a TXT record at txt_name, then verify. 409 conflict when this organization already lists it. Capability account.sign_in_policy.write with a fresh step-up. Audited (verified_domain.added).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
DELETE /v1/accounts/{account_id}/verified-domains/{sso_domain_id} deleteAccountVerifiedDomain
  • signed-in person only

Remove a domain.

Plan-014 C2. Refused 409 sign_in_policy_lockout for the last verified domain while single sign-on only is enforced, and 409 conflict for the last proven domain of an enforced single sign-on provider. Capability account.sign_in_policy.write with a fresh step-up. Audited (verified_domain.removed).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
sso_domain_id path yes The verified domain.
  • 204
  • 401
  • 403
  • 404
  • 409
  • 429
PUT /v1/accounts/{account_id}/verified-domains/{sso_domain_id}/auto-join updateAccountVerifiedDomainAutoJoin
  • signed-in person only

Who may join without an invitation, and where.

Plan-014 C2 (FR12-SEC-05). People with an address on the domain may join while it is verified — always in a limited workspace role (viewer, member, developer, campaign_manager or billing; never owner or admin) and only in the active workspaces named. member_role: null turns it off. 422 validation_failed for another role or a workspace that is not an active workspace of this organization. Capability account.sign_in_policy.write with a fresh step-up. Audited (verified_domain.auto_join_changed).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
sso_domain_id path yes The verified domain.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
POST /v1/accounts/{account_id}/verified-domains/{sso_domain_id}/verify verifyAccountVerifiedDomain
  • signed-in person only
  • heavy

Verify now — check the domain's TXT record.

Plan-014 C2 (FR12-SEC-05, ED-3). One DNS check on WeTalk's own resolver. outcome: found verifies the domain (from pending, or again from lapsed) and schedules its daily recheck; not_found_yet leaves it waiting ("DNS can take up to an hour"); a timeout, SERVFAIL or refused connection is 503 domain_check_unavailable — never "not found", and the domain's state never moves on it. 409 sso_domain_already_claimed when another organization verified the domain first. Only a person verifies (an operator never marks a domain verified). Rate class expensive. Capability account.sign_in_policy.write with a fresh step-up. Audited (verified_domain.verified).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.
sso_domain_id path yes The verified domain.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
  • 503
POST /v1/accounts/{account_id}/workspace-access-grants grantAccountWorkspaceAccess
  • signed-in person only

Give someone in the organization operational access to a workspace, with a reason.

Plan-014 B2 over A3's explicit grant (FR12-AUTH-07). An organization role never opens a workspace's content; this does, visibly — the membership records access_grant and the reason (at least ten characters), and the audit log records who gave it. The person must already be in the organization (404 not_found otherwise — invite a stranger instead). Only an Owner of the organization grants the workspace Owner role. Capability account.workspace_access.grant — Owner, Admin.

AMENDED 2026-10-06 (plan-014 review I-4 / I-5): a draining or archived workspace takes nobody new (422 workspace_draining / workspace_archived; a deleted one is 404), and the seats of the balance funding the workspace are checked after the row, in the same transaction (409 seat_limit_reached).

Parameters
Name In Required Description
account_id path yes The organization (account) — the request's own; another one is 404 not_found.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/me/account-closures listMyAccountClosures
  • no key needed

The closures the signed-in person asked for that can still be undone.

An identity entry point (an unscoped auth_session is the normal caller once access ended). Each closure in its grace that this person started, with the organization's name, the grace's end and where to undo it.

  • 200
  • 401
GET /v1/me/onboarding-draft getOnboardingDraft
  • no key needed

The signed-in person's resumable Create-an-organization draft.

Plan-014 B1 (FR12-ONB-01, FR12-ONB-05). The open draft — its schema revision, the steps completed (identity, organization, voice_agent) and the validated inputs — or an empty one, with what the screen shows beside it: whether the email is verified, the person's trial eligibility (one trial per verified person) and its length, the billing timezone a new organization gets, and the terms revision in force with its links. Kept server side; never a password, card data or a token.

  • 200
  • 401
  • 429
PUT /v1/me/onboarding-draft saveOnboardingDraft
  • no key needed

Save the Create-an-organization draft.

Plan-014 B1 (FR12-ONB-05). Replaces the open draft's inputs and the person's own step markers. Unknown fields are refused, so nothing but the three known inputs can be stored. identity needs a verified email (403 email_unverified); organization is marked only by creating the organization (POST /v1/accounts) and is refused here; voice_agent needs the organization first. Once the organization exists its inputs are the record's and are not changed by a save. The draft completes when every step is marked; the next draft starts empty.

Requires X-WeTalk-Csrf. Refusals: 400 validation_failed; 422 country_unsupported; 403 email_unverified; 403 impersonation_forbidden; the three 401s; 429 rate_limited.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 422
  • 429
POST /v1/resource-shares/{resource_share_id}/accept acceptResourceShare
  • signed-in person only

Accept something shared with a workspace.

Plan-014 B5 (FR12-SHR-02). The consumer is consumer_workspace_id, else the share's destination, else the workspace the person has open. A document is accepted by reference (mode: reference), a configuration as a copy (mode: copy); mode may be left out. Accepting again answers outcome: already_accepted. Capability resource_share.accept in the consumer workspace (workspace Owner, Admin).

Parameters
Name In Required Description
resource_share_id path yes The share.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
POST /v1/resource-shares/{resource_share_id}/attach attachResourceShare
  • signed-in person only

Add an accepted shared document to one of this workspace's agent builds.

Plan-014 B5 (FR12-SHR-03). Copies the accepted, fixed revision into the agent's build that waits for documents (an edit that replaces documents, or a new agent before its build starts), through the share gate: accepted by the agent's workspace, not revoked, both workspaces active. A build that already carries the same bytes answers created: false. Capability agent_document.write on the agent.

Parameters
Name In Required Description
resource_share_id path yes The share.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
POST /v1/resource-shares/{resource_share_id}/revoke revokeResourceShare
  • signed-in person only

Revoke a share — nothing new can use it; what is already built keeps it.

Plan-014 B5 (FR12-SHR-03). Final. Blocks new acceptance, new use, new copies and new builds; live agent versions and conversations in progress keep the revision they use. The answer lists each acceptance with the agent versions that use the revision, so the person is told who keeps it. Revoking again answers outcome: already_revoked. Capability resource_share.publish in the source workspace, or account.workspace.manage in the organization.

Parameters
Name In Required Description
resource_share_id path yes The share.
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/workspaces/{workspace_id}/resource-shares createResourceShare
  • signed-in person only

Share a document revision or an agent's configuration from this workspace.

Plan-014 B5 (FR12-SHR-02). The source workspace publishes the revision it holds NOW — the document row itself, or the agent's live version — to one other workspace, or to every other workspace when destination_workspace_id is left out. Publishing the same revision to the same destination again answers 200 with outcome: already_published and writes nothing. Capability resource_share.publish in the source workspace (workspace Owner, Admin).

Parameters
Name In Required Description
workspace_id path yes The source workspace that publishes.

Takes a JSON request body.

  • 200
  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429

billing-account

GET /v1/accounts/{account_id}/billing-accounts listBillingAccounts
  • billing:read

The balances of this organization the caller may read.

Every balance to an Owner, Admin or Finance member of the organization (account.finance.read); a delegate of a dedicated balance sees that balance only. The shared balance first, then dedicated ones by name. Each balance carries what GET /v1/billing-accounts/{billing_account_id} returns.

403 capability_missing to a member holding neither; another organization's id is 404 not_found. An organization API key holding billing:read reads every balance (plan-014 C3); a workspace key, or an organization key without billing:read, is refused 403 scope_insufficient.

Parameters
Name In Required Description
account_id path yes The organization (account) whose balances are read.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/billing-accounts/{billing_account_id} getBillingAccount
  • billing:read

One balance — Available, Held and Balance, its terms, what it pays for and this month's spend.

The live balance is the one computation every surface shows: Available (available_minor) = Balance (balance_minor) − talk time not yet settled today (unbilled_today_minor) − held by conversations in progress (reserved_minor). funded_workspace names each workspace this balance pays for now, since when (the start of its unbroken run on this balance) and its open usage in seconds. month_spend_minor is the talk charged this billing month (in the organization's billing timezone), closed days only.

Needs account.finance.read over this balance (Owner, Admin or Finance of the organization, or a live delegate of a dedicated balance): 403 capability_missing otherwise. An unknown balance or another organization's is 404 not_found. 500 billing_assignment_missing or data_integrity_out_of_range when the stored billing facts are incomplete (an organization without a billing timezone, say) — never a substituted value. An organization API key holding billing:read may read it (plan-014 C3); a workspace key is refused 403 scope_insufficient.

Parameters
Name In Required Description
billing_account_id path yes The balance (billing account) read — always named, never "the first".
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/billing-accounts/{billing_account_id}/funded-workspaces listFundedWorkspaces
  • billing:read

Which workspaces this balance pays for now, since when, and their open usage.

The same rows as funded_workspace in GET /v1/billing-accounts/{billing_account_id}, by workspace id. A conversation keeps the balance it started on: a workspace moved to another balance stops appearing here for new activity while its conversations in progress stay where they began.

Same authorization and errors as GET /v1/billing-accounts/{billing_account_id}, organization API keys with billing:read included.

Parameters
Name In Required Description
billing_account_id path yes The balance (billing account) read — always named, never "the first".
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429

billing-operation

POST /v1/accounts/{account_id}/billing-operations confirmBillingOperation
  • signed-in person only

Confirm a quote — one durable billing operation per Idempotency-Key.

Confirms billing_quote_id. In ONE db_transaction the server checks the quote is still valid — not expired (410 quote_expired), the organization's billing configuration unchanged since it was made (409 quote_stale, after locking that configuration), still eligible (the kind's own re-check) — that a purchase names the purchase terms revision in force and the organization has accepted it (409 terms_acceptance_required: an Owner or Finance member accepts it first), and that what the confirmation states, when it states it (kind, amount_due_minor: what the person was shown), is the quote (409 quote_mismatch); then creates the operation and moves it to awaiting_payment (or straight to applying when nothing is due). A payment is then asked for exactly once; its outcome — and a hosted payment page to send the person to (next_action) — is what the status route reports. A browser return from a payment page only READS the status; it never confirms a payment.

`Idempotency-Key` is required (16–128 characters). The same key with the same confirmation returns the same operation, however long after; the same key with a different confirmation is 409 operation_conflict. A quote is confirmed at most once (409 operation_conflict), and two funding or plan changes are never in progress together (409 operation_conflict).

Needs account.finance.manage over the quote's balance (or the organization). A cookie request carries X-WeTalk-Csrf. Signed-in people only; refused under impersonation.

Parameters
Name In Required Description
account_id path yes The organization (account) the quote belongs to.
Idempotency-Key header yes Required, 16–128 characters. Decides the durable outcome; the HTTP answer is replayed for 24 h.

Takes a JSON request body.

  • 202
  • 400
  • 401
  • 403
  • 404
  • 409
  • 410
  • 422
  • 429
POST /v1/accounts/{account_id}/billing-quotes createBillingQuote
  • signed-in person only

A frozen quote for one billing change — its lines, tax, effective time and expiry.

The body is one request of the quote union (kind and the kind's own fields). The server computes and FREEZES the lines (net of VAT, after one rounding each), the tax per line, the instant the change takes effect, the remaining-period credit and resource impact where the kind has them, and when the quote expires (billingOrganization.quoteLifetimeSecond: 30 minutes). A confirmation charges exactly these amounts or refuses: it never recomputes a price. Prices exclude VAT.

Needs account.finance.manage over the balance the request names (or the organization): Owner, Admin or Finance of the organization, or a live delegate of a dedicated balance — 403 capability_missing otherwise; a balance of another organization is 404 not_found. A closing organization refuses a purchase (422 account_closing). Signed-in people only (403 scope_insufficient to an API key); refused under impersonation. A cookie request carries X-WeTalk-Csrf.

Refusals: 400 validation_failed (an unknown or unavailable kind, an unknown member, a malformed id or amount); the kind's own (422 offer_not_sellable, 503 commercial_policy_unset, 422 transfer_exceeds_available, 422 legal_profile_required, 503 tax_rate_unset).

Parameters
Name In Required Description
account_id path yes The organization (account) the quote is for — the signed-in context's own.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
  • 503
GET /v1/billing-operations/{billing_operation_id} getBillingOperation
  • signed-in person only

One billing operation's status — what a payment page's return URL reads.

The operation as it stands: draft, awaiting_payment, applying, completed, failed (with failure_code), cancelled or needs_reconciliation (the payment's outcome is being looked up with the bank — never charged again). Reading it changes nothing.

Needs account.finance.read over the operation's balance (or the organization); another organization's operation is 404 not_found. Signed-in people only (403 scope_insufficient to an API key).

Parameters
Name In Required Description
billing_operation_id path yes The durable billing operation.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/billing-operations/{billing_operation_id}/cancel cancelBillingOperation
  • signed-in person only

Cancel an operation before any payment was asked for.

Only a draft, or an operation awaiting_payment for which no payment has been asked yet, can be cancelled. Once a payment was asked for it is 409 payment_pending (its outcome will be shown); any other state is 409 operation_state_conflict.

Needs account.finance.manage over the operation's balance (or the organization). A cookie request carries X-WeTalk-Csrf. Signed-in people only; refused under impersonation.

Parameters
Name In Required Description
billing_operation_id path yes The durable billing operation.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429

billing-profile

GET /v1/accounts/{account_id}/billing-profile readBillingProfile
  • signed-in person only

The legal and tax details in force, the VAT number's check and how VAT applies.

data is null until the details are given. vat_treatment is how VAT applies to the next document: a Greek payer pays Greek VAT; an EU business whose VAT number checked valid is reverse charged; any other EU payer — a consumer, or a business whose number is not valid yet — pays its own country's VAT; a payer outside the EU pays none. The rate itself comes from the VAT rate table when a document is issued.

Needs account.finance.read (Owner, Admin or Finance of the organization): 403 capability_missing otherwise. Another organization's id is 404 not_found. Signed-in people only: an API key is refused 403 scope_insufficient.

Parameters
Name In Required Description
account_id path yes The organization (account) whose legal and tax details these are.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
PUT /v1/accounts/{account_id}/billing-profile replaceBillingProfile
  • signed-in person only

New legal and tax details from now; the next document uses them.

The details are checked on the server. A business names its VAT or tax number. An EU business's number must have its country's VAT number shape (400 tax_id_malformed, before anything is saved or asked) and is stored as the VAT number with its country prefix (EL… for Greece). A consumer's number, or a number from outside the EU, is stored as entered and never checked: the VAT does not depend on it.

An unchanged number keeps its check. A new one is checked with the EU VAT register straight after saving; the answer says valid, invalid, or unavailable when the register could not answer — never invalid for that. Nothing checks it again on a schedule and there is no customer "check again" (owner answer 42): WeTalk support can recheck an unavailable number on request. Until it checks valid the payer pays its country's VAT, and reverse charge applies only to documents issued after that.

expected_billing_profile_id names the details the form was showing (null for none); 409 revision_stale when someone saved others since. Needs account.finance.manage (Owner, Admin or Finance); a WeTalk operator acting as a member may not change them. 503 tax_id_verifier_unconfigured when no tax-number check is available here — nothing is saved.

Parameters
Name In Required Description
account_id path yes The organization (account) whose legal and tax details these are.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
  • 503

commercial-offer

GET /v1/accounts/{account_id}/commercial-offers listCommercialOffers
  • billing:read

The offers this organization may buy now.

The public offers in force and the custom offers granted to this organization, each one sellable: approved, in force and priced. An offer without approved values is absent — never listed with a pending price. At launch the answer is PAYG alone, at its price series' rate.

Needs account.finance.read (Owner, Admin, Finance): 403 capability_missing otherwise. Another organization's id is 404 not_found. An organization API key holding billing:read may read the offers (plan-014 C3); a workspace key is refused 403 capability_missing.

Parameters
Name In Required Description
account_id path yes The organization (account) whose offers are read.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429

compliance

GET /v1/compliance getCompliance
  • agent:read

What callers are told, how long things are kept, and who at WeTalk may hear them.

The principal's workspace: its consent lines (one row per medium, with how many languages carry the line and one sample), the effective retention rule for every class, operator access (which is the ACCOUNT's setting — operator_access.scope is account), the account's active do-not-call count, the workspace's person-data requests (the newest 50) and the AC-34 deadline in hours. outbound_caller_id is null: branded caller identity is not modelled yet.

  • 200
  • 401
  • 403
  • 429
DELETE /v1/compliance/consent-lines/{medium}/{language_code} deleteConsentLine
  • signed-in person only

Put WeTalk's standard recording notice back for one language.

Session only, an owner or an admin, refused under impersonation, as putConsentLine. Removes the workspace's own line — there is no "off": a version that records always tells the caller — and republishes every live or paused agent that speaks the language with the language pack's standard notice. Writes compliance.consent_line_changed.

Parameters
Name In Required Description
medium path yes voice — only calls are recorded, so only the spoken line can be changed.
language_code path yes The language the line is spoken in; it must be one this platform speaks.
  • 200
  • 400
  • 401
  • 403
  • 409
  • 422
  • 429
PUT /v1/compliance/consent-lines/{medium}/{language_code} putConsentLine
  • signed-in person only

Save the workspace's own wording of what callers hear before a recording starts.

Session only, and an owner or an admin: an API key is 403 permission_denied. text is trimmed and its whitespace folded; it must be non-empty, at most 300 characters, and must not contain one of the language's own "don't record me" phrases (the agent would hear its own notice as the caller declining) — each is 400 validation_failed on text. A language the platform does not speak is 400 language_unavailable or language_pack_incomplete.

Upserts the workspace's consent_line row and, in the same database transaction, republishes every live or paused agent of the workspace whose live version speaks the language and says anything else — the live configuration with the line replaced, published as a new version, as the "Record calls" switch does — so callers hear it from their next conversation. One agent that cannot be republished rolls the whole save back (422 agent_version_not_ready, naming it); one that changed meanwhile is 409 conflict. Writes compliance.consent_line_changed. A WeTalk operator acting as a member is refused with 403 impersonation_forbidden, and the refusal is written to the audit log as impersonation.refused.

Parameters
Name In Required Description
medium path yes voice — only calls are recorded, so only the spoken line can be changed.
language_code path yes The language the line is spoken in; it must be one this platform speaks.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 409
  • 422
  • 429
PUT /v1/compliance/operator-access putOperatorAccess
  • signed-in person only

Allow or stop WeTalk operators opening recordings.

Session only: an API key is 403 permission_denied. Writes account.operator_access_recording_enabled, which applies to EVERY workspace in the account (operator_access.scope is account), and the audit entry compliance.operator_access_changed. A WeTalk operator acting as a member is refused with 403 impersonation_forbidden — an operator may not grant themselves access — and the refusal is itself written to the audit log as impersonation.refused.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/compliance/person-data-requests createPersonDataRequest
  • signed-in person only

Ask for everything held about one person to be exported or deleted.

Session only: an API key is 403 permission_denied. search_term is a phone number in international format (spaces, brackets and hyphens are ignored) or an email address; anything else is 400 person_data_request_term_invalid. The request row stores only a hash of the term — the shared phone-number hash for a number, SHA-256 of the lower-cased address for an email — and the audit entry person_data_request.created records a mask (the last four digits, or the first character and the domain), which is what search_term_masked shows. A person_data_request.run message carrying only the request's id is queued in the same database transaction.

Answers 202: the request is accepted and queued, not done. deadline_at is 24 hours after requested_at (AC-34) and progress says where the request stands against it. No worker handler for the topic is deployed yet (execution.handler_deployed is false), so a queued request does not progress until one is; the deadline runs regardless, and a request still queued after it reads overdue. download_url is always null: a completed export's file is collected with downloadPersonDataExport.

A WeTalk operator acting as a member is refused with 403 impersonation_forbidden — a deletion is irreversible — and the refusal is written to the audit log as impersonation.refused.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 202
  • 400
  • 401
  • 403
  • 409
  • 429
POST /v1/compliance/person-data-requests/{person_data_request_id}/download downloadPersonDataExport
  • signed-in person only

Get a short-lived link to the file a completed export wrote.

Session only, and an owner or an admin: an API key is 403 permission_denied. The request must be a completed export in the principal's workspace; a deletion, or an export that has not finished, is 409 person_data_export_not_ready, and an export whose file the export container's seven-day rule has removed (download_available_until has passed) is 410 person_data_export_expired — ask for a new export. Each call writes the audit entry person_data_request.downloaded, committed before the link exists, and answers a new delegated read link to the zip, valid until expires_at (the API's export read window). A WeTalk operator acting as a member is refused with 403 impersonation_forbidden, and the refusal is written to the audit log as impersonation.refused.

Parameters
Name In Required Description
person_data_request_id path yes The request whose file to collect.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 410
  • 429
PUT /v1/compliance/retention-policies/{class} putRetentionPolicy
  • agent:write

Choose how long one class of data is kept in this workspace.

retain_day must be one of the values the class offers (option on the class in getCompliance); anything else, and any unknown class, is 400 retention_option_invalid. Upserts the workspace's retention_policy row — the row the retention sweep reads — writes the audit entry compliance.retention_changed with the value before and after, and queues a retention.sweep message for the workspace, all in one database transaction. The sweep is an application job on the outbox dispatcher, not a storage lifecycle rule (AC-35); until its handler is deployed the message waits, which the response says in retention_sweep.handler_deployed.

Parameters
Name In Required Description
class path yes The retention class.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 429
GET /v1/compliance/retention-policies/{class}/preview previewRetentionPolicy
  • agent:read

What choosing a period for one class would make the next sweep remove.

Read-only. Counts, with the retention sweep's own predicates, what the next sweep would remove in this workspace if the class were kept for retain_day days: recordings (rows and their audio), transcripts (per conversation whose transcript document or words would be removed), or caller numbers blanked (caller_detail), for conversations that ENDED before the cutoff. Classes the sweep keeps (order, survey_result, audit) answer swept: false and affected_count: 0. retain_day must be one of the class's option values (400 retention_option_invalid otherwise). Nothing is saved; the Compliance screen asks this before it confirms a shorter period.

Parameters
Name In Required Description
class path yes The retention class.
retain_day query yes The period being considered, one of the class's option values.
  • 200
  • 400
  • 401
  • 403
  • 429
GET /v1/do-not-call-entries listDoNotCallEntries
  • agent:write

The account's active do-not-call entries.

agent:write for a key; owner, admin or member for a person. Newest first. source filters by how a number was added (added_by_member also shows a WeTalk operator's additions); search matches the trailing digits of the number (when the term is a number) or any part of the name, case-insensitively. matched_count counts the whole filtered set and stat the whole account — neither is the page.

Parameters
Name In Required Description
source query no
search query no
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/do-not-call-entries addDoNotCallEntry
  • agent:write

Add one number to the account's do-not-call list.

agent:write for a key; owner, admin or member for a person. The number is normalised to E.164 (a national number against the account's country) and stored with its phone-number hash. Written as added_by_member with the person, as added_by_operator with the operator when a WeTalk operator is acting as the person, and with neither for an API key. note is kept in the audit entry do_not_call_entry.added.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
DELETE /v1/do-not-call-entries/{do_not_call_entry_id} removeDoNotCallEntry
  • signed-in person only

Remove a number from the account's do-not-call list.

Session only: owner, admin or member, signed in. An API key is 403 permission_denied. A WeTalk operator acting as the person is 403 impersonation_forbidden, and the refusal is audited: operators may add a suppression but never remove one. A member may remove any entry, including one an operator added. Sets removed_at and removed_by_person_id; writes the audit entry do_not_call_entry.removed.

Parameters
Name In Required Description
do_not_call_entry_id path yes
  • 204
  • 401
  • 403
  • 404
  • 429
POST /v1/do-not-call-entries/exports exportDoNotCallEntries
  • agent:write
  • heavy

Export the account's active do-not-call entries as a CSV.

agent:write for a key; owner or admin for a person. The audit entry do_not_call_entry.exported (row count and artefact id) is written before the file exists. More rows than WETALK_API_EXPORT_MAX_ROW is refused with the count; the file is never truncated.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
  • 201
  • 401
  • 403
  • 422
  • 429
POST /v1/do-not-call-entries/import importDoNotCallEntries
  • agent:write

Add every number in a CSV to the account's do-not-call list.

agent:write for a key; owner, admin or member for a person. The file travels as text in source_text (design §15.5.6); it is read, not stored. Each readable number is added for every agent; one already on the list is counted, not duplicated. Writes one audit entry, do_not_call_entry.imported, with the three counts.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 413
  • 422
  • 429
POST /v1/do-not-call-entries/removals removeDoNotCallEntries
  • signed-in person only

Remove several numbers from the account's do-not-call list at once.

The same rules as removing one (customer-portal review #254, 2026-09-27). Session only: owner, admin or member, signed in. An API key is 403 permission_denied. A WeTalk operator acting as the person is 403 impersonation_forbidden, the refusal is audited once, and no entry is touched. Each entry taken off writes its own audit entry, do_not_call_entry.removed. An id that names no active entry of this account is counted in not_found_count rather than refused, so a list that was a moment out of date still removes the rest.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 429
GET /v1/do-not-call/opt-out-page getOptOutPage
  • agent:read

The caller's own business as its public opt-out page names it.

agent:read for a key; any member role. The account's slug and name, from the credential's account and no other: the page suppresses for this one business (AC-32).

  • 200
  • 401
  • 403
  • 429
GET /v1/do-not-call/refusal-phrases listRefusalPhrases
  • agent:read

The refusal phrases the agent listens for, per language, and the AC-30 deadline.

agent:read for a key; any member role. Read from the shipped language packs; a language that is not available (unpublished, differing from its publication, or failing any of the six availability conditions, signed-off refusal phrases among them) is missing, never answered in another language. See DoNotCallRefusalPhraseCard.

  • 200
  • 401
  • 403
  • 429
POST /v1/public/opt-out/{business_slug} createOptOutRequest
  • no key needed

Take a number off one business's call list, from its public opt-out page.

Unauthenticated. The slug is the account's public name (§15.4.3) and the only thing that names the business: the number is suppressed for that one account and no other (AC-32), account-wide (scope = every_voice_agent, source = opt_out_page). A national number is read against the business's country. The submission is recorded as an opt_out_request with a hash of the source address, never the address.

What it will not tell a stranger. A repeat submission of a number already on the list is answered exactly like the first — same status, same body, recorded_at is this submission's time — so the page cannot reveal who has opted out. An unknown business and an unreadable number are refused identically (same status, code, field and message).

Order of checks: the per-source limit (every attempt counts), the body, the CAPTCHA, then the business and the number. Nothing is read or written until the CAPTCHA passes: a solution to a challenge from getOptOutChallenge that is genuine, unexpired and not submitted before.

Parameters
Name In Required Description
business_slug path yes The business's public slug, as printed in its opt-out link.

Takes a JSON request body.

  • 201
  • 400
  • 429
  • 502
GET /v1/public/opt-out/challenge getOptOutChallenge
  • no key needed

A fresh proof-of-work challenge for the public opt-out page.

Unauthenticated. An ALTCHA challenge (product decision A6: self-hosted, no third party, no cookie), signed by this API and valid for five minutes. The page's widget solves it and the solution travels as captcha_token in createOptOutRequest; each solved challenge is accepted once.

Counted by the same per-source limit as createOptOutRequest — fetching a challenge spends the budget that submitting does. Never cached: every call is a new challenge.

The body's data is ALTCHA's own challenge shape, which is why its field names are camelCase: the widget reads exactly these.

  • 200
  • 429
  • 502

customer-order-payment

POST /v1/customer-order-payment/webhooks/everypay/new-payment receiveCollectionWebhookNewPayment
  • no key needed

The collection merchant's "new payment" webhook.

The processor posts the bare payment object here when a payment is created on the collection merchant (order money). The route is the event kind.

Public: the credential is the signature. X-Signature-SHA256 is base64(hmac_sha256(raw body, collection merchant secret)), verified against the raw bytes, constant-time, before any JSON parsing. A request from an address outside the processor's documented list is refused with 403 and nothing is written. Every other request is recorded with its raw bytes and its verdict, a forged one included, which is acknowledged with 200 and processed by nothing.

A verified event is attributed to WeTalk's own payment attempt — by the attempt the metadata names, corroborated by the processor's reference, or by the reference alone — and dispatched to the worker, which re-reads the payment from the processor before it marks anything paid. An event that names no attempt is recorded unattributed and becomes an operator exception; it never credits anyone. A webhook is a trigger, never a fact.

Takes a JSON request body.

  • 200
  • 403
POST /v1/customer-order-payment/webhooks/everypay/notification-expired receiveCollectionWebhookNotificationExpired
  • no key needed

The collection merchant's "notification expired" webhook.

The processor posts the bare payment notification here when a payment link expires unpaid. Attributed by the notification's own token. Verification, the address check and the acknowledgement are those of the "new payment" route.

Takes a JSON request body.

  • 200
  • 403
POST /v1/customer-order-payment/webhooks/everypay/notification-paid receiveCollectionWebhookNotificationPaid
  • no key needed

The collection merchant's "notification paid" webhook.

The processor posts the bare payment notification here when a payer pays a payment link. metadata may arrive as [] when empty, and payment is the paid payment's token. The worker re-reads the notification and the payment and compares the amount with the order before the order is marked paid. Verification, the address check and the acknowledgement are those of the "new payment" route.

Takes a JSON request body.

  • 200
  • 403
POST /v1/customer-order-payment/webhooks/everypay/refund receiveCollectionWebhookRefund
  • no key needed

The collection merchant's "refund" webhook.

The processor posts here when a payment on the collection merchant is refunded. Its body is undocumented, so it is read as a trigger only: attributed through the refunded payment it names, and the worker re-reads the payment's refunds from the processor before it records anything. Verification, the address check and the acknowledgement are those of the "new payment" route.

Takes a JSON request body.

  • 200
  • 403
POST /v1/public/payment-links/{code}/placeholder-outcome recordPlaceholderPaymentOutcome
  • no key needed

The placeholder test page's choice — paid, or not paid.

Unauthenticated; the test path only. Accepted (202) when the code names a placeholder attempt (a test conversation's) that is open and unexpired; the choice is recorded and the worker settles the attempt the way a real confirmation would (paid), or cancels it (not_paid). No money moves and nothing is written to a payable book. For a REAL link the answer is exactly the unknown code's 404 — this route never says a real code exists.

Parameters
Name In Required Description
code path yes The link code: 16 base64url characters. Any other shape answers 404 payment_link_not_found, exactly like an unknown code. Never logged.

Takes a JSON request body.

  • 202
  • 400
  • 404
  • 429

entitlement

PUT /v1/accounts/{account_id}/entitlement-allocations putEntitlementAllocations
  • signed-in person only

Change a shared talk-second grant's workspace allocations.

account.finance.manage (Owner, Admin, Finance), a person acting as themselves. Allocations never add up to more than the grant (422 allocation_exceeds_grant) and never drop below what a workspace already used or holds (422 allocation_below_use). A workspace absent from the body keeps its allocation. If-Match is the grant's allocation_revision (409 revision_stale when it moved). Writes entitlement_allocation.changed.

Parameters
Name In Required Description
account_id path yes
If-Match header yes "<revision>" — the revision the change was based on (409 revision_stale when it moved).

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/accounts/{account_id}/entitlements getEntitlements
  • signed-in person only

The organization's allowances, holds, spend caps and concurrency entitlement at an instant.

account.finance.read (Owner, Admin, Finance). Grants are listed in native units with their workspace allocations and the unallocated remainder (granted − Σ allocated); each balance carries its trial and what its open conversations hold (Σ open usage reservations); each workspace its spend cap and this month's charged talk; concurrency is the figure admission enforces — max(the tier's channel units, the largest granted, unexpired quota request). A pay-as-you-go organization has no grant.

Parameters
Name In Required Description
account_id path yes The organization (account).
period query no An ISO 8601 instant inside the allowance period to read; now when absent.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/accounts/{account_id}/resource-restrictions listResourceRestrictions
  • signed-in person only

The restrictions a lowered plan limit left standing — disclosed, never a deletion.

account.finance.read (Owner, Admin, Finance). When an entitlement change leaves the organization over a limit, the newest VoiceAgents past it stand paused, the newest numbers read_only, a kind over its limit no_new and a scope over its recording storage no_new_storage; nothing is deleted, archived or removed. A restriction is lifted (and leaves this list) when the plan or the count fits again. A pay-as-you-go organization has none.

Parameters
Name In Required Description
account_id path yes The organization (account).
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/billing-accounts/{billing_account_id}/entitlement-impact getEntitlementImpact
  • signed-in person only

What would be over each limit if this balance moved to an offer — shown before a downgrade is confirmed.

account.finance.read (Owner, Admin, Finance). Reads only an offer the organization may buy now (public and approved, or granted to it); anything else is 422 offer_not_sellable. Each line names the kind, how many are in use, the limit after, by how much it would be over, the restriction the excess would get and the rows it would land on (the newest first kept last). channel_unit is the organization's concurrency entitlement before and after — callers beyond it queue; it never says a GPU is ready. Writes nothing.

Parameters
Name In Required Description
billing_account_id path yes The balance whose terms would change.
commercial_revision_id query yes The offer to preview.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
PUT /v1/workspaces/{workspace_id}/spend-cap putSpendCap
  • signed-in person only

Set the workspace's monthly spend cap.

spend_cap.write on the workspace (its Owner or Admin, or the organization's), a person acting as themselves. hard_stop refuses new conversations once the month's spend reaches the cap; capped_overage once it reaches the cap plus overage_minor; notify_only never refuses. warn_at_percent is a notification threshold, never enforcement. If-Match is the cap's revision — "0" creates the first one (409 revision_stale when it moved). Writes spend_cap.changed.

Parameters
Name In Required Description
workspace_id path yes
If-Match header yes "<revision>" — the revision the change was based on (409 revision_stale when it moved).

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429

financial-document

GET /v1/accounts/{account_id}/finance-overview getFinanceOverview
  • signed-in person only

Every balance the person may read, by workspace — settled, pending and estimated, kept apart.

For one billing month of the organization's billing timezone (period=YYYY-MM; default the month in progress): per balance and per workspace, settled_minor (spend the ledger has charged for the month), estimated_minor (talk that ended in the month and that day close has not charged yet, after the trial), and per balance pending_minor (payments started and not confirmed, now). calculated_at and time_zone say when and in which timezone the figures were worked out.

Owner, Admin and Finance (account.finance.read) see every balance; a person who is only a delegate of a dedicated balance sees that balance alone (reader = delegated), never the shared one. billing_account_id outside the person's balances and an unknown workspace_id are 404 not_found. Signed-in people only.

Parameters
Name In Required Description
account_id path yes The organization (account) whose documents these are.
period query no
workspace_id query no
billing_account_id query no
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/accounts/{account_id}/financial-documents listFinancialDocuments
  • signed-in person only

The organization's financial documents, newest first, and the month in progress.

Issued documents, newest first, a keyset page at a time (?cursor=&limit=, at most 100). Filters: kind, payment_state, billing_account_id, workspace_id, and period (YYYY-MM, a calendar month in the organization's billing timezone, on the issue date).

estimated lists, on the first unfiltered page, each prepaid balance's usage in the month in progress — the consumption statement "Not issued yet". It is computed on read and carries no number.

issuance.state is withheld with reason = issuer_unset while WeTalk's issuer details are not set: no document is issued then, though payments are still taken; the documents are issued once the details are set.

Owner, Admin and Finance (account.finance.read) see every document; a person who is only a live delegate of a dedicated balance sees the documents naming one of their balances — never an organization-wide document — and a billing_account_id outside them is 404 (plan-014 review I-7). Another organization's id is 404 not_found. Signed-in people only: an API key is refused 403 scope_insufficient.

Parameters
Name In Required Description
account_id path yes The organization (account) whose documents these are.
kind query no
payment_state query no
billing_account_id query no
workspace_id query no
period query no
cursor query no
limit query no
  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/accounts/{account_id}/financial-documents/exports exportFinancialDocuments
  • signed-in person only
  • heavy

The documents list as one CSV file, with the documents list's filters.

Filters (query): period (YYYY-MM), workspace_id, kind, payment_state, billing_account_id. The month in progress is a labelled "Not issued yet (estimated)" row exactly when the list shows it. Amounts are decimal text with their currency. The export is recorded in the organization's audit log before the rows are read; a file with more rows than financialDocument.exportMaximumRow is refused 422 export_too_large, never cut.

Owner, Admin and Finance export every document; a delegate of a dedicated balance only that balance's. Signed-in people only; a WeTalk operator acting as a member is refused 403 impersonation_forbidden.

Parameters
Name In Required Description
account_id path yes The organization (account) whose documents these are.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
period query no
workspace_id query no
kind query no
payment_state query no
billing_account_id query no
  • 201
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
POST /v1/billing-accounts/{billing_account_id}/ledger-entries/exports exportLedgerEntries
  • signed-in person only
  • heavy

One balance's ledger (the Transactions screen) as one CSV file.

Every entry of the balance, oldest first, with the running balance exactly as it was written; optionally from and to (YYYY-MM-DD, days of the organization's billing timezone, inclusive). Recorded in the audit log before the rows are read; refused 422 export_too_large over the export ceiling, never cut.

Needs account.finance.read over the balance (Owner, Admin, Finance) or a live delegation for a dedicated balance. Another organization's balance is 404 not_found. Signed-in people only.

Parameters
Name In Required Description
billing_account_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.
from query no
to query no
  • 201
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
GET /v1/financial-documents/{financial_document_id} readFinancialDocument
  • signed-in person only

One financial document, its lines and its deliveries.

Needs account.finance.read (Owner, Admin or Finance), or a live delegation for the dedicated balance the document names (plan-014 review I-7). A document of another organization, an organization-wide one asked by a delegate, or another balance's, is 404 not_found.

Parameters
Name In Required Description
financial_document_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/financial-documents/{financial_document_id}/download downloadFinancialDocument
  • signed-in person only

A read-only link to the document's PDF, for 15 minutes.

The download is recorded in the organization's audit log before the link exists. The link is read-only, names one file and stops working after expires_at (15 minutes). A document whose file is not rendered yet, or that waits for the tax authority's confirmation, is 409 document_not_issued.

Needs account.finance.read, or a live delegation for the dedicated balance the document names (as the read). A signed-in person only; a WeTalk operator acting as a member is refused 403 impersonation_forbidden.

Parameters
Name In Required Description
financial_document_id path yes
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/financial-documents/{financial_document_id}/resend resendFinancialDocument
  • signed-in person only

Email the document again to the billing emails in force now.

Queues a new delivery — never a new document. Needs account.finance.manage (Owner, Admin or Finance). 409 document_not_issued for a document not issued yet; 422 legal_profile_required when the legal and tax details name no billing email.

Parameters
Name In Required Description
financial_document_id path yes
  • 202
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429

identity

POST /v1/accounts createAccount
  • no key needed

Create an organization for the signed-in person (plan-014 B1), or finish a sign-up.

Two bodies, told apart by `terms_revision` (a compatibility window of one release):

* with `terms_revision` — plan-014 B1's Create an organization (FR12-ONB-01 step 2): { account_name, country, first_workspace_name, terms_revision, time_zone? }. The person's email must be verified (403 email_unverified), and terms_revision must be the terms revision in force (409 terms_acceptance_required — reload and accept the current terms). The billing timezone is the platform's configured one for new organizations and is never taken from the request; without time_zone the first workspace takes it too. The Idempotency-Key is 16–128 characters. * without it — design §15.4.2's recovery body { account_name, country, time_zone }, which the console sends after a sign-up whose account step failed (setup_pending: true). It creates what sign-up would have; no terms acceptance is recorded.

Either way ONE committed unit creates the organization (state trial), its shared balance, the first workspace, the person's Owner role in the organization AND the workspace, the workspace's funding under the pay-as-you-go terms, the person's one trial when they are verified and have not had one (trial_granted), the terms acceptance (B1 body) and the audit entries — then rotates the auth_session onto the new organization and workspace and sets a new cookie.

`Idempotency-Key` is required, stored durably against the *person* and the key. The same key with the same facts re-scopes to the organization the first request created — however long ago, and when several arrive at once — rather than creating another; with other facts it is 409 idempotency_key_reused_with_different_body.

Requires X-WeTalk-Csrf matching this auth_session's token (401 csrf_token_invalid otherwise), checked before anything is looked up. Refusals: 400 validation_failed (a missing or unknown field, the name's length, a missing or malformed Idempotency-Key); 422 country_unsupported; 400 time_zone_invalid; 403 email_unverified; 409 terms_acceptance_required; 403 impersonation_forbidden; 401 authentication_required, auth_session_expired or auth_session_revoked; 429 rate_limited, per source address.

Parameters
Name In Required Description
Idempotency-Key header yes Required. Stored durably against the person and the key (16–128 characters for the B1 body).

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 409
  • 422
  • 429
  • 500
POST /v1/auth/discover discoverIdentity
  • no key needed

Home-realm discovery — should this address see a password field at all?

Identifier-first login (design §8.3). A work address on a verified, SSO-governed domain answers microsoft with a start_url; show_password is false when the organisation enforces SSO and the person holds no live break-glass grant.

Enumeration-safe. An unknown domain, a known domain with no SSO, and a malformed address all answer { "method": "password", "start_url": null, "show_password": true }. Only whether a *domain* is SSO-governed is ever disclosed — never whether a person or an account exists. Answers are padded to a configured floor so the governed and ungoverned branches take comparable time, and the route is limited per source address.

Takes a JSON request body.

  • 200
  • 400
  • 429
POST /v1/auth/email-verification verifyPersonEmail
  • no key needed
  • heavy

Confirm a person's email address with the token from the verification link.

The token the verification mail carries in its link's fragment (plan-014 MAIL; owner answer 41). Public: the link may be opened in a browser where nobody is signed in. The token is single-use and expires with WETALK_API_EMAIL_VERIFICATION_TTL_MS (24 hours in production); a used, expired or forged token is one answer, 404 not_found. A verified address is what lets the person's first organization take the trial (one trial per verified person). Counted against the per-source sign-up window (429 rate_limited).

Takes a JSON request body.

  • 200
  • 400
  • 404
  • 429
POST /v1/auth/email-verification/resend resendPersonEmailVerification
  • no key needed
  • heavy

Send the signed-in person a new email verification link.

Replaces the person's live verification link (the old one stops working) and queues a new verification mail (plan-014 MAIL). Requires X-WeTalk-Csrf matching this auth_session's token (401 csrf_token_invalid otherwise). 202 sent; 200 already_verified when the address is verified already (nothing is sent); 404 not_found while WeTalk sends no email (mail.binding = none); 403 impersonation_forbidden when a WeTalk operator is acting as the person; 429 rate_limited past the person's window.

  • 200
  • 202
  • 401
  • 403
  • 404
  • 429
POST /v1/auth/impersonation-handoff redeemImpersonationHandoff
  • no key needed

Redeem a WeTalk support hand-off and receive the impersonated auth_session.

The customer half of the impersonation hand-off (design §15.7.8, ADR 0017 §7). When a WeTalk operator starts acting as a member, the operator console opens <this console's origin>/impersonation/<impersonation_grant_id>#<handoff_token>. The console removes the fragment at once and posts both values here.

The token is accepted only if it names a live auth_session issued for exactly that grant, carrying the operator as actor, that has never been presented, whose person is active and still holds the membership the grant is for. It is then rotated at once: the hand-off token stops working on first use, and a new token is set as the __Host-wetalk_session cookie. The session can never outlive the grant (at most thirty minutes), and an unredeemed hand-off expires after the operator deployment's hand-off window.

No cookie is required, and one the browser already holds is ignored. Every refusal — unknown, already used, expired, mismatched, revoked or malformed — is the same 401 impersonation_handoff_invalid, so the route is not an oracle. 400 validation_failed only for a body that is not the documented shape.

The body is the auth_session, including the banner fields (actor.operator_display_name, actor.reason) read from the account's own audit log.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 429
POST /v1/auth/invitation/accept acceptInvitation
  • no key needed
  • heavy

Join a workspace from an invitation link, setting a password if new.

Design §16.37. A browser with a live session joins as that person (and must carry x-wetalk-csrf); the invitation's address must be theirs, or 403 permission_denied. Otherwise a person is created for the invitation's own address with display_name and password (sign-up's rules: 400 password_too_short; 409 email_taken when the address already has a sign-in — sign in, then open the link again). They do not found an account of their own. The invitation is single use; the session is rotated onto the account and workspace joined, and the cookie set.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/auth/invitation/inspect inspectInvitation
  • no key needed
  • heavy

What an invitation link joins, before anyone signs in.

The join page's first read (design §16.37). The token is the capability; it travels in the body so it stays out of access logs. 404 not_found for a link that was used, expired, was withdrawn or never existed — one answer for all four.

Takes a JSON request body.

  • 200
  • 400
  • 404
  • 429
POST /v1/auth/login logInPerson
  • no key needed
  • heavy

Sign in with an email address and a password.

Sets the __Host-wetalk_session cookie for valid credentials and answers 401 otherwise.

Every failure that is a fact about a person — unknown address, wrong password, locked, disabled, or a person who only signs in with Google — is the same `401 credential_invalid`, with the same message, no `field`, and the same cost: each one pays for a full memory-hard password derivation before it answers. Lockout is never announced; the recovery path is the ordinary password reset.

401 sso_required_for_domain is the one distinct refusal, because it is a fact about a *domain* that discovery already discloses. A person who belongs to exactly one account is scoped to it; a person with none or several gets an unscoped auth_session and the list of account_choice.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 429
POST /v1/auth/logout logOutPerson
  • no key needed

End this auth_session.

Revokes the auth_session the cookie names and clears the cookie. Requires X-WeTalk-Csrf matching this auth_session's token (401 csrf_token_invalid otherwise), checked before anything is looked up. Idempotent: an auth_session that has already expired is answered 204 and its cookie cleared all the same.

  • 204
  • 401
  • 429
GET /v1/auth/sign-in-code readSignInCodeAvailability
  • no key needed

Can a sign-in code be emailed in this environment?

Whether POST /v1/auth/sign-in-code can send a code at all. false while WeTalk has no email sender; the log-in page then offers no emailed code. Says nothing about any address.

  • 200
  • 429
POST /v1/auth/sign-in-code requestSignInCode
  • no key needed
  • heavy

Email a single-use sign-in code (uniform answer).

FR12-SEC-02. Answers 202 the same way for a known and an unknown address, and sets the HttpOnly challenge cookie __Host-wetalk_sign_in_code the code must be entered with (the browser binding). A code is mailed only to a person who exists. A new request while a code is open is a resend: the old code stops working. At most personSecurity.signInCodeResendMaximumPerHour resends per address per hour (429 rate_limited). An address on a domain with enforced single sign-on is 401 sso_required_for_domain. 404 not_found when no email sender exists (see readSignInCodeAvailability). An emailed code never counts as a second factor.

Takes a JSON request body.

  • 202
  • 400
  • 401
  • 404
  • 429
POST /v1/auth/sign-in-code/verify verifySignInCode
  • no key needed
  • heavy

Sign in with the emailed code.

Needs the challenge cookie set by requestSignInCode. Single use, expires after personSecurity.signInCodeExpirySecond, at most personSecurity.signInCodeAttemptMaximum attempts (the last burns the code); a wrong code counts against the person lock-out. Every failure is the one 401 sign_in_code_invalid. On success the auth_session cookie is set exactly as at password sign-in; the auth_session's mfa_verified_at is never set by an emailed code.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 429
POST /v1/auth/signup signUpPerson
  • no key needed
  • heavy

Create a person with a password, and their business account, and sign them in.

Design §15.4.2's two steps, in one request. First the person and their password, with an auth_session and the __Host-wetalk_session cookie (Secure; HttpOnly; SameSite=Lax; Path=/). Then the account the business name names: the account, its billing account, a first workspace, the person's owner membership and the trial (WETALK_API_TRIAL_GRANT_SECOND, 30 free minutes). The auth_session is then rotated to that account and workspace, so the 201 normally carries account_id, workspace_id and member_role: owner, and a new cookie.

The two steps run on two database roles and cannot share one transaction, so a failure after the person exists is answered honestly rather than hidden. Still `201`:

* setup_pending: true, account_id: null — the account was not created. Finish with POST /v1/accounts (createAccount). * setup_pending: false, account_id: null, the new account in account_choice — the account exists but the session was not re-scoped. Finish with POST /v1/session/account (switchAuthSessionAccount), never with a second createAccount.

Everything is judged before the person is created: the fields, the country (a closed list; 422 country_unsupported), the time zone (an IANA name; 400 time_zone_invalid) and the platform catalogue. Zero or several effective price books is 500 data_integrity_out_of_range with nothing created — there is no fallback price book.

Refusals: 400 password_too_short (fewer than 10 characters) or validation_failed naming the field; 401 sso_required_for_domain when the address's organisation enforces Microsoft sign-in (design §8.3 — a password account there would be a land grab); 409 email_taken when the address already has a person. That last one is an acknowledged enumeration oracle, which is why this route is limited per source.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 409
  • 410
  • 422
  • 429
  • 500
POST /v1/invitations/accept acceptAccountInvitation
  • no key needed

Accept an organization invitation.

Plan-014 B2 (FR12-TEAM-03, FR12-ONB-04). The signed-in person whose email is the invited one (403 invitation_identity_mismatch otherwise). Owner answer 45: accepting with the invited address VERIFIES that address when it was not yet verified (the emailed token proves the mailbox) — no second verification email; another address is never verified. The inviter's current authority is asked again (an inviter removed or demoted since makes the link unusable); the organization role is granted or the existing one reused; each workspace role is created or the existing membership reused, in one database transaction. A token works once; a second acceptance by the same person — concurrent or later — returns the same result (replayed: true). It creates no organization, workspace, subscription or trial. The console then switches into account_id and goes to landing. Requires X-WeTalk-Csrf; an operator acting as the person is refused.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 410
  • 429
POST /v1/invitations/inspect inspectAccountInvitation
  • no key needed

What an organization invitation offers.

Plan-014 B2 (FR12-ONB-04). The token is the proof; nobody needs to be signed in. Answers the organization, exactly the offered memberships (a workspace archived since is available: false and will not be granted), whether the invited address already signs in to WeTalk, the organization's policy, and — when a live session cookie is presented — whether the signed-in person is the invited one. An invitation the viewer already accepted answers state: accepted with where to go. Every unusable token is one answer: 410 invitation_unavailable.

Takes a JSON request body.

  • 200
  • 400
  • 410
  • 429
GET /v1/me/auth-sessions listMyAuthSessions
  • no key needed

Where I am signed in.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5).

  • 200
  • 401
  • 403
DELETE /v1/me/auth-sessions/{auth_session_id} revokeMyAuthSession
  • no key needed

Sign out one of my other devices.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf. 400 for the device making the request (log out instead); 404 not_found for anything that is not one of the person's own live auth_sessions.

Security review CR-1 (2026-10-06): when the person holds an active authenticator, this write needs an auth_session that has passed it (mfa_verified_at), else 401 assurance_required — answer POST /v1/me/mfa/challenge (the authenticator code or one recovery code), then retry. A password step-up does not stand in for it.

Parameters
Name In Required Description
auth_session_id path yes The device's auth_session id.
  • 204
  • 400
  • 401
  • 403
  • 404
POST /v1/me/auth-sessions/sign-out-others signOutMyOtherAuthSessions
  • no key needed

Sign out everywhere else.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf.

Security review CR-1 (2026-10-06): when the person holds an active authenticator, this write needs an auth_session that has passed it (mfa_verified_at), else 401 assurance_required — answer POST /v1/me/mfa/challenge (the authenticator code or one recovery code), then retry. A password step-up does not stand in for it.

  • 200
  • 401
  • 403
GET /v1/me/domain-join-offers listDomainJoinOffers
  • no key needed

My offers to join an organization through my verified email domain.

Owner answer 47. One offer per organization and verified domain: the organization, the domain, and the workspaces the person would join with their role (never Owner or Admin). Only a VERIFIED address, a domain in state verified with auto-join on, an organization that is not closed, and a person who holds no account or workspace membership there in any state (someone an Owner removed is not re-offered — they can still be invited). An unverified address has none. Dismissing an offer is the console's; nothing is stored for it.

  • 200
  • 401
  • 403
  • 429
POST /v1/me/domain-join-offers/{offer_id}/accept acceptDomainJoinOffer
  • no key needed

Accept an offer to join an organization through my verified email domain.

Owner answer 47. The organization's sign-in policy is judged first (plan-014 C2, FR12-SEC-06): a stricter policy is 401 assurance_required, a network outside its ranges 403 network_not_allowed; nothing joins either way. Then, in one database transaction in the organization, every condition of the listing is checked again and a workspace membership is created in each named active workspace with the constrained role (provenance domain_join; never an organization role), audited membership.domain_joined. An offer that no longer stands — for any reason — is 410 domain_join_offer_unavailable. The console then switches into account_id and goes to landing. Requires X-WeTalk-Csrf.

Parameters
Name In Required Description
offer_id path yes The offer's id (the organization's verified domain).
  • 200
  • 400
  • 401
  • 403
  • 410
  • 429
POST /v1/me/email-changes requestMyEmailChange
  • no key needed
  • heavy

Change my email: a confirmation link goes to the new address.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf. A security change: needs a confirmation within WETALK_API_STEP_UP_MAX_AGE_S (POST /v1/session/step-up or POST /v1/me/mfa/challenge), else 401 step_up_required. 409 email_taken when another sign-in holds the address; 404 not_found when no email sender exists. The change is made only when the link is opened (confirmMyEmailChange); the previous address is then told.

Security review CR-1 (2026-10-06): when the person holds an active authenticator, this write needs an auth_session that has passed it (mfa_verified_at), else 401 assurance_required — answer POST /v1/me/mfa/challenge (the authenticator code or one recovery code), then retry. A password step-up does not stand in for it.

Takes a JSON request body.

  • 202
  • 400
  • 401
  • 403
  • 404
  • 409
POST /v1/me/email-changes/confirm confirmMyEmailChange
  • no key needed
  • heavy

Confirm an email change with the link's token.

The token is the capability (single use, expires after WETALK_API_EMAIL_VERIFICATION_TTL_MS); no cookie is needed. 404 not_found for a used, expired or unknown token. The previous address is told, and the change is written to the audit log of every organization the person belongs to.

Takes a JSON request body.

  • 200
  • 400
  • 404
  • 409
  • 429
POST /v1/me/mfa/challenge answerMyMfaChallenge
  • no key needed
  • heavy

Prove my second step (authenticator code or recovery code).

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf. Stamps this auth_session's mfa_verified_at and its confirmation. A TOTP code works once (replay refused by time step); a recovery code works once and its use is audited. 401 mfa_factor_invalid / 401 recovery_code_invalid; rate-limited (429) and counted against the lock-out.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/me/mfa/recovery-codes regenerateMyRecoveryCodes
  • no key needed

Make new recovery codes; the old ones stop working.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf. A security change: needs a confirmation within WETALK_API_STEP_UP_MAX_AGE_S (POST /v1/session/step-up or POST /v1/me/mfa/challenge), else 401 step_up_required. 403 mfa_enrolment_required without an authenticator. The codes are answered once and stored only as keyed digests.

Security review CR-1 (2026-10-06): when the person holds an active authenticator, this write needs an auth_session that has passed it (mfa_verified_at), else 401 assurance_required — answer POST /v1/me/mfa/challenge (the authenticator code or one recovery code), then retry. A password step-up does not stand in for it.

  • 201
  • 401
  • 403
POST /v1/me/mfa/totp startMyTotpEnrolment
  • no key needed

Start (or replace) my authenticator app.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf. A security change: needs a confirmation within WETALK_API_STEP_UP_MAX_AGE_S (POST /v1/session/step-up or POST /v1/me/mfa/challenge), else 401 step_up_required. Answers the otpauth:// URI once (the console renders the QR code; the seed is never returned again and is stored only in the person-MFA vault). Confirm within personSecurity.totpEnrolmentExpirySecond; a new start replaces an unfinished one.

Security review CR-1 (2026-10-06): when the person holds an active authenticator, this write needs an auth_session that has passed it (mfa_verified_at), else 401 assurance_required — answer POST /v1/me/mfa/challenge (the authenticator code or one recovery code), then retry. A password step-up does not stand in for it.

  • 201
  • 401
  • 403
POST /v1/me/mfa/totp/confirm confirmMyTotpEnrolment
  • no key needed
  • heavy

Confirm the authenticator with a 6-digit code.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf. 401 mfa_factor_invalid for a wrong code (it counts against the lock-out). The first authenticator answers its recovery codes once (recovery_code, personSecurity.recoveryCodeCount of them); a replacement keeps the existing codes and answers null.

Security review CR-1 (2026-10-06): when the person holds an active authenticator, this write needs an auth_session that has passed it (mfa_verified_at), else 401 assurance_required — answer POST /v1/me/mfa/challenge (the authenticator code or one recovery code), then retry. A password step-up does not stand in for it.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/me/password changeMyPassword
  • no key needed
  • heavy

Change (or set) my password; my other devices are signed out.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf. A security change: needs a confirmation within WETALK_API_STEP_UP_MAX_AGE_S (POST /v1/session/step-up or POST /v1/me/mfa/challenge), else 401 step_up_required. Sign-up's rules apply (400 password_too_short). Every other auth_session of the person is revoked.

Security review CR-1 (2026-10-06): when the person holds an active authenticator, this write needs an auth_session that has passed it (mfa_verified_at), else 401 assurance_required — answer POST /v1/me/mfa/challenge (the authenticator code or one recovery code), then retry. A password step-up does not stand in for it.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
GET /v1/me/profile readMyProfile
  • no key needed

My profile — the same in every organization.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5).

  • 200
  • 401
  • 403
PATCH /v1/me/profile updateMyProfile
  • no key needed

Change my display name, locale or time zone.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf. time_zone must be an IANA zone (400 time_zone_invalid); null clears locale or time zone.

Security review CR-1 (2026-10-06): when the person holds an active authenticator, this write needs an auth_session that has passed it (mfa_verified_at), else 401 assurance_required — answer POST /v1/me/mfa/challenge (the authenticator code or one recovery code), then retry. A password step-up does not stand in for it.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
GET /v1/me/sign-in-methods listMySignInMethods
  • no key needed

My ways to sign in and my second step.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). linkable is false while WeTalk serves no Google or Microsoft sign-in.

  • 200
  • 401
  • 403
DELETE /v1/me/sign-in-methods/{provider} unlinkMySignInMethod
  • no key needed

Remove a way to sign in — never the last one.

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf. A security change: needs a confirmation within WETALK_API_STEP_UP_MAX_AGE_S (POST /v1/session/step-up or POST /v1/me/mfa/challenge), else 401 step_up_required. 409 last_sign_in_method when it is the last usable way to sign in; 404 not_found when it is not set up.

Security review CR-1 (2026-10-06): when the person holds an active authenticator, this write needs an auth_session that has passed it (mfa_verified_at), else 401 assurance_required — answer POST /v1/me/mfa/challenge (the authenticator code or one recovery code), then retry. A password step-up does not stand in for it.

Parameters
Name In Required Description
provider path yes password, google or microsoft.
  • 204
  • 401
  • 403
  • 404
  • 409
POST /v1/me/sign-in-methods/{provider}/link linkMySignInMethod
  • no key needed

Link Google or Microsoft (not available yet).

Own identity only: the auth_session cookie, any organization or none selected. An operator acting as the person is refused 403 impersonation_forbidden (design §27.5.5). A write carries x-wetalk-csrf. Answers 404 not_found in this release: the relying-party sign-in routes are not served. Identities are never merged on an unverified email.

Security review CR-1 (2026-10-06): when the person holds an active authenticator, this write needs an auth_session that has passed it (mfa_verified_at), else 401 assurance_required — answer POST /v1/me/mfa/challenge (the authenticator code or one recovery code), then retry. A password step-up does not stand in for it.

Parameters
Name In Required Description
provider path yes password, google or microsoft.
  • 204
  • 401
  • 403
  • 404
POST /v1/operator-action-confirmations/confirm confirmOperatorAction
  • no key needed
  • heavy

Confirm a change WeTalk support prepared, with the link's token.

awaiting_approval: a second WeTalk operator decides next (within 7 days). approved: an operator applies it. The confirmation is written to the organization's audit log, the person as the actor.

Takes a JSON request body.

  • 200
  • 400
  • 404
  • 429
POST /v1/operator-action-confirmations/inspect inspectOperatorActionConfirmation
  • no key needed
  • heavy

What a confirmation link asks the person to confirm — read only.

Takes a JSON request body.

  • 200
  • 400
  • 404
  • 429
GET /v1/session getCurrentAuthSession
  • no key needed

Who is signed in, scoped to which account, and on whose behalf.

The console's source of truth for the signed-in person, the account scope, the member role and the impersonation banner (design §5.2, §8.7). Answers an unscoped auth_session too (account_id: null, with account_choice).

401 authentication_required when there is no cookie; 401 auth_session_expired — with the cookie cleared — when there is one that is expired, revoked or unknown. Which of those applied is deliberately not said.

AMENDED 2026-10-07 (plan-014 ACT, design §27.6.3): one scoped to a workspace or organization the person no longer belongs to is NOT signed out. It is rotated (a new cookie, a new scope generation) to the next allowed destination — another enterable workspace of the organization, else the organization alone for someone with an organization role there, else the picker — and the body carries notice: { code: "membership_revoked" }. An operator's impersonated auth_session keeps the old answer (its scope is fixed), a 401 auth_session_expired.

AMENDED 2026-10-07 (plan-014 FIX2): the tabs of a browser share the cookie, so a second tab's read that left before that move's answer came back carries the token just rotated away. For 30 seconds after such a move that token is answered 409 auth_session_superseded with the cookie left alone (never cleared): read the session again, with the cookie the browser now holds. Nothing is described to it.

The body carries the auth_session's csrf_token, the value every cookie-authenticated non-GET request must send as X-WeTalk-Csrf.

Plan-014 A2 (design §27.6.1), additive: context is the three-state authentication context (selection_required, account_only, workspace_selected) with its scope_generation, organization, workspace, capability listing and assurance facts; landing is where the console lands it; each account_choice entry also carries the organization's own fields. The flat account_id / account_name / workspace_id / member_role fields and each choice's account_name / member_role keep their meanings for one release (the compatibility window). Every cookie request to a scoped route must send X-WeTalk-Scope-Generation with context.scope_generation; a stale or absent one is 409 scope_changed.

  • 200
  • 401
  • 409
  • 429
POST /v1/session/account switchAuthSessionAccount
  • no key needed

Re-scope this auth_session to another organization, one of its workspaces, or the organization alone.

Design §15.4.4, §27.6.2. The organization must be one the person can enter (an account membership, workspace memberships, or both); the workspace is the one named. With scope_generation (the new contract) workspace_id: null means the organization ALONE — it needs an account role there (403 capability_missing otherwise) — and the generation must be the auth_session's current one (409 scope_changed otherwise, nothing rotated). Without it (the legacy contract, one release) workspace_id: null means the organization's first workspace membership, or the organization alone for a person with an account role and no workspace there. An unscoped auth_session (a person with several organizations who has not chosen) is a normal caller.

A re-scope is a privilege change, so the auth_session is rotated: a new token and a new cookie, and the old token stops working at once (design §8.1).

Requires X-WeTalk-Csrf matching this auth_session's token (401 csrf_token_invalid otherwise), checked before anything is looked up. 404 not_found for an account or workspace the person does not belong to — the same answer as for one that does not exist. 403 impersonation_scope_fixed when a WeTalk operator is acting as this person: a re-scope would leave the account the grant covers. 401 authentication_required without a cookie; 401 auth_session_expired or auth_session_revoked, with the cookie cleared, for one that no longer works.

AMENDED 2026-10-06 (plan-014 C2, FR12-SEC-06): the destination organization's sign-in policy is judged first — 401 assurance_required when it asks for an authenticator or single sign-on this auth_session has not proven (answer the challenge, then switch again), 403 network_not_allowed from outside its allowed networks; nothing is rotated either way.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
POST /v1/session/step-up stepUpAuthSession
  • no key needed
  • heavy

Confirm it is you — re-verify a factor so a sensitive change is allowed for a few minutes.

The person proves a factor they already hold, and this auth_session records the moment (stepped_up_at). Routes that change money details — the payout destination (PUT /v1/payouts/destination) — answer 401 step_up_required when that moment is missing or older than WETALK_API_STEP_UP_MAX_AGE_S (300 seconds in production); the console asks for the factor here and retries once.

factor is password, totp, recovery_code, single_sign_on or passkey. AMENDED 2026-10-06 (plan-014 C2): the authenticator confirms too — totp with its code, or one recovery_code (used once; a replayed code is 401 credential_invalid; the person lock-out is shared with sign-in) — which also records the sign-in's second step; a person without one gets 400 validation_failed naming factor. single_sign_on (a fresh Google or Microsoft sign-in) answers 403 step_up_unavailable until those sign-ins are connected. passkey answers 400 validation_failed. An auth_session with its organization alone open (no workspace) steps up too, audited at the organization. An auth_session in no organization answers 403 step_up_unavailable — except (plan-014 review I-18, 2026-10-06) a person in NO organization who holds NO authenticator, confirming their password so they can add their first one (POST /v1/me/mfa/totp), as the invitee of an organization that requires an authenticator must before accepting; recorded on the auth_session only (no organization's log exists).

Not a rotation: 204 with no cookie, and the CSRF token is unchanged. A later rotation (switching account, a role or password change) starts the new auth_session with no step-up.

Requires X-WeTalk-Csrf matching this auth_session's token (401 csrf_token_invalid otherwise), checked before anything is looked up. Refusals: 401 credential_invalid for a wrong password — the same answer while the person is locked out, as at sign-in, and each failure counts toward that lockout; 403 step_up_unavailable when a WeTalk operator is acting as this person (only the person can confirm it is them), when the auth_session has no organization yet, or when the person signs in only with Google or Microsoft and holds no password; 429 rate_limited when this person's attempts exceed the sign-in window; 401 authentication_required without a cookie; 401 auth_session_expired or auth_session_revoked, with the cookie cleared, for one that no longer works. Each success writes auth_session.stepped_up to the account's audit log, naming the factor's kind.

AMENDED 2026-10-06 (security review CR-1): password is refused 401 assurance_required while the person holds an active authenticator this auth_session has not passed — the password never stands in for the authenticator; confirm with totp or recovery_code, which records both the second step and the step-up.

Takes a JSON request body.

  • 204
  • 400
  • 401
  • 403
  • 429

notification

GET /v1/notifications listNotifications
  • signed-in person only

This workspace's notifications for the signed-in person, newest first.

Visible rows are this workspace's, addressed to everyone in it or to the signed-in person. A row is unread when it was created after the person's read marker for this workspace, or when the person has never marked anything read.

unread_count describes the whole visible set, not the page, so the badge does not change as somebody scrolls.

Session only. An API key is refused 403 permission_denied. A cursor naming a row this person cannot see — another account's, another workspace's, another person's, or none at all — is 400 cursor_invalid, the same answer for each.

Parameters
Name In Required Description
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/notifications/read-all readAllNotifications
  • signed-in person only

Mark every notification read for the signed-in person in this workspace.

Moves the person's read marker for this workspace to the later of where it was and now. Deletes nothing: a notification addressed to the whole workspace stays unread for everybody else. No body.

Writes no audit entry — moving your own read marker is neither a security act nor a data act (design §15.6.1, the one named exception to DD-8).

Session only; requires X-WeTalk-Csrf. An API key is refused 403 permission_denied.

  • 204
  • 401
  • 403
  • 429

outbound

GET /v1/agents/{voice_agent_id}/campaigns listCampaigns
  • campaign:write

A VoiceAgent's campaigns.

Newest first.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/agents/{voice_agent_id}/questionnaire getVoiceAgentQuestionnaire
  • campaign:write

The questions a campaign for this VoiceAgent asks.

Read from the VoiceAgent's live version (the document it was built from). When asks_questionnaire is true a campaign takes no question list; to change the questions, rebuild the VoiceAgent with a new document. A campaign pins no version, so a republish changes what its later calls ask.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/agents/{voice_agent_id}/survey-results getVoiceAgentSurveyResult
  • conversation:read

A survey VoiceAgent's results — totals by outcome and every question's answers.

Every conversation of the VoiceAgent counts towards the totals by its outcome (Completed, Partial, Declined, No answer, or none yet), including the ones that declined before any answer. Each question lists the answers recorded to it with its share of THAT question's own answered_count (basis points) and how many the interviewer was sure of. Emotion and source questions also count every option named (mention_count). Test conversations are left out unless include_is_test=true, and hidden_is_test_count says how many were left out. The language and campaign lists cover the whole window whichever is chosen. Requires conversation:read; every member role.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
started_from query no Conversations that started at or after this instant (RFC 3339).
started_before query no Conversations that started before this instant (RFC 3339); later than started_from.
include_is_test query no true adds test conversations, each marked is_test. Absent or false: real conversations only.
campaign_id query no Only the conversations this campaign placed.
language_code query no Only conversations in this language (BCP-47).
variant query no Only conversations whose answers are in this arm of the questionnaire; a conversation with no answer has no arm.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/agents/{voice_agent_id}/survey-results/exports createVoiceAgentSurveyExport
  • conversation:read
  • heavy

Write the survey's respondents and their answers to a CSV file and return a short-lived link.

Synchronous and bounded (design §15.5.5): the body carries the same filters as the results; a count over WETALK_API_EXPORT_MAX_ROW is refused 422 export_too_large with the count (never truncated); the audit entry survey_result.exported is written before the link is minted. One row per conversation, one column per question, recorded values as they are kept, the far end masked. Nothing is billed.

Requires conversation:read; owner or admin; X-WeTalk-Csrf on a cookie request. Accepts Idempotency-Key.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/agents/{voice_agent_id}/survey-results/respondents listVoiceAgentSurveyRespondents
  • conversation:read

The survey's conversations, newest first, each with its answers.

Filtered like the results, and by outcome. party_masked is the far end masked (+30***1391) for an owner, an admin or an API key and null for a member; the number is never returned whole. Requires conversation:read; owner, admin or member.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
started_from query no Conversations that started at or after this instant (RFC 3339).
started_before query no Conversations that started before this instant (RFC 3339); later than started_from.
include_is_test query no true adds test conversations, each marked is_test. Absent or false: real conversations only.
campaign_id query no Only the conversations this campaign placed.
language_code query no Only conversations in this language (BCP-47).
variant query no Only conversations whose answers are in this arm of the questionnaire; a conversation with no answer has no arm.
outcome query no Absent is every outcome, the same as all.
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/agents/{voice_agent_id}/survey-rounds listSurveyRounds
  • conversation:read

The survey rounds a VoiceAgent's campaigns took part in, newest first.

A round is listed when its campaign_id[] includes a campaign of this VoiceAgent. A round that has not started sorts first. Requires conversation:read; every member role.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/campaigns createCampaign
  • campaign:write

Create a campaign and queue its dialling.

Records the campaign as scheduled with its questions (the second set, when returning_person is different_questions, as question_set = 'returning'), stores the high end of the estimate band as its estimated cost, and queues its dialling as a campaign.dial message, deliverable from starts_at. Nobody is called by this request: the campaign dialler places the calls from starts_at, inside each person's own calling hours. Writes the audit entry campaign.created. The whole write is one db_transaction.

AMENDED 2026-10-09 (plan-015 CMP): the start (start_local_date, start_local_time) and the optional end (end_local_date) are read on each person's own clock. starts_at is computed — the earliest instant at which any time zone of the list reaches the start.

A campaign dials only through your own SIP trunk (plan-008): caller_id_phone_number_id must name a caller-ID number on a verified, enabled SIP trunk that is bound to voice_agent_id. Without one the campaign has no SIP trunk route and is not created. Voicemail answers are billed talk time, like any answered conversation.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
DELETE /v1/campaigns/{campaign_id} deleteCampaign
  • campaign:write

Delete a campaign that has called nobody.

Allowed only while nobody has been called: no attempt the dialler dialled (or is dialling) and no conversation naming the campaign (deletable on the campaign). Skips alone do not count. Removes the campaign, its questions and its skips; a queued dialler tick finds it gone and stops. Writes the audit entry campaign.deleted.

Parameters
Name In Required Description
campaign_id path yes The campaign. One in another workspace or account is 404 campaign_not_found.
  • 204
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/campaigns/{campaign_id} getCampaign
  • campaign:write

One campaign in full.

Parameters
Name In Required Description
campaign_id path yes The campaign. One in another workspace or account is 404 campaign_not_found.
  • 200
  • 401
  • 403
  • 404
  • 429
PATCH /v1/campaigns/{campaign_id} updateCampaign
  • campaign:write

Change a campaign before it starts.

plan-015 CMP (G17, owner answer 6). Allowed while the campaign is scheduled (or draft) and its earliest start (starts_at) is still ahead — editable on the campaign; once it has started the answer is 409 campaign_started: pause or end it instead. Name only the fields to change; the rest stay. The changed campaign is checked as a create is (the calling window, the wait between attempts, an end on or after the start, the organization's concurrency, the name rule and uniqueness). The estimate and starts_at are recomputed, the dialling is queued again at the new start, and the audit entry campaign.edited names the changed fields. Asking for what is already stored changes nothing and writes nothing.

Parameters
Name In Required Description
campaign_id path yes The campaign. One in another workspace or account is 404 campaign_not_found.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
POST /v1/campaigns/{campaign_id}/end endCampaign
  • campaign:write

End a campaign for good.

A scheduled, running or paused campaign becomes cancelled ("Ended"): the dialler places no further call and it can never be resumed. A conversation already in progress is not hung up; it runs to its end and its outcome is still settled. Outcomes, survey results and costs stay readable. Ending an ended campaign changes nothing. Writes the audit entry campaign.ended.

Parameters
Name In Required Description
campaign_id path yes The campaign. One in another workspace or account is 404 campaign_not_found.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429
PUT /v1/campaigns/{campaign_id}/paused setCampaignPaused
  • campaign:write

Pause or resume a campaign.

Writes campaign.paused or campaign.resumed. Resuming queues the dialling again (a campaign.dial message) for the campaign dialler, exactly as creation does, and needs the campaign's SIP trunk route to still hold: a trunk disabled or a number unbound since creation is refused with the reason, and nothing moves. Pausing stops new dials within one dialler tick and never ends a conversation in progress.

Parameters
Name In Required Description
campaign_id path yes The campaign. One in another workspace or account is 404 campaign_not_found.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/campaigns/{campaign_id}/skips listCampaignSkips
  • campaign:write

The people a campaign skipped, each with its reason (AC-31).

Newest first. total_count and count_by_reason describe every skip of the campaign, not the page; count_by_reason carries all six reasons, zero included. Empty until the dialler has run, because only the dialler records a skip.

Parameters
Name In Required Description
campaign_id path yes The campaign. One in another workspace or account is 404 campaign_not_found.
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/campaigns/estimate estimateCampaign
  • campaign:write

What a campaign would cost, as a band, and how many calling days it takes.

Pure: writes nothing. See CampaignEstimate for what the band assumes; the three assumed figures are provisional estimates and are returned beside it.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
GET /v1/contact-lists listContactLists
  • campaign:write

The contact lists in this workspace.

Newest first. list_count, person_count and suppressed_count describe the whole collection, not the page. person_count is the number of different phone numbers across the workspace's lists (a number on two lists is one person); suppressed_count is the account's active do-not-call entries.

Parameters
Name In Required Description
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 429
POST /v1/contact-lists createContactList
  • campaign:write

Save a contact list from a CSV or pasted numbers.

Re-reads source_text (an earlier inspection is never trusted) and writes one contact per readable row: the number normalised to E.164 (a national-format number read against the account's country) with its phone-number hash, the name, the language, the time zone and the personal_detail columns. Ignored columns are not stored in any contact. A number already on the account's do-not-call list for every VoiceAgent is marked suppressed; the dialler re-checks at dial time regardless. The original text is kept in the contact-list store until the list is deleted. The skipped rows (line and reason, no value) and assumed_time_zone are kept with the list. Writes the audit entry contact_list.created, which carries counts and never a number or a name. Every refusal's message is written for the person who chose the file and names what to change.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 409
  • 413
  • 422
  • 429
DELETE /v1/contact-lists/{contact_list_id} deleteContactList
  • campaign:write

Delete a contact list, its contacts and its stored copy.

Refused with 409 contact_list_in_use while any campaign references the list, whatever that campaign's state: a finished campaign keeps the list it called. The refusal names up to three of the campaigns. Deletes the people on the list and the stored copy of the file. Writes the audit entry contact_list.deleted. Does not remove anyone from the do-not-call list.

Parameters
Name In Required Description
contact_list_id path yes
  • 204
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/contact-lists/{contact_list_id} getContactList
  • campaign:write

One contact list, with the rows skipped when it was saved.

Parameters
Name In Required Description
contact_list_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
PATCH /v1/contact-lists/{contact_list_id} updateContactList
  • campaign:write

Rename a contact list or change its two rules.

Every field is optional and at least one is required. The people on the list do not change. Writes the audit entry contact_list.updated naming what changed.

Parameters
Name In Required Description
contact_list_id path yes

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/contact-lists/{contact_list_id}/contacts listContactListContacts
  • campaign:write

The people on one contact list.

In the order they were read from the source.

Parameters
Name In Required Description
contact_list_id path yes
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/contact-lists/{contact_list_id}/contacts addContactListContacts
  • campaign:write

Add people to a saved contact list, from a CSV or pasted numbers.

Owner or Admin (fix round R, CL #121). Reads source_text as a save does and writes one contact per readable row whose number is not already on the list (those are skipped as duplicate_number). Refused with 409 contact_list_in_use while any campaign references the list: a campaign keeps the list it called. The list's total may not pass WETALK_API_UPLOAD_MAX_ROW. The stored copy of the original file is kept; the addition's counts and skipped lines (no values) are recorded with the list. Writes the audit entry contact_list.contacts_added, with counts only.

Parameters
Name In Required Description
contact_list_id path yes
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 413
  • 422
  • 429
PUT /v1/contact-lists/{contact_list_id}/contacts replaceContactListContacts
  • campaign:write

Replace everyone on a saved contact list with the people in a new file.

Owner or Admin (fix round R, CL #121). Deletes the people on the list and writes the new source's readable rows, exactly as a new list would be saved; the name and the rules stay. The stored copy of the file becomes the new text, and the skipped rows recorded with the list are the new source's. Refused with 409 contact_list_in_use while any campaign references the list. Writes the audit entry contact_list.contacts_replaced, with counts only.

Parameters
Name In Required Description
contact_list_id path yes

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 409
  • 413
  • 422
  • 429
GET /v1/contact-lists/{contact_list_id}/export.csv exportContactListCsv
  • campaign:write
  • heavy

One contact list as a spreadsheet.

Owner or Admin (fix round R, CL #121). One row per person, in the order they were read: name, phone number (E.164), language, time zone, each personal-detail column the list keeps, and whether the number is on the do-not-call list. UTF-8 with a BOM and CRLF records; a field beginning =, +, - or @ is prefixed with a single quote so a spreadsheet never runs it as a formula. Writes the audit entry contact_list.exported, with the count only.

Parameters
Name In Required Description
contact_list_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/contact-lists/inspect inspectContactSource
  • campaign:write

What a CSV or a paste contains, before anything is saved.

The columns found, a proposed role for each and up to three sample values, the number of rows and how many would be saved, every row that would not become a contact with its line, name and reason, and every value that would be left empty. With column, under the member's mapping. Stores nothing.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 413
  • 422
  • 429
GET /v1/conversations/{conversation_id}/survey-answers getConversationSurveyAnswer
  • conversation:read

One survey conversation's answers, question by question.

In the questionnaire's order, each with the question as that conversation's own version words it in the language the answer was given in (text, null when the questionnaire has no such text). A conversation with no answers has an empty answer. Requires conversation:read; owner, admin or member.

Parameters
Name In Required Description
conversation_id path yes
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/survey-rounds/{survey_round_id}/exports createSurveyResultExport
  • conversation:read
  • heavy

Write the round's results to a CSV file and return a short-lived link to it.

Synchronous and bounded (design §15.5.5): the rows are read in one statement, a count over WETALK_API_EXPORT_MAX_ROW is refused 422 export_too_large with the count (never truncated), the audit entry survey_result.exported is written before the link is minted, and the link lives WETALK_API_EXPORT_READ_WINDOW_SECOND seconds. The file is UTF-8 with a BOM, one row per person, one column per question. Nothing is billed.

Requires conversation:read; owner or admin; X-WeTalk-Csrf on a cookie request. Accepts Idempotency-Key.

Parameters
Name In Required Description
survey_round_id path yes A survey round of the caller's workspace. Another workspace's round, another account's, or a value that is not a UUID is 404 survey_round_not_found, the same answer for each.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
GET /v1/survey-rounds/{survey_round_id}/respondents listSurveyRespondents
  • conversation:read

The people dialled in a round, with their latest answers, newest call first.

status filters the page and matched_count; called_count is the whole round. Absent status is the whole round, the same as all. Requires conversation:read; owner, admin or member — viewers are refused because every row carries a phone number. A cursor naming a row outside this round is 400 cursor_invalid.

Parameters
Name In Required Description
survey_round_id path yes A survey round of the caller's workspace. Another workspace's round, another account's, or a value that is not a UUID is 404 survey_round_not_found, the same answer for each.
status query no
limit query no Page size, 1–100. Defaults to 25.
cursor query no An opaque cursor from a previous response's next_cursor. Never an offset: the ledger and the conversation list are append-heavy, and an offset page recomputes its own start on every request, so a row inserted mid-paging produces a duplicate or a skip.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/survey-rounds/{survey_round_id}/result getSurveyResult
  • conversation:read

One round's figures, question by question.

Every share is divided by that question's own answered_count. Answers stay with the question they were recorded against, so a replaced question set shows both sets, each with its own counts. Open answers have no share; theme is empty in plan-002 (O-5) and quote holds the eight most recent. A quote's attribution is null for a member or a viewer. Requires conversation:read; every member role.

Parameters
Name In Required Description
survey_round_id path yes A survey round of the caller's workspace. Another workspace's round, another account's, or a value that is not a UUID is 404 survey_round_not_found, the same answer for each.
  • 200
  • 401
  • 403
  • 404
  • 429

outbound-conversations

POST /v1/agents/{voice_agent_id}/outbound-conversations startOutboundConversation
  • conversation:dial
  • heavy

Ring a number now, through your SIP trunk.

Your agent rings party_number from caller_id_phone_number_id — a number of one of your SIP trunks marked as caller ID and bound to this agent — and talks to whoever answers. The answer comes as soon as the ringing starts; follow the conversation with getOutboundConversation, or in History.

Needs the conversation:dial scope (a key) or a signed-in Owner, Admin or Member. Idempotency-Key is required: the same key never rings a number twice. At most 10 conversations a minute per account and 3 per key (429 rate_limited, with Retry-After). Refused, with nothing dialled and nothing charged, outside WeTalk's reviewed destinations (Greece at launch; never an emergency, short-code or premium-rate number), for a number on the do-not-call list or that asked not to be contacted, or when the balance cannot pay. ring_second defaults to 30 and is at most 80.

Parameters
Name In Required Description
voice_agent_id path yes The VoiceAgent this version belongs to.
Idempotency-Key header yes Required. Stored for 24 hours against the account, the route and the key: the same key with the same body returns the first answer, with a different body it is refused. After an answer of 503 agent_runtime_unavailable the key keeps giving that answer — look in History before starting the conversation again with a new key.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 402
  • 403
  • 404
  • 409
  • 422
  • 429
  • 503
GET /v1/outbound-conversations/{conversation_id} getOutboundConversation
  • conversation:dial

An outbound conversation's progress and outcome.

Whether it is still ringing, in progress or ended, how it ended, the billed seconds, and the codec it used. Needs the conversation:dial scope (a key; a conversation:read key reads the same conversation in History, getConversation) or any member role.

Parameters
Name In Required Description
conversation_id path yes An outbound conversation through one of your SIP trunks. Another workspace's is a 404.
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/outbound-conversations/{conversation_id}/end endOutboundConversation
  • conversation:dial

Hang up an outbound conversation.

Ends a conversation that is still ringing or in progress. Ending one that has ended is a 204. Needs the conversation:dial scope (a key) or a signed-in Owner, Admin or Member.

Parameters
Name In Required Description
conversation_id path yes An outbound conversation through one of your SIP trunks. Another workspace's is a 404.
  • 204
  • 400
  • 401
  • 403
  • 404
  • 429
  • 503

sip-trunks

GET /v1/sip-trunk-settings readSipTrunkSettings
  • agent:read

What to type into the provider's settings.

WeTalk's SIP host and address, its ports and media range, the destination countries a trunk may ring (only countries with reviewed emergency, short-code and premium-rate data), and whether inbound is on.

  • 200
  • 401
  • 403
  • 429
GET /v1/sip-trunks listSipTrunks
  • agent:read

The workspace's SIP trunks.

Every trunk of the workspace, oldest first, with its numbers.

  • 200
  • 401
  • 403
  • 429
POST /v1/sip-trunks createSipTrunk
  • agent:write

Connect a SIP trunk.

The password goes to WeTalk's SIP trunk vault and never to the database; the trunk and its numbers are saved together or not at all. A new trunk is unverified until a connection check or a test call through it proves it. An account holds at most 10 SIP trunks across its workspaces (deleted trunks do not count); past that the request is refused before anything is saved. An IP address as host must be a public one.

A body without the outbound half (host, port, auth_username, password, allowed_destination_country) makes an inbound-only trunk: at least one allowed_address, WeTalk's inbound sign-in details generated with it — never in this answer, which an Idempotency-Key replay would repeat; call createSipTrunkInboundAuth for them, shown once. Its first answered call verifies it.

Parameters
Name In Required Description
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 409
  • 422
  • 429
  • 502
DELETE /v1/sip-trunks/{sip_trunk_id} deleteSipTrunk
  • agent:write

Delete a trunk.

Its numbers are released, their connections to voice agents end, and both of its passwords are deleted from the vault. Refused while a running campaign presents one of its numbers or a conversation on it is in progress.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
  • 204
  • 401
  • 403
  • 404
  • 409
  • 429
GET /v1/sip-trunks/{sip_trunk_id} getSipTrunk
  • agent:read

One SIP trunk.

The trunk, its numbers, its readiness for inbound and its last activity.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
  • 200
  • 401
  • 403
  • 404
  • 429
PATCH /v1/sip-trunks/{sip_trunk_id} updateSipTrunk
  • agent:write

Change a trunk's settings.

Any setting except the password and the numbers, merged over the current ones and validated as a whole. A change of host, port, transport or username makes the trunk unverified. An inbound-only trunk may gain its whole outbound half, password included; the password goes to the vault first, then the trunk is saved (unverified), then the connection check runs — best effort, so a check refused by its cooldown leaves the trunk unverified until "Check connection".

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
POST /v1/sip-trunks/{sip_trunk_id}/check checkSipTrunk
  • agent:write
  • heavy

Check the connection to the provider.

WeTalk sends the provider an OPTIONS request (and at most one authenticated retry) from its own address and reports what happened. One check per trunk per cooldown and a daily number per account, both enforced before anything is sent; the refusal's Retry-After header says how long to wait.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
  • 503
POST /v1/sip-trunks/{sip_trunk_id}/disable disableSipTrunk
  • agent:write

Disable a trunk.

No conversation runs through a disabled trunk in either direction, and its numbers stop answering.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/sip-trunks/{sip_trunk_id}/enable enableSipTrunk
  • agent:write

Enable a disabled trunk.

The trunk returns unverified; a check or a test call verifies it again.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
GET /v1/sip-trunks/{sip_trunk_id}/events listSipTrunkEvents
  • agent:read

The trunk's recent SIP activity.

Newest first. Party numbers are masked to the country code and the last four digits.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
limit query no 1–100; 50 when absent.
before query no Only events strictly older than this ISO 8601 instant.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
DELETE /v1/sip-trunks/{sip_trunk_id}/inbound-auth deleteSipTrunkInboundAuth
  • agent:write

Turn inbound off for a trunk.

The inbound credentials are removed from the trunk and its inbound route is taken down, so the old password admits nothing. Turning inbound on again issues a new pair. Refused for an inbound-only trunk, whose sign-in details are half of how its provider is admitted.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
  • 204
  • 401
  • 403
  • 404
  • 422
  • 429
POST /v1/sip-trunks/{sip_trunk_id}/inbound-auth createSipTrunkInboundAuth
  • agent:write

Create WeTalk's inbound credentials.

WeTalk generates a username and a 32-character password the provider must present when it sends calls to WeTalk. The password is in this answer only (Cache-Control: no-store) and can never be read again; calling this again replaces both.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
  • 201
  • 400
  • 401
  • 403
  • 404
  • 429
  • 502
POST /v1/sip-trunks/{sip_trunk_id}/numbers addSipTrunkNumber
  • agent:write

Add a number to a trunk.

The number is saved unbound. A number already on any SIP trunk is refused.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 403
  • 404
  • 409
  • 422
  • 429
DELETE /v1/sip-trunks/{sip_trunk_id}/numbers/{phone_number_id} removeSipTrunkNumber
  • agent:write

Remove a number from a trunk.

The number is released and can be added again, by this account or another.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
phone_number_id path yes A number on this trunk.
  • 204
  • 401
  • 403
  • 404
  • 409
  • 429
PATCH /v1/sip-trunks/{sip_trunk_id}/numbers/{phone_number_id} updateSipTrunkNumber
  • agent:write

Allow or stop a number as caller ID.

Whether a voice agent bound to this number may present it on outbound conversations.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
phone_number_id path yes A number on this trunk.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
DELETE /v1/sip-trunks/{sip_trunk_id}/numbers/{phone_number_id}/binding unbindSipTrunkNumber
  • agent:write

Disconnect a number from its voice agent.

The number stays on the trunk, connected to nothing.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
phone_number_id path yes A number on this trunk.
  • 204
  • 401
  • 403
  • 404
  • 429
PUT /v1/sip-trunks/{sip_trunk_id}/numbers/{phone_number_id}/binding bindSipTrunkNumber
  • agent:write

Connect a number to a voice agent.

Inbound conversations to the number reach this voice agent. Connecting it to another voice agent replaces the first.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
phone_number_id path yes A number on this trunk.

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
PUT /v1/sip-trunks/{sip_trunk_id}/password replaceSipTrunkPassword
  • agent:write

Replace the outbound password.

Written to the vault as a new version; never readable. The trunk becomes unverified until a check or a test call accepts the new password.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.

Takes a JSON request body.

  • 204
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
  • 502
GET /v1/sip-trunks/{sip_trunk_id}/test-calls readSipTrunkTestCall
  • agent:read

A test call's progress.

The newest test conversation on the trunk number (the caller-ID number for agent_calls_me, the called number for i_call_the_agent) that started at or after since: its state, the voice agent that answered and the negotiated codec.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
phone_number_id query yes
since query yes An ISO 8601 instant — when the test was started.
  • 200
  • 400
  • 401
  • 403
  • 404
  • 429
POST /v1/sip-trunks/{sip_trunk_id}/test-calls createSipTrunkTestCall
  • agent:write
  • heavy

Place a test call through the trunk.

agent_calls_me — the voice agent rings the member's own phone through the trunk, from a caller-ID number bound to it; it may ring through an unverified trunk, and an answered call verifies it. i_call_the_agent — arms a trunk number: the member's next call to it is the test. A test call is billed like any other minute. Refused, with nothing dialled, outside the trunk's reviewed destinations, for a number on the do-not-call list, or when the balance cannot pay.

agent_calls_me rings a number, so it is a dial: an API key needs conversation:dial as well as agent:write (a signed-in Owner or Admin needs no scope), and it counts against the same limits as startOutboundConversation — 10 a minute per account and 3 a minute per API key — before anything else is judged, then against the account's daily outbound ceiling. It is also refused when the trunk's host resolves to an address that is not on the public internet.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
Idempotency-Key header no Accepted on every POST that spends money or starts a side effect. Stored for 24 hours against the account, the route and the key. Replaying the key with a *different* body is refused rather than answered from the store.

Takes a JSON request body.

  • 201
  • 400
  • 401
  • 402
  • 403
  • 404
  • 422
  • 429
  • 503
POST /v1/sip-trunks/{sip_trunk_id}/test-calls/{conversation_id}/end endSipTrunkTestCall
  • agent:write

Hang up an outbound test call.

Ends an agent_calls_me test call. One the member placed ends when they hang up (422). Ending an ended one is a 204.

Parameters
Name In Required Description
sip_trunk_id path yes The trunk. Another workspace's trunk is a 404, exactly like one that does not exist.
conversation_id path yes A test conversation that ran on this trunk.
  • 204
  • 400
  • 401
  • 403
  • 404
  • 422
  • 429
  • 503

sms

POST /v1/sms/delivery-report/{account_id}/{sms_message_id}/{token} receiveSmsDeliveryReport
  • no key needed

The SMS provider's delivery report for one message.

The provider does not sign its reports, so a report is authenticated by two things WeTalk controls: the request comes from the provider's published callback address (otherwise 403, and nothing is written), and the path carries a token minted for exactly this account and this message, compared constant-time. A token minted for another message does not verify.

Every request from the published address is recorded with its raw bytes and answered 200, a bad token included: that one is recorded invalid and changes no message. A valid report is handed to the worker, which moves the message forward only. Delivery state is information, never money.

The token is a secret: it never appears in a log line.

Parameters
Name In Required Description
account_id path yes
sms_message_id path yes
token path yes 43 characters of unpadded base64url. Any other shape is simply a bad token.
  • 200
  • 403

status

GET /v1/public/status readPublicStatus
  • no key needed

The public status page's figures — components, uptime, incidents, latency.

Unauthenticated. Everything the Status page shows, as states and figures; the page writes the words. Components are the configured closed set, in its order, with their current state and 90 daily uptime bars (UTC). Uptime is derived from the operator-published state history: a day's uptime is 1 − (major outage minutes + 0.3 × partial outage minutes) / 1440, floored to a permille. latency.measured is false when there is no measurement in the last 24 hours — the page says "not measured yet" — and latency.stale is true when the newest measurement is older than the configured freshness, so the Landing Live latency section is not shown. Never names a carrier, the model, a model route or the model backup. Cached briefly (Cache-Control: public, max-age=30).

  • 200
  • 429
GET /v1/public/status/feed.atom readPublicStatusFeed
  • no key needed

The published incidents as an Atom feed (the page's "RSS" link).

Unauthenticated. One entry per published update of an active incident and one per resolved incident of the last 90 days, newest first. Cached like the JSON.

  • 200
  • 429
POST /v1/public/status/latency-measurements ingestLatencyMeasurement
  • no key needed

An external probe's signed batch of latency measurements.

Unauthenticated; HMAC-signed. X-WeTalk-Probe-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>." followed by the raw body>, keyed with the platform secret status-probe-signing-key. Verified on the raw bytes, constant-time, before the body is parsed; a missing, malformed, wrong or stale (more than 300 seconds from the server's clock) signature answers 401 probe_signature_invalid and records nothing. Ingestion is idempotent by attempt_id: a repeated attempt is counted in duplicate, never an error. A measurement whose region is not configured refuses the whole batch (400); while no region is configured (no probe exists yet) every batch is refused.

Parameters
Name In Required Description
X-WeTalk-Probe-Signature header yes

Takes a JSON request body.

  • 200
  • 400
  • 401
  • 429
POST /v1/public/status/subscriptions requestStatusSubscription
  • no key needed

"Get updates by email" — step one of the double opt-in.

Unauthenticated. Records the address as pending with the consent text revision the visitor agreed to, and answers 202 the same way whether the address is new, already pending, already confirmed or was unsubscribed — the endpoint never says who is subscribed. A repeat records nothing new; for an address still pending it re-sends the confirmation at most once an hour (plan-014 review I-11), so the endpoint cannot be used to mail an address repeatedly. Only the address and, once confirmed, the consent time are stored; the links' tokens are stored as digests, and every unsubscribe link ever mailed keeps working until the address unsubscribes (I-10). consent_text_revision must be the revision GET /v1/public/status names under subscription; a stale page is refused 409 conflict. While status email is not sent (email_delivery_available false) or the consent text is not published, subscription.available is false and this route answers 503 configuration_missing — no address is stored (I-12).

Takes a JSON request body.

  • 202
  • 400
  • 409
  • 429
  • 503
POST /v1/public/status/subscriptions/confirmation confirmStatusSubscription
  • no key needed

Step two of the double opt-in — the token from the confirmation link.

Unauthenticated. The confirmation page posts the token from its address (a link scanner's GET never confirms anything). Idempotent: confirming twice answers confirmed again. An unknown, replaced or malformed token answers 404 not_found, identically; so does an unsubscribed address's old link.

Takes a JSON request body.

  • 200
  • 404
  • 429
POST /v1/public/status/subscriptions/unsubscription unsubscribeStatusSubscription
  • no key needed

The token from the unsubscribe link in every message.

Unauthenticated. Stops every status email to the address that holds the token, pending or confirmed. Idempotent. An unknown, replaced or malformed token answers 404 not_found.

Takes a JSON request body.

  • 200
  • 404
  • 429

subscription

GET /v1/billing-accounts/{billing_account_id}/subscription getBalanceSubscription
  • signed-in person only

The balance's running subscription — its state, offer, current period, scheduled changes and add-ons.

subscription is null when the balance runs on pay-as-you-go terms (no running subscription). A running subscription is pending_activation (confirmed, its payment not settled — nothing is granted until it is), active, past_due (a renewal is unpaid; the new period's allowance is not published) or suspended. scheduled_change lists what applies at the end of the current period (a downgrade, a cancellation, an add-on reduction) with the over-limit preview shown before it was confirmed.

Needs account.finance.read over the balance: Owner, Admin or Finance of the organization, or a live delegate of a dedicated balance — 403 capability_missing otherwise; a balance of another organization is 404 not_found. Signed-in people only (403 scope_insufficient to an API key).

Parameters
Name In Required Description
billing_account_id path yes The balance (billing account).
  • 200
  • 401
  • 403
  • 404
  • 429
POST /v1/billing-subscriptions/{billing_subscription_id}/withdraw-cancellation withdrawSubscriptionCancellation
  • signed-in person only

Keep the subscription — the cancellation scheduled for the end of the period is withdrawn.

Nothing is charged; the subscription renews at the end of its period as if the cancellation had never been asked (one future schedule, never two). Any quote made while the cancellation stood becomes stale.

Needs account.finance.manage over the subscription's balance — 403 capability_missing otherwise; a subscription of another organization is 404 not_found. 409 operation_state_conflict when no cancellation is scheduled or the subscription is no longer running. Signed-in people only. A cookie request carries X-WeTalk-Csrf.

Parameters
Name In Required Description
billing_subscription_id path yes The subscription.
  • 200
  • 401
  • 403
  • 404
  • 409
  • 429

workspace-home

GET /v1/workspace/onboarding getWorkspaceOnboarding
  • signed-in person only

The four first-run tasks for the signed-in person's workspace, each done or not.

Four tasks, always all four, always in this order: verify_email, create_voice_agent, connect_number, add_card. Each is derived from existing rows at read time and never stored:

- verify_email — the signed-in person's email address has been verified; - create_voice_agent — this workspace has at least one VoiceAgent, in any state; - connect_number — this workspace has at least one enabled voice channel binding; - add_card — this workspace's billing account has at least one card that has not been removed.

Session only: verify_email is a fact about a person. An API key holding agent:read passes the scope check and is then refused 403 permission_denied ("This operation needs a signed-in person.").

  • 200
  • 401
  • 403
  • 404
  • 429
GET /v1/workspace/usage-summary getWorkspaceUsageSummary
  • conversation:read

Today's figures for this workspace, cut in the workspace's own time zone.

"Today" is usage_day, the current date in the workspace's time_zone, never the server's. Conversation figures are this workspace's, except channel_unit_in_use, which counts the whole account's open conversations because the tier's channel_unit_ceiling is the account's.

billed_second_today and spend_today count voice conversations that ENDED today — the day close's own rule — whether day close has priced them yet or not, each exactly once. answered_first_time_basis_point is null, not 0, on a day with no inbound conversation: a share of nothing is not a share.

  • 200
  • 401
  • 403
  • 404
  • 429

Error codes

Every error body carries one of these in error.code. They are stable: a new condition gets a new code rather than changing the meaning of an old one.

  • configuration_missing
  • configuration_invalid
  • measured_constant_unset
  • measured_constant_provisional
  • validation_failed
  • malformed_e164
  • malformed_sip_uri
  • password_too_short
  • unsupported_media_type
  • payload_too_large
  • idempotency_key_reused_with_different_body
  • cursor_invalid
  • authentication_required
  • credential_invalid
  • auth_session_expired
  • auth_session_revoked
  • csrf_token_invalid
  • mfa_required
  • mfa_factor_invalid
  • sso_required_for_domain
  • api_key_revoked
  • permission_denied
  • scope_insufficient
  • member_role_insufficient
  • recording_access_denied
  • recording_operator_access_disabled
  • impersonation_forbidden
  • impersonation_expired
  • operator_read_only
  • break_glass_required
  • not_found
  • voice_agent_not_found
  • agent_version_not_found
  • conversation_not_found
  • channel_binding_not_found
  • customer_order_not_found
  • contact_list_not_found
  • conflict
  • live_version_seq_stale
  • slug_taken
  • address_already_bound
  • sso_domain_already_claimed
  • entra_tenant_already_mapped
  • do_not_call_entry_exists
  • coupon_already_redeemed
  • agent_version_not_ready
  • voice_agent_paused
  • account_suspended
  • language_unavailable
  • language_pack_incomplete
  • acknowledgement_set_unavailable
  • voice_does_not_speak_language
  • extraction_failed
  • build_failed
  • connection_operation_not_allowed
  • connection_secret_unresolvable
  • tenant_resolution_failed
  • campaign_window_closed
  • contact_suppressed
  • credit_exhausted
  • trial_exhausted
  • hard_stop_reached
  • budget_exceeded
  • payment_declined
  • payment_requires_action
  • payment_requires_reauth
  • payment_outcome_ambiguous
  • payment_method_not_found
  • currency_not_supported
  • rate_limited
  • upstream_failed
  • upstream_timed_out
  • model_pool_unavailable
  • speech_provider_failed
  • carrier_failed
  • payment_provider_failed
  • internal_error
  • audit_write_failed
  • data_integrity_out_of_range
  • email_taken
  • country_unsupported
  • time_zone_invalid
  • contact_list_in_use
  • contact_source_unavailable
  • contact_source_too_many_rows
  • campaign_not_found
  • campaign_state_conflict
  • campaign_calling_window_invalid
  • campaign_simultaneous_call_over_ceiling
  • survey_round_not_found
  • export_too_large
  • export_format_unavailable
  • do_not_call_entry_not_found
  • retention_option_invalid
  • person_data_request_term_invalid
  • optimisation_review_not_found
  • optimisation_finding_not_found
  • optimisation_review_state_conflict
  • optimisation_review_unavailable
  • return_path_invalid
  • step_up_required
  • totp_enrolment_not_started
  • impersonation_handoff_invalid
  • agent_runtime_unavailable
  • idempotency_in_progress
  • build_already_started
  • build_no_document
  • document_too_large
  • document_type_unsupported
  • document_count_exceeded
  • document_mismatch
  • upload_grant_expired
  • upload_grant_invalid
  • number_bound_elsewhere
  • number_not_held
  • voice_agent_not_pausable
  • voice_agent_not_paused
  • voice_agent_paused_by_operator
  • voice_agent_archive_refused
  • voice_agent_archived
  • rebuild_refused
  • build_job_not_found
  • build_in_progress
  • edit_unchanged
  • edit_needs_no_build
  • document_location_mismatch
  • all_lines_busy
  • webrtc_unavailable
  • widget_voice_disabled
  • widget_voice_unavailable
  • origin_not_allowed
  • proof_of_work_failed
  • origin_invalid
  • origin_list_empty
  • person_data_request_not_found
  • person_data_export_not_ready
  • person_data_export_expired
  • customer_order_payment_link_active
  • customer_order_already_paid
  • customer_order_not_paid_online
  • refund_exceeds_paid_amount
  • refund_decision_required
  • online_payment_unavailable
  • payment_link_not_found
  • payout_destination_invalid
  • payout_destination_unverified
  • step_up_unavailable
  • maker_checker_violation
  • safeguarding_shortfall
  • payout_run_open
  • nothing_to_pay_out
  • payout_state_conflict
  • general_live_off
  • regulatory_sign_off_missing
  • payout_run_unbuilt
  • payout_run_building
  • caller_id_not_bound
  • destination_refused
  • sip_trunk_unverified
  • sip_trunk_disabled
  • daily_ceiling_reached
  • sip_edge_unavailable
  • sip_trunk_in_use
  • sip_trunk_number_taken
  • sip_trunk_check_cooldown
  • sip_trunk_inbound_off
  • sip_trunk_limit_reached
  • voice_sample_unavailable
  • campaign_question_not_accepted
  • returning_person_unavailable
  • campaign_not_deletable
  • scope_required
  • scope_changed
  • capability_missing
  • assurance_required
  • network_not_allowed
  • impersonation_scope_fixed
  • revision_stale
  • quote_stale
  • quote_expired
  • quote_mismatch
  • operation_conflict
  • operation_state_conflict
  • payment_pending
  • entitlement_exhausted
  • concurrency_entitlement_reached
  • seat_limit_reached
  • resource_limit_reached
  • allocation_exceeds_grant
  • allocation_below_use
  • offer_not_sellable
  • commercial_policy_unset
  • custom_offer_policy_unset
  • retention_policy_unset
  • terms_revision_unset
  • tax_policy_unset
  • tax_rate_unset
  • account_closing
  • account_suspended_nonpayment
  • workspace_draining
  • workspace_archived
  • workspace_creation_forbidden
  • invitation_unavailable
  • invitation_identity_mismatch
  • email_unverified
  • last_owner_protected
  • owner_change_forbidden
  • transfer_exceeds_available
  • payment_method_in_use
  • mandate_unavailable
  • auto_reload_limit_reached
  • refund_not_refundable
  • legal_profile_required
  • tax_id_malformed
  • tax_id_verifier_unconfigured
  • document_not_issued
  • sign_in_policy_lockout
  • sign_in_policy_domain_unverified
  • last_sign_in_method
  • mfa_enrolment_required
  • recovery_code_invalid
  • sign_in_code_invalid
  • domain_check_unavailable
  • suppression_unavailable
  • share_unavailable
  • share_revoked
  • closure_blocked
  • terms_acceptance_required
  • operator_approval_required
  • operator_capability_missing
  • cost_price_backdate_refused
  • status_text_refused
  • probe_signature_invalid
  • billing_assignment_missing
  • issuer_unset
  • vat_rate_backdate_refused
  • domain_join_offer_unavailable
  • auth_session_superseded
  • channel_unit_limit_above_entitlement
  • name_taken
  • campaign_started
  • sip_trunk_inbound_only
  • forward_destination_refused
  • forward_unavailable
  • draft_base_changed
  • test_call_arm_not_found
  • survey_question_set_not_ready
  • agent_version_pinned