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.
Agents and versions
GET /v1/agents listVoiceAgents
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.
POST /v1/agents createVoiceAgent
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.
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/agents/{voice_agent_id} getVoiceAgent
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.
PATCH /v1/agents/{voice_agent_id} updateVoiceAgent
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/agents/{voice_agent_id}/analytics getVoiceAgentAnalytics
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.
GET /v1/agents/{voice_agent_id}/answering getVoiceAgentAnswering
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.
GET /v1/agents/{voice_agent_id}/answers getVoiceAgentAnswers
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.
- 200
- 401
- 403
- 404
- 409
- 422
- 429
POST /v1/agents/{voice_agent_id}/archive archiveVoiceAgent
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).
- 200
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/agents/{voice_agent_id}/browser-test-calls createBrowserTestCall
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.
Takes a JSON request body.
- 201
- 400
- 401
- 402
- 403
- 404
- 422
- 429
- 503
POST /v1/agents/{voice_agent_id}/build startVoiceAgentBuild
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.
- 202
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/agents/{voice_agent_id}/channel-bindings listVoiceAgentChannelBindings
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.
GET /v1/agents/{voice_agent_id}/concurrency getVoiceAgentConcurrency
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.
PUT /v1/agents/{voice_agent_id}/concurrency setVoiceAgentChannelUnitLimit
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/agents/{voice_agent_id}/connection getVoiceAgentConnection
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".
GET /v1/agents/{voice_agent_id}/documents listVoiceAgentLiveDocuments
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 …").
POST /v1/agents/{voice_agent_id}/documents createAgentDocumentUpload
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.
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
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 410
- 422
- 429
POST /v1/agents/{voice_agent_id}/edit editVoiceAgent
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.
Takes a JSON request body.
- 201
- 202
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/agents/{voice_agent_id}/flags listConversationFlags
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.
POST /v1/agents/{voice_agent_id}/flags/{conversation_flag_id}/resolve resolveConversationFlag
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.
GET /v1/agents/{voice_agent_id}/gaps listGaps
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.
POST /v1/agents/{voice_agent_id}/gaps/{gap_id}/answer answerGap
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.
Takes a JSON request body.
POST /v1/agents/{voice_agent_id}/gaps/{gap_id}/dismiss dismissGap
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.
POST /v1/agents/{voice_agent_id}/gaps/publish publishAnswers
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/agents/{voice_agent_id}/numbers listVoiceAgentNumbers
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.
POST /v1/agents/{voice_agent_id}/optimisation-reviews createOptimisationReview
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.
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/agents/{voice_agent_id}/optimisation-reviews/quote getOptimisationReviewQuote
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.
POST /v1/agents/{voice_agent_id}/pause pauseVoiceAgent
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).
- 200
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/agents/{voice_agent_id}/rebuild rebuildVoiceAgent
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.
Takes a JSON request body.
- 201
- 202
- 400
- 401
- 403
- 404
- 409
- 422
- 429
POST /v1/agents/{voice_agent_id}/resume resumeVoiceAgent
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).
- 200
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/agents/{voice_agent_id}/sandbox-turns createSandboxTurn
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 422
- 429
- 503
GET /v1/agents/{voice_agent_id}/signalling listVoiceAgentSignallingEvents
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.
GET /v1/agents/{voice_agent_id}/sip getVoiceAgentSip
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.
POST /v1/agents/{voice_agent_id}/sip/rotation requestSipRotation
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.
GET /v1/agents/{voice_agent_id}/test-calls readTestCall
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.
POST /v1/agents/{voice_agent_id}/test-calls createTestCall
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.
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
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.
Takes a JSON request body.
- 204
- 400
- 401
- 403
- 404
- 429
- 503
POST /v1/agents/{voice_agent_id}/test-calls/arm/renew renewTestCallArm
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 429
- 503
GET /v1/agents/{voice_agent_id}/test-suites getVoiceAgentTestSuites
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.
GET /v1/agents/{voice_agent_id}/versions listAgentVersions
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.
GET /v1/agents/{voice_agent_id}/versions/{agent_version_id} getAgentVersion
One version, with the configuration document it was built into.
GET /v1/agents/{voice_agent_id}/versions/{agent_version_id}/documents listAgentVersionDocuments
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.
POST /v1/agents/{voice_agent_id}/versions/{agent_version_id}/publish publishAgentVersion
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/agents/{voice_agent_id}/versions/{agent_version_id}/restore restoreAgentVersion
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 429
GET /v1/builds/{build_job_id} getBuildJob
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.
GET /v1/connections listConnections
The systems one version of a VoiceAgent may call, with their allowlists.
POST /v1/connections createConnection
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.
Takes a JSON request body.
GET /v1/connections/{connection_id} getConnection
One connection and its allowlist.
POST /v1/connections/read-spec readConnectionSpec
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.
POST /v1/document-previews createDocumentPreviewUpload
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
One menu preview — poll it while it is being read.
POST /v1/document-previews/{document_preview_id}/confirm confirmDocumentPreview
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.
- 200
- 400
- 401
- 403
- 404
- 422
- 429
GET /v1/messaging/conversations listMessagingConversations
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).
GET /v1/messaging/media listMessagingMedia
The four media, and whether each can carry a conversation today.
GET /v1/messaging/media/{medium_code} getMessagingMedium
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.
POST /v1/messaging/media/{medium_code}/connection connectMessagingMedium
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.
Takes a JSON request body.
- 200
- 201
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/messaging/media/{medium_code}/disconnection disconnectMessagingMedium
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.
PATCH /v1/messaging/media/web_chat/widgets/{channel_binding_id} updateWebChatWidget
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 422
- 429
POST /v1/messaging/media/web_chat/widgets/{channel_binding_id}/disconnection disconnectWebChatWidget
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.
PUT /v1/messaging/media/web_chat/widgets/{channel_binding_id}/origins replaceWebChatWidgetOrigins
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 422
- 429
GET /v1/numbers listNumbers
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.
GET /v1/numbers/{phone_number_id}/activity getNumberActivity
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.
GET /v1/numbers/availability getNumberAvailability
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).
GET /v1/numbers/region-latency getRegionLatency
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.
GET /v1/numbers/requests listNumberRequests
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.
POST /v1/numbers/requests requestPhoneNumber
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.
Takes a JSON request body.
GET /v1/optimisation-reviews/{optimisation_review_id} getOptimisationReview
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.
PUT /v1/optimisation-reviews/{optimisation_review_id}/findings/{optimisation_finding_id}/decision decideOptimisationFinding
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/optimisation-reviews/{optimisation_review_id}/publish publishOptimisationReview
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
POST /v1/public/widget-voice/{widget_key}/grants createWidgetVoiceGrant
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.
Takes a JSON request body.
- 201
- 400
- 403
- 404
- 429
- 502
- 503
GET /v1/public/widget-voice/challenge issueWidgetVoiceChallenge
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).
GET /v1/sip listSipCredentials
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.
POST /v1/test-calls/{conversation_id}/end endTestCall
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.
- 200
- 400
- 401
- 403
- 404
- 422
- 429
- 503
GET /v1/test-calls/availability readTestCallAvailability
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.
GET /v1/workspace/shared-agent-configurations listSharedVoiceAgentConfigurations
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.
Billing
GET /v1/billing-accounts/{billing_account_id}/postpaid getPostpaid
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.
POST /v1/billing-accounts/{billing_account_id}/postpaid/pay payPostpaid
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.
- 202
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/billing-accounts/{billing_account_id}/refund-requests createRefundRequest
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.
- 200
- 202
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/billing-accounts/{billing_account_id}/refundable getRefundable
Unused purchased credit on a balance.
POST /v1/billing-operations/{billing_operation_id}/pay payOperation
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).
GET /v1/billing-operations/{billing_operation_id}/payment getOperationPayment
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.
GET /v1/billing/auto-reload getAutoReloadPolicy
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.
PUT /v1/billing/auto-reload putAutoReloadPolicy
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).
Takes a JSON request body.
GET /v1/billing/balance getBillingBalance
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.
GET /v1/billing/budget getBudget
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.
PUT /v1/billing/budget putBudget
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.
Takes a JSON request body.
GET /v1/billing/charge-attempts listChargeAttempts
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".
GET /v1/billing/funding getWorkspaceFunding
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.
GET /v1/billing/invoices listInvoices
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.
GET /v1/billing/ledger listLedgerEntries
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.
GET /v1/billing/payment-methods listBillingPaymentMethods
Saved cards — brand, expiry and the last four digits.
DELETE /v1/billing/payment-methods/{payment_method_id} deleteBillingPaymentMethod
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.
POST /v1/billing/payment-methods/{payment_method_id}/default setBillingDefaultPaymentMethod
Make this card the default.
POST /v1/billing/payment-methods/card-purchase completeCardPurchase
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.
Takes a JSON request body.
POST /v1/billing/payment-methods/hosted-form createHostedCardForm
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.
POST /v1/billing/top-ups createTopUp
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.
Takes a JSON request body.
GET /v1/billing/usage listBillingUsage
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.
POST /v1/billing/webhooks/everypay/new-payment receivePaymentWebhookNewPayment
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.
POST /v1/billing/webhooks/everypay/notification-expired receivePaymentWebhookNotificationExpired
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.
POST /v1/billing/webhooks/everypay/notification-paid receivePaymentWebhookNotificationPaid
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.
POST /v1/billing/webhooks/everypay/refund receivePaymentWebhookRefund
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.
GET /v1/online-payment/status readOnlinePaymentStatus
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.
GET /v1/payouts readPayoutStatement
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.
GET /v1/payouts/destination readPayoutDestination
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.
PUT /v1/payouts/destination setPayoutDestination
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
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.
GET /v1/quota-requests listQuotaRequests
This workspace's requests for more concurrent conversations, newest first.
POST /v1/quota-requests requestConcurrencyQuota
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.
Takes a JSON request body.
Workspaces
GET /v1/accounts/{account_id}/workspace-creation-policy getWorkspaceCreationPolicy
Who may create workspaces in this organization.
PUT /v1/accounts/{account_id}/workspace-creation-policy putWorkspaceCreationPolicy
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).
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 429
GET /v1/accounts/{account_id}/workspace-lifecycle listWorkspaceLifecycle
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.
POST /v1/accounts/{account_id}/workspaces createAccountWorkspace
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.
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 429
GET /v1/audit-entries listAuditEntries
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.
POST /v1/audit-entries/exports exportAuditEntries
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.
Takes a JSON request body.
GET /v1/workspaces listWorkspaces
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.
POST /v1/workspaces createWorkspace
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.
Takes a JSON request body.
DELETE /v1/workspaces/{workspace_id} deleteWorkspace
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.
GET /v1/workspaces/{workspace_id} getWorkspace
One workspace, with the VoiceAgents in it.
PATCH /v1/workspaces/{workspace_id} updateWorkspace
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/workspaces/{workspace_id}/access-policy getWorkspaceAccessPolicy
How people join this workspace.
PUT /v1/workspaces/{workspace_id}/access-policy putWorkspaceAccessPolicy
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
POST /v1/workspaces/{workspace_id}/archive archiveWorkspaceWithPreflight
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).
- 200
- 202
- 400
- 401
- 403
- 404
- 409
GET /v1/workspaces/{workspace_id}/archive-preflight getWorkspaceArchivePreflight
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.
POST /v1/workspaces/{workspace_id}/invitations inviteWorkspaceMember
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.
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 429
DELETE /v1/workspaces/{workspace_id}/invitations/{invitation_id} revokeWorkspaceInvitation
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.
GET /v1/workspaces/{workspace_id}/lifecycle-operations/{workspace_lifecycle_operation_id} getWorkspaceLifecycleOperation
An archive's or a restore's durable status.
POST /v1/workspaces/{workspace_id}/lifecycle-operations/{workspace_lifecycle_operation_id}/cancel cancelWorkspaceArchive
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.
GET /v1/workspaces/{workspace_id}/members listWorkspaceMembers
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.
DELETE /v1/workspaces/{workspace_id}/members/{membership_id} removeWorkspaceMember
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".
PATCH /v1/workspaces/{workspace_id}/members/{membership_id} setWorkspaceMemberRole
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".
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/workspaces/{workspace_id}/restore restoreWorkspace
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.
account
GET /v1/accounts listAccounts
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.
GET /v1/accounts/{account_id} getAccountProfile
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.
PATCH /v1/accounts/{account_id} updateAccountProfile
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).
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/accounts/{account_id}/allowed-networks createAccountAllowedNetwork
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).
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 429
DELETE /v1/accounts/{account_id}/allowed-networks/{account_allowed_network_id} deleteAccountAllowedNetwork
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).
GET /v1/accounts/{account_id}/audit-entries listAccountAuditEntries
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.
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.
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 429
POST /v1/accounts/{account_id}/billing-timezone scheduleAccountBillingTimezone
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).
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 429
GET /v1/accounts/{account_id}/closure-preflight getAccountClosurePreflight
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.
POST /v1/accounts/{account_id}/closures startAccountClosure
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.
Takes a JSON request body.
- 200
- 202
- 400
- 401
- 403
- 404
- 409
GET /v1/accounts/{account_id}/closures/{account_closure_operation_id} getAccountClosure
A closure's durable status.
POST /v1/accounts/{account_id}/closures/{account_closure_operation_id}/undo undoAccountClosure
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.
POST /v1/accounts/{account_id}/closures/{account_closure_operation_id}/withdraw withdrawAccountClosure
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.
GET /v1/accounts/{account_id}/invitations listAccountInvitations
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.
POST /v1/accounts/{account_id}/invitations createAccountInvitation
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).
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/accounts/{account_id}/invitations/{account_invitation_id}/resend resendAccountInvitation
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).
POST /v1/accounts/{account_id}/invitations/{account_invitation_id}/revoke revokeAccountInvitation
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).
GET /v1/accounts/{account_id}/members listAccountMembers
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.
DELETE /v1/accounts/{account_id}/members/{account_membership_id} removeAccountMember
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.
- 204
- 400
- 401
- 403
- 404
- 409
- 429
PATCH /v1/accounts/{account_id}/members/{account_membership_id} updateAccountMember
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/accounts/{account_id}/ownership-transfers transferAccountOwnership
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).
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 429
GET /v1/accounts/{account_id}/resource-shares listAccountResourceShares
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.
GET /v1/accounts/{account_id}/security getAccountSecurity
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.
PUT /v1/accounts/{account_id}/sign-in-policy updateAccountSignInPolicy
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).
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
POST /v1/accounts/{account_id}/sign-in-policy/preflight previewAccountSignInPolicy
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.
Takes a JSON request body.
GET /v1/accounts/{account_id}/terms-acceptance readAccountTerms
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.
POST /v1/accounts/{account_id}/terms-acceptance acceptAccountTerms
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.
Takes a JSON request body.
- 204
- 400
- 401
- 403
- 404
- 409
- 429
POST /v1/accounts/{account_id}/verified-domains createAccountVerifiedDomain
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).
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 429
DELETE /v1/accounts/{account_id}/verified-domains/{sso_domain_id} deleteAccountVerifiedDomain
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).
PUT /v1/accounts/{account_id}/verified-domains/{sso_domain_id}/auto-join updateAccountVerifiedDomainAutoJoin
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).
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).
- 200
- 401
- 403
- 404
- 409
- 429
- 503
POST /v1/accounts/{account_id}/workspace-access-grants grantAccountWorkspaceAccess
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).
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/me/account-closures listMyAccountClosures
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.
GET /v1/me/onboarding-draft getOnboardingDraft
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.
PUT /v1/me/onboarding-draft saveOnboardingDraft
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.
POST /v1/resource-shares/{resource_share_id}/accept acceptResourceShare
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).
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
POST /v1/resource-shares/{resource_share_id}/attach attachResourceShare
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
POST /v1/resource-shares/{resource_share_id}/revoke revokeResourceShare
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.
POST /v1/workspaces/{workspace_id}/resource-shares createResourceShare
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).
Takes a JSON request body.
- 200
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 429
compliance
GET /v1/compliance getCompliance
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.
DELETE /v1/compliance/consent-lines/{medium}/{language_code} deleteConsentLine
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.
- 200
- 400
- 401
- 403
- 409
- 422
- 429
PUT /v1/compliance/consent-lines/{medium}/{language_code} putConsentLine
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 409
- 422
- 429
PUT /v1/compliance/operator-access putOperatorAccess
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.
POST /v1/compliance/person-data-requests createPersonDataRequest
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.
Takes a JSON request body.
POST /v1/compliance/person-data-requests/{person_data_request_id}/download downloadPersonDataExport
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.
- 200
- 401
- 403
- 404
- 409
- 410
- 429
PUT /v1/compliance/retention-policies/{class} putRetentionPolicy
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.
Takes a JSON request body.
GET /v1/compliance/retention-policies/{class}/preview previewRetentionPolicy
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.
GET /v1/do-not-call-entries listDoNotCallEntries
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.
POST /v1/do-not-call-entries addDoNotCallEntry
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
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.
POST /v1/do-not-call-entries/exports exportDoNotCallEntries
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.
POST /v1/do-not-call-entries/import importDoNotCallEntries
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 413
- 422
- 429
POST /v1/do-not-call-entries/removals removeDoNotCallEntries
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.
GET /v1/do-not-call/opt-out-page getOptOutPage
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).
GET /v1/do-not-call/refusal-phrases listRefusalPhrases
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.
POST /v1/public/opt-out/{business_slug} createOptOutRequest
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.
Takes a JSON request body.
GET /v1/public/opt-out/challenge getOptOutChallenge
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.
identity
POST /v1/accounts createAccount
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.
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 409
- 422
- 429
- 500
POST /v1/auth/discover discoverIdentity
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.
POST /v1/auth/email-verification verifyPersonEmail
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.
POST /v1/auth/email-verification/resend resendPersonEmailVerification
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.
POST /v1/auth/impersonation-handoff redeemImpersonationHandoff
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.
POST /v1/auth/invitation/accept acceptInvitation
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
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.
POST /v1/auth/login logInPerson
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.
POST /v1/auth/logout logOutPerson
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.
GET /v1/auth/sign-in-code readSignInCodeAvailability
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.
POST /v1/auth/sign-in-code requestSignInCode
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.
POST /v1/auth/sign-in-code/verify verifySignInCode
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.
POST /v1/auth/signup signUpPerson
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
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.
POST /v1/invitations/inspect inspectAccountInvitation
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.
GET /v1/me/auth-sessions listMyAuthSessions
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).
DELETE /v1/me/auth-sessions/{auth_session_id} revokeMyAuthSession
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.
POST /v1/me/auth-sessions/sign-out-others signOutMyOtherAuthSessions
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.
GET /v1/me/domain-join-offers listDomainJoinOffers
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.
POST /v1/me/domain-join-offers/{offer_id}/accept acceptDomainJoinOffer
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.
POST /v1/me/email-changes requestMyEmailChange
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.
POST /v1/me/email-changes/confirm confirmMyEmailChange
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.
POST /v1/me/mfa/challenge answerMyMfaChallenge
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.
POST /v1/me/mfa/recovery-codes regenerateMyRecoveryCodes
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.
POST /v1/me/mfa/totp startMyTotpEnrolment
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.
POST /v1/me/mfa/totp/confirm confirmMyTotpEnrolment
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.
POST /v1/me/password changeMyPassword
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.
GET /v1/me/profile readMyProfile
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).
PATCH /v1/me/profile updateMyProfile
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.
GET /v1/me/sign-in-methods listMySignInMethods
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.
DELETE /v1/me/sign-in-methods/{provider} unlinkMySignInMethod
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.
POST /v1/me/sign-in-methods/{provider}/link linkMySignInMethod
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.
POST /v1/operator-action-confirmations/confirm confirmOperatorAction
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.
POST /v1/operator-action-confirmations/inspect inspectOperatorActionConfirmation
What a confirmation link asks the person to confirm — read only.
Takes a JSON request body.
GET /v1/session getCurrentAuthSession
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.
POST /v1/session/account switchAuthSessionAccount
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
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.
outbound
GET /v1/agents/{voice_agent_id}/campaigns listCampaigns
A VoiceAgent's campaigns.
Newest first.
GET /v1/agents/{voice_agent_id}/questionnaire getVoiceAgentQuestionnaire
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.
GET /v1/agents/{voice_agent_id}/survey-results getVoiceAgentSurveyResult
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.
POST /v1/agents/{voice_agent_id}/survey-results/exports createVoiceAgentSurveyExport
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.
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/agents/{voice_agent_id}/survey-results/respondents listVoiceAgentSurveyRespondents
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.
GET /v1/agents/{voice_agent_id}/survey-rounds listSurveyRounds
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.
POST /v1/campaigns createCampaign
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.
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 429
DELETE /v1/campaigns/{campaign_id} deleteCampaign
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.
GET /v1/campaigns/{campaign_id} getCampaign
One campaign in full.
PATCH /v1/campaigns/{campaign_id} updateCampaign
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
POST /v1/campaigns/{campaign_id}/end endCampaign
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.
PUT /v1/campaigns/{campaign_id}/paused setCampaignPaused
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/campaigns/{campaign_id}/skips listCampaignSkips
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.
POST /v1/campaigns/estimate estimateCampaign
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
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.
POST /v1/contact-lists createContactList
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.
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 409
- 413
- 422
- 429
DELETE /v1/contact-lists/{contact_list_id} deleteContactList
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.
GET /v1/contact-lists/{contact_list_id} getContactList
One contact list, with the rows skipped when it was saved.
PATCH /v1/contact-lists/{contact_list_id} updateContactList
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 429
GET /v1/contact-lists/{contact_list_id}/contacts listContactListContacts
The people on one contact list.
In the order they were read from the source.
POST /v1/contact-lists/{contact_list_id}/contacts addContactListContacts
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 413
- 422
- 429
PUT /v1/contact-lists/{contact_list_id}/contacts replaceContactListContacts
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.
Takes a JSON request body.
- 200
- 400
- 401
- 403
- 404
- 409
- 413
- 422
- 429
GET /v1/contact-lists/{contact_list_id}/export.csv exportContactListCsv
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.
POST /v1/contact-lists/inspect inspectContactSource
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
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.
POST /v1/survey-rounds/{survey_round_id}/exports createSurveyResultExport
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.
Takes a JSON request body.
- 201
- 400
- 401
- 403
- 404
- 409
- 422
- 429
GET /v1/survey-rounds/{survey_round_id}/respondents listSurveyRespondents
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.
GET /v1/survey-rounds/{survey_round_id}/result getSurveyResult
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.