sdkStartVoiceCall Reference
This content is not available in your language yet.
sdkStartVoiceCall
Section titled “sdkStartVoiceCall”mutation StartVoiceCall($input: SdkStartVoiceCallInput!) { sdkStartVoiceCall(input: $input) { sessionId agentId livekitUrl livekitToken roomName expiresAt }}Headers: x-api-key: <YOUR_API_KEY>, x-huat-platform: customer (see Authentication). Call it from your server. The API key must never reach a browser or app.
It starts a session exactly as sdkStart does (workspace scoping, the template gate and usage metering all apply) and mints a LiveKit join token for one fresh room. Variables go in variables:
{ "input": { "agentId": 7, "clientId": "call-2026-10-19-0042", "context": { "source": "web-call", "plan": "trial" } }}Input: SdkStartVoiceCallInput
Section titled “Input: SdkStartVoiceCallInput”| Field | Type | Required | Notes |
|---|---|---|---|
agentId | Int! | Yes | An agent in the API key’s workspace; NOT_FOUND otherwise. Its published workflow answers every turn, so make it voice-safe. |
clientId | String | No | Your own identifier for the caller or the call, shown on the session. At most 255 characters. Generated when omitted. |
context | JSON | No | A flat per-call object, exposed to the workflow as ctx.session.context. Same rules as the context of sdkStart. Pass it through variables, not as an inline literal. |
engine | VoiceEngine | No | WORKFLOW (default): the agent’s published workflow answers every turn. LIVE: a speech-to-speech model answers from the agent’s instructions only (no workflow, no tools); the transcript is recorded after the call, and it is refused unless the deployment has the live engine enabled. Pilot, staging only. |
Result: SdkVoiceCallResult
Section titled “Result: SdkVoiceCallResult”| Field | Type | Notes |
|---|---|---|
sessionId | Int! | The session created for this call (the conversation record). |
agentId | Int! | The agent that will answer. |
livekitUrl | String! | The LiveKit server URL (wss://...) to connect the LiveKit client to. |
livekitToken | String! | The join token for the one room of this call. See security. |
roomName | String! | The name of the room created for this call. |
expiresAt | DateTime! | When the token stops being accepted for joining, about two minutes after the request. A call already joined continues. |
What the token allows
Section titled “What the token allows”- It can join exactly one room, the one for this call, and no other.
- The caller may publish audio and subscribe. There is no data channel and the caller cannot edit metadata.
- It asks LiveKit to dispatch Wetel’s voice worker into the room as soon as the caller joins.
- It is valid to join for about two minutes. Join immediately; mint a new call for a later attempt.
Errors
Section titled “Errors”| Situation | Code | Message |
|---|---|---|
| No API key header | UNAUTHENTICATED | Missing X-Api-Key header |
| Unknown or revoked key | UNAUTHENTICATED | Invalid API key |
| Voice not enabled on the environment | FORBIDDEN | Voice calls are not enabled on this environment |
| Platform header missing | FORBIDDEN | Platform is not specified! |
| Agent not in the key’s workspace | NOT_FOUND | the agent was not found |
| The API key’s daily call cap reached | INTERNAL_SERVER_ERROR with extensions.status 429 | Daily voice call limit reached for this API key; try again tomorrow (UTC) |
Limits
Section titled “Limits”- Per client address: 60 a minute by default, shared by every caller behind one server (that is, all of your calls from one server count together).
- Per workspace: 30 call starts a minute. The ceiling is counted per API process.
- Per API key per day: 1,000 call requests per UTC day by default (set per environment), counted across all servers. It resets at 00:00 UTC. Every request that passes the checks above counts, including one whose call then fails to start. Once reached, the mutation answers HTTP-429-style (
extensions.status429) with the messageDaily voice call limit reached for this API key; try again tomorrow (UTC): do not retry that one until the next UTC day. This is a ceiling against a runaway script, not a plan limit. - The over-limit response is an error with a message and may not carry a specific
extensions.code. Do not branch on a code: treat any error from this mutation as retryable after a short wait, with backoff, unless it is one of the codes above. - Usage is metered with the session (speech and model usage). A per-minute voice price will be published later.
Security: the token belongs to one caller
Section titled “Security: the token belongs to one caller”The join token’s contents are readable by the caller’s browser. It carries the credential the voice worker uses for that one session, the same kind of session credential sdkStart hands to the chat SDK. So:
- Treat a token as belonging to one caller. Never hand one caller’s token to another.
- Never log it, share it or paste it into a support ticket.
- Never put your API key in a page. Only your server calls this mutation.
- Authenticate your own caller before you mint a call, and rate-limit your own start endpoint.
A planned change replaces the readable credential with a one-time handle (see the roadmap).
Server example
Section titled “Server example”See the quickstart for a complete Node example and the browser example.
A curl equivalent:
curl -sS "$WETEL_API_URL" \ -H 'content-type: application/json' \ -H "x-api-key: $WETEL_API_KEY" \ -H 'x-huat-platform: customer' \ --data '{ "query": "mutation($input: SdkStartVoiceCallInput!){ sdkStartVoiceCall(input:$input){ sessionId livekitUrl roomName expiresAt } }", "variables": { "input": { "agentId": 7 } } }'(This example leaves out livekitToken on purpose, so it does not land in your shell history or logs.)
After the call
Section titled “After the call”A call is a session. Read its transcript with the sessions API. A call nobody joins is closed by the platform: about 10 minutes after the request (checked every 5 minutes, so within about 15 minutes) its session is set to EXPIRED, with no evaluation and no events. A call counts as joined once the caller’s first turn reached the session.
For a LIVE call the voice worker stores the transcript afterwards with sdkRecordVoiceTranscript, using the call’s own token. It is not meant to be called from a browser or with an API key: it appends the spoken lines (at most 200 lines, 60,000 characters) to the call’s session once, without running the agent.
startOutboundCall
Section titled “startOutboundCall”startOutboundCall makes one of your AI employees place a phone call. It uses the signed-in workspace owner’s login (a dashboard JWT), not an API key, and only the workspace owner may call it.
mutation StartOutboundCall($input: StartOutboundCallInput!) { startOutboundCall(input: $input) { callId status maxDurationSeconds }}{ "input": { "agentId": 7, "toNumber": "+60123456789", "selfTest": true, "maxDurationSeconds": 120 }}Input: StartOutboundCallInput
Section titled “Input: StartOutboundCallInput”| Field | Type | Required | Notes |
|---|---|---|---|
agentId | Int! | Yes | The AI employee that places the call. NOT_FOUND when it is not in your workspace. |
toNumber | String! | Yes | E.164: a plus and 8 to 15 digits. For a self-test it must equal the phone number on the owner’s own profile and start with an allowed prefix. |
selfTest | Boolean! | Yes | Must be true. false is refused with “not available yet”. |
maxDurationSeconds | Int | No | Hard maximum call length, 15 to 300. Default 120. The call is ended at this limit. |
engine | VoiceEngine | No | WORKFLOW (default) or LIVE, as for sdkStartVoiceCall. LIVE is refused unless the deployment has the live engine enabled. |
Allowed destination prefixes are +1 and +60 unless the workspace sets its own list.
Result: StartOutboundCallResult
Section titled “Result: StartOutboundCallResult”| Field | Type | Notes |
|---|---|---|
callId | Int! | Id of the outbound call record. |
status | String! | Always queued: the dial happens asynchronously a moment later. |
maxDurationSeconds | Int! | The maximum call length that applies. |
The mutation returns at once. The phone rings a moment later and the AI says one short opening sentence that identifies it as an AI assistant. The result never contains a join token or the phone number.
Errors
Section titled “Errors”| Situation | Code |
|---|---|
| Outbound calling is off for the environment or for the workspace, or the platform kill switch is on | FORBIDDEN |
| The workspace’s daily call or minute limit is reached | FORBIDDEN |
The destination prefix is not allowed, or toNumber is not your own profile number | FORBIDDEN |
| Caller is not the workspace owner | FORBIDDEN |
toNumber is not valid E.164, selfTest is false, the duration is outside 15 to 300, or LIVE is not enabled | BAD_REQUEST |
agentId is not in your workspace | NOT_FOUND |
Call states
Section titled “Call states”Each call record moves through these states: dialing, ringing, answered, then an end state: completed, voicemail, no_answer, busy, declined or failed. A watcher records completed with the call’s duration and end reason once an answered call ends; the voice worker detects voicemail, and a voicemail call is hung up. The mutation itself only ever reports queued. To read a call’s later states, use outboundCall (see below), which is on staging and ships to production with the next release.
Reading a call: outboundCall and outboundAvailability
Section titled “Reading a call: outboundCall and outboundAvailability”Both use the signed-in workspace owner’s login (a dashboard JWT) and are refused for anyone else. They are reads: they never place a call.
query OutboundAvailability { outboundAvailability { available reason phoneLast4 phoneCountry defaultDurationSeconds }}
query OutboundCall($id: Int!) { outboundCall(id: $id) { id status toCountry toNumberLast4 durationSeconds endReason sessionId }}outboundAvailability tells a screen whether the “Call my phone” self-test could start now, so a button can be hidden or disabled without placing a call. available is false with a plain reason: NOT_ENABLED, NO_PHONE, COUNTRY_NOT_ALLOWED, DAILY_LIMIT or UNAVAILABLE. NOT_ENABLED covers every kind of “off” together on purpose. It does not check the AI employee or whether the voice worker is online, and it does not verify that the profile number belongs to the owner. The usage limits are hidden unless the workspace has calls enabled, and the full number is never returned (only phoneLast4 and phoneCountry).
outboundCall(id) returns the state of one call in your workspace, using the callId from startOutboundCall. An id from another workspace answers not found. Poll about every 2 seconds while the status is queued, dialing or ringing, every 5 seconds while answered, and stop at an end state (completed, voicemail, no_answer, busy, declined, failed, cancelled or blocked). It returns only the last four digits and the country of the called number, never the number, its hash or a join token. endReason is a short machine code, not text to show as it is.
Privacy
Section titled “Privacy”The destination number is never stored in clear. Wetel keeps a per-workspace keyed hash of it, plus the last four digits and the country.