> For complete lifecycle-event payload schemas and examples, use the canonical overview: Session /api/sessions; Room lifecycle, participants, and room playback /api/rooms; Session playback /api/playback; Recording /api/recording; DTMF /api/keypad-input; Messages /api/messages; WebSocket Session /api/web-socket. Use /api/event-delivery for transport behavior. Endpoint pages name relevant events, but these overviews are the canonical references.

# Create a client token

POST https://api.vorbal.dev/v1/client_tokens
Content-Type: application/json

Create a token that lets one browser or SIP-over-WebSocket client connect to the platform as an `identity`. Use any SIP-over-WebSocket client that supports WebRTC, such as JsSIP or SIP.js: connect to `ws_url`, authenticate with `username` and `password`, and use `sip_uri` as the client's own address.

An identity can hold at most 2 live tokens, so a client can rotate a token before the old one expires. A third request returns `409`. Only one token per identity can be connected at a time, and an identity has at most one Session at a time.

Browser connections are accepted only from the network addresses configured for the application. If none are configured, this operation returns `403`.

The password is returned only by this response; keep it out of logs. Retrying with the same `Idempotency-Key` within 60 seconds replays the original response; the replay never outlives `expires_at`.

A token only gates new connections. A call already in progress on a token that was revoked, replaced, or has expired continues until it hangs up; end it with `DELETE /v1/sessions/{uuid}`.

Reference: https://docs.vorbal.dev/api/browser-calling/create

## Authentication

- `Authorization` header (bearer token, required) — Send `Authorization: Bearer <app_uuid>:<api_key>` using an active API key. See [Authentication](https://voice-platform.docs.buildwithfern.com/api/authentication) for details.

## Request

### Headers

- `Idempotency-Key` (string, optional) — Optional key for safely retrying mutating requests. An explicitly empty header is treated like omission and does not enable replay protection. See [Idempotency](https://voice-platform.docs.buildwithfern.com/idempotency) for details.

### Body (application/json)

This endpoint expects an object.

- `identity` (string, required) — Identity the token authenticates: 1 to 64 lowercase letters, digits, dots, underscores, or hyphens. It is not normalized; any other value returns `400`.
- `ttl_seconds` (integer, optional) — Token lifetime in seconds. Defaults to 3600; range 60 to 31536000.

## Response

### 201

Token created.

- `client_token_id` (string, required) — Token identifier. Use it to read or revoke the token.
- `created_at` (datetime, required) — When the token was created.
- `expires_at` (datetime, required) — When the token stops authenticating new connections.
- `ice_servers` (list of ClientTokenCreatedIceServersItems, required) — ICE servers for the browser's `RTCPeerConnection`, in `RTCIceServer` format. Currently always empty.
- `identity` (string, required) — Identity the token authenticates.
- `password` (string, required) — SIP digest authentication password. It is shown only once.
- `protocol` (enum, required) — Signaling protocol: SIP over secure WebSocket (RFC 7118).
  - Allowed values: `sip_wss`
- `sip_uri` (string, required) — The client's own SIP address, `sip:<identity>@<domain>`.
- `username` (string, required) — SIP digest authentication username. It equals `client_token_id`.
- `ws_url` (string, required) — Secure WebSocket URL to connect to.

## Errors

### 400 Bad Request Error

Missing `Authorization` or malformed Bearer credentials return `invalid_authorization_header`; invalid request parameters return `invalid_request`. A syntactically valid credential with an invalid API key returns `401 unauthorized` instead.

- `code` (enum, required) — Stable, machine-readable error class from a closed set. Branch on this for programmatic handling. New codes are only added with a documented API change. Maps to the HTTP status as follows: `invalid_request` (400/405/406/415/422), `invalid_authorization_header` (400), `payload_too_large` and `result_set_too_large` (413), `unauthorized` (401), `forbidden` (403), `not_found` (404), `recording_not_active` (400); `conflict`, `concurrent_recording_limit_reached`, `room_full`, and `room_not_empty` (409), `rate_limited` (429), and `unavailable` (503/529). Unexpected failures use the separate `InternalErrorResponse` schema. Several statuses map from more than one code. For `413`, `payload_too_large` means the request body exceeded its size cap, while `result_set_too_large` means a list response exceeded the overall result limit (narrow the query with filters and retry). `unavailable` maps to different retry conditions: `503` means a required store or service is temporarily unavailable, and `529` means the platform is overloaded. Use the HTTP status and operation description, not just `code`, to distinguish them.
  - Allowed values: `invalid_request`, `invalid_authorization_header`, `payload_too_large`, `result_set_too_large`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `concurrent_recording_limit_reached`, `recording_not_active`, `room_full`, `room_not_empty`, `rate_limited`, `unavailable`
- `message` (string, required) — Human-readable, English-only error message. For display only — phrasing may change without notice, so branch on `code`, not on this string.

### 401 Unauthorized Error

The Bearer credential is syntactically valid but its API key is invalid. Missing `Authorization` or malformed Bearer credentials return `400 invalid_authorization_header`.

- `code` (enum, required) — Stable, machine-readable error class from a closed set. Branch on this for programmatic handling. New codes are only added with a documented API change. Maps to the HTTP status as follows: `invalid_request` (400/405/406/415/422), `invalid_authorization_header` (400), `payload_too_large` and `result_set_too_large` (413), `unauthorized` (401), `forbidden` (403), `not_found` (404), `recording_not_active` (400); `conflict`, `concurrent_recording_limit_reached`, `room_full`, and `room_not_empty` (409), `rate_limited` (429), and `unavailable` (503/529). Unexpected failures use the separate `InternalErrorResponse` schema. Several statuses map from more than one code. For `413`, `payload_too_large` means the request body exceeded its size cap, while `result_set_too_large` means a list response exceeded the overall result limit (narrow the query with filters and retry). `unavailable` maps to different retry conditions: `503` means a required store or service is temporarily unavailable, and `529` means the platform is overloaded. Use the HTTP status and operation description, not just `code`, to distinguish them.
  - Allowed values: `invalid_request`, `invalid_authorization_header`, `payload_too_large`, `result_set_too_large`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `concurrent_recording_limit_reached`, `recording_not_active`, `room_full`, `room_not_empty`, `rate_limited`, `unavailable`
- `message` (string, required) — Human-readable, English-only error message. For display only — phrasing may change without notice, so branch on `code`, not on this string.

### 403 Forbidden Error

The caller is not permitted to perform this action — e.g. the caller-ID is not on the allow-list for `POST /v1/sessions:dial`, or a resource cap has been reached (the room cap on `POST /api/v1/rooms`).

- `code` (enum, required) — Stable, machine-readable error class from a closed set. Branch on this for programmatic handling. New codes are only added with a documented API change. Maps to the HTTP status as follows: `invalid_request` (400/405/406/415/422), `invalid_authorization_header` (400), `payload_too_large` and `result_set_too_large` (413), `unauthorized` (401), `forbidden` (403), `not_found` (404), `recording_not_active` (400); `conflict`, `concurrent_recording_limit_reached`, `room_full`, and `room_not_empty` (409), `rate_limited` (429), and `unavailable` (503/529). Unexpected failures use the separate `InternalErrorResponse` schema. Several statuses map from more than one code. For `413`, `payload_too_large` means the request body exceeded its size cap, while `result_set_too_large` means a list response exceeded the overall result limit (narrow the query with filters and retry). `unavailable` maps to different retry conditions: `503` means a required store or service is temporarily unavailable, and `529` means the platform is overloaded. Use the HTTP status and operation description, not just `code`, to distinguish them.
  - Allowed values: `invalid_request`, `invalid_authorization_header`, `payload_too_large`, `result_set_too_large`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `concurrent_recording_limit_reached`, `recording_not_active`, `room_full`, `room_not_empty`, `rate_limited`, `unavailable`
- `message` (string, required) — Human-readable, English-only error message. For display only — phrasing may change without notice, so branch on `code`, not on this string.

### 406 Not Acceptable Error

The public API returns JSON and does not expose a Server-Sent Events representation. Requests that explicitly negotiate `text/event-stream` without accepting JSON are rejected.

- `code` (enum, required) — Stable, machine-readable error class from a closed set. Branch on this for programmatic handling. New codes are only added with a documented API change. Maps to the HTTP status as follows: `invalid_request` (400/405/406/415/422), `invalid_authorization_header` (400), `payload_too_large` and `result_set_too_large` (413), `unauthorized` (401), `forbidden` (403), `not_found` (404), `recording_not_active` (400); `conflict`, `concurrent_recording_limit_reached`, `room_full`, and `room_not_empty` (409), `rate_limited` (429), and `unavailable` (503/529). Unexpected failures use the separate `InternalErrorResponse` schema. Several statuses map from more than one code. For `413`, `payload_too_large` means the request body exceeded its size cap, while `result_set_too_large` means a list response exceeded the overall result limit (narrow the query with filters and retry). `unavailable` maps to different retry conditions: `503` means a required store or service is temporarily unavailable, and `529` means the platform is overloaded. Use the HTTP status and operation description, not just `code`, to distinguish them.
  - Allowed values: `invalid_request`, `invalid_authorization_header`, `payload_too_large`, `result_set_too_large`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `concurrent_recording_limit_reached`, `recording_not_active`, `room_full`, `room_not_empty`, `rate_limited`, `unavailable`
- `message` (string, required) — Human-readable, English-only error message. For display only — phrasing may change without notice, so branch on `code`, not on this string.

### 409 Conflict Error

The request cannot be completed because the resource is in an incompatible state, such as a terminating session or a room or recording that cannot accept this command. For operations that accept `Idempotency-Key`, this status can also mean the original request is still in progress; wait for its result instead of sending a different request with the same key.

- `code` (enum, required) — Stable, machine-readable error class from a closed set. Branch on this for programmatic handling. New codes are only added with a documented API change. Maps to the HTTP status as follows: `invalid_request` (400/405/406/415/422), `invalid_authorization_header` (400), `payload_too_large` and `result_set_too_large` (413), `unauthorized` (401), `forbidden` (403), `not_found` (404), `recording_not_active` (400); `conflict`, `concurrent_recording_limit_reached`, `room_full`, and `room_not_empty` (409), `rate_limited` (429), and `unavailable` (503/529). Unexpected failures use the separate `InternalErrorResponse` schema. Several statuses map from more than one code. For `413`, `payload_too_large` means the request body exceeded its size cap, while `result_set_too_large` means a list response exceeded the overall result limit (narrow the query with filters and retry). `unavailable` maps to different retry conditions: `503` means a required store or service is temporarily unavailable, and `529` means the platform is overloaded. Use the HTTP status and operation description, not just `code`, to distinguish them.
  - Allowed values: `invalid_request`, `invalid_authorization_header`, `payload_too_large`, `result_set_too_large`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `concurrent_recording_limit_reached`, `recording_not_active`, `room_full`, `room_not_empty`, `rate_limited`, `unavailable`
- `message` (string, required) — Human-readable, English-only error message. For display only — phrasing may change without notice, so branch on `code`, not on this string.

### 413 Content Too Large Error

Request body exceeds the endpoint's size limit.

- `code` (enum, required) — Stable, machine-readable error class from a closed set. Branch on this for programmatic handling. New codes are only added with a documented API change. Maps to the HTTP status as follows: `invalid_request` (400/405/406/415/422), `invalid_authorization_header` (400), `payload_too_large` and `result_set_too_large` (413), `unauthorized` (401), `forbidden` (403), `not_found` (404), `recording_not_active` (400); `conflict`, `concurrent_recording_limit_reached`, `room_full`, and `room_not_empty` (409), `rate_limited` (429), and `unavailable` (503/529). Unexpected failures use the separate `InternalErrorResponse` schema. Several statuses map from more than one code. For `413`, `payload_too_large` means the request body exceeded its size cap, while `result_set_too_large` means a list response exceeded the overall result limit (narrow the query with filters and retry). `unavailable` maps to different retry conditions: `503` means a required store or service is temporarily unavailable, and `529` means the platform is overloaded. Use the HTTP status and operation description, not just `code`, to distinguish them.
  - Allowed values: `invalid_request`, `invalid_authorization_header`, `payload_too_large`, `result_set_too_large`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `concurrent_recording_limit_reached`, `recording_not_active`, `room_full`, `room_not_empty`, `rate_limited`, `unavailable`
- `message` (string, required) — Human-readable, English-only error message. For display only — phrasing may change without notice, so branch on `code`, not on this string.

### 415 Unsupported Media Type Error

Send JSON request bodies with `Content-Type: application/json`. The API rejects an explicitly supplied malformed or unsupported media type.

- `code` (enum, required) — Stable, machine-readable error class from a closed set. Branch on this for programmatic handling. New codes are only added with a documented API change. Maps to the HTTP status as follows: `invalid_request` (400/405/406/415/422), `invalid_authorization_header` (400), `payload_too_large` and `result_set_too_large` (413), `unauthorized` (401), `forbidden` (403), `not_found` (404), `recording_not_active` (400); `conflict`, `concurrent_recording_limit_reached`, `room_full`, and `room_not_empty` (409), `rate_limited` (429), and `unavailable` (503/529). Unexpected failures use the separate `InternalErrorResponse` schema. Several statuses map from more than one code. For `413`, `payload_too_large` means the request body exceeded its size cap, while `result_set_too_large` means a list response exceeded the overall result limit (narrow the query with filters and retry). `unavailable` maps to different retry conditions: `503` means a required store or service is temporarily unavailable, and `529` means the platform is overloaded. Use the HTTP status and operation description, not just `code`, to distinguish them.
  - Allowed values: `invalid_request`, `invalid_authorization_header`, `payload_too_large`, `result_set_too_large`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `concurrent_recording_limit_reached`, `recording_not_active`, `room_full`, `room_not_empty`, `rate_limited`, `unavailable`
- `message` (string, required) — Human-readable, English-only error message. For display only — phrasing may change without notice, so branch on `code`, not on this string.

### 422 Unprocessable Entity Error

The `Idempotency-Key` was reused with a different method, path, query, content type, body, or `Operation-UUID`. A key maps to one request; use a new key. See the Idempotency guide.

- `code` (enum, required) — Stable, machine-readable error class from a closed set. Branch on this for programmatic handling. New codes are only added with a documented API change. Maps to the HTTP status as follows: `invalid_request` (400/405/406/415/422), `invalid_authorization_header` (400), `payload_too_large` and `result_set_too_large` (413), `unauthorized` (401), `forbidden` (403), `not_found` (404), `recording_not_active` (400); `conflict`, `concurrent_recording_limit_reached`, `room_full`, and `room_not_empty` (409), `rate_limited` (429), and `unavailable` (503/529). Unexpected failures use the separate `InternalErrorResponse` schema. Several statuses map from more than one code. For `413`, `payload_too_large` means the request body exceeded its size cap, while `result_set_too_large` means a list response exceeded the overall result limit (narrow the query with filters and retry). `unavailable` maps to different retry conditions: `503` means a required store or service is temporarily unavailable, and `529` means the platform is overloaded. Use the HTTP status and operation description, not just `code`, to distinguish them.
  - Allowed values: `invalid_request`, `invalid_authorization_header`, `payload_too_large`, `result_set_too_large`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `concurrent_recording_limit_reached`, `recording_not_active`, `room_full`, `room_not_empty`, `rate_limited`, `unavailable`
- `message` (string, required) — Human-readable, English-only error message. For display only — phrasing may change without notice, so branch on `code`, not on this string.

### 500 Internal Server Error

An unexpected platform failure. Quote `support_id` to support; do not parse it. The technical cause and vendor event identifier are not exposed.

- `code` ("internal_error", required)
- `message` ("An internal error occurred", required)
- `support_id` (string, required) — Identifier for this error occurrence, starting with `err_`. Preserve the complete value and quote it when you contact support.

### 503 Service Unavailable Error

WebRTC is not available, or the idempotency store is unavailable. Retry later with the same `Idempotency-Key`.

- `code` (enum, required) — Stable, machine-readable error class from a closed set. Branch on this for programmatic handling. New codes are only added with a documented API change. Maps to the HTTP status as follows: `invalid_request` (400/405/406/415/422), `invalid_authorization_header` (400), `payload_too_large` and `result_set_too_large` (413), `unauthorized` (401), `forbidden` (403), `not_found` (404), `recording_not_active` (400); `conflict`, `concurrent_recording_limit_reached`, `room_full`, and `room_not_empty` (409), `rate_limited` (429), and `unavailable` (503/529). Unexpected failures use the separate `InternalErrorResponse` schema. Several statuses map from more than one code. For `413`, `payload_too_large` means the request body exceeded its size cap, while `result_set_too_large` means a list response exceeded the overall result limit (narrow the query with filters and retry). `unavailable` maps to different retry conditions: `503` means a required store or service is temporarily unavailable, and `529` means the platform is overloaded. Use the HTTP status and operation description, not just `code`, to distinguish them.
  - Allowed values: `invalid_request`, `invalid_authorization_header`, `payload_too_large`, `result_set_too_large`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `concurrent_recording_limit_reached`, `recording_not_active`, `room_full`, `room_not_empty`, `rate_limited`, `unavailable`
- `message` (string, required) — Human-readable, English-only error message. For display only — phrasing may change without notice, so branch on `code`, not on this string.

## Types

### ClientTokenCreatedIceServersItems

- `urls` (list of string, required)
- `credential` (string, optional)
- `username` (string, optional)

## Examples

**Request**

```json
{
  "identity": "agent-17",
  "ttl_seconds": 3600
}
```

**Response**

```json
{
  "client_token_id": "ct_0123456789abcdef0123456789abcdef",
  "created_at": "2026-06-29T10:30:00.000Z",
  "expires_at": "2026-06-29T11:30:00.000Z",
  "ice_servers": [],
  "identity": "agent-17",
  "password": "k3Jx9mQpV2tR8wYb5nLc7HdA",
  "protocol": "sip_wss",
  "sip_uri": "sip:agent-17@example.com",
  "username": "ct_0123456789abcdef0123456789abcdef",
  "ws_url": "wss://webrtc.example.com"
}
```

**SDK Code**

```python WebRTC_createClientToken_example
import requests

url = "https://api.vorbal.dev/v1/client_tokens"

payload = {
    "identity": "agent-17",
    "ttl_seconds": 3600
}
headers = {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

```javascript WebRTC_createClientToken_example
const url = 'https://api.vorbal.dev/v1/client_tokens';
const options = {
  method: 'POST',
  headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
  body: '{"identity":"agent-17","ttl_seconds":3600}'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

```go WebRTC_createClientToken_example
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://api.vorbal.dev/v1/client_tokens"

	payload := strings.NewReader("{\n  \"identity\": \"agent-17\",\n  \"ttl_seconds\": 3600\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("Authorization", "Bearer <token>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby WebRTC_createClientToken_example
require 'uri'
require 'net/http'

url = URI("https://api.vorbal.dev/v1/client_tokens")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n  \"identity\": \"agent-17\",\n  \"ttl_seconds\": 3600\n}"

response = http.request(request)
puts response.read_body
```

```java WebRTC_createClientToken_example
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://api.vorbal.dev/v1/client_tokens")
  .header("Authorization", "Bearer <token>")
  .header("Content-Type", "application/json")
  .body("{\n  \"identity\": \"agent-17\",\n  \"ttl_seconds\": 3600\n}")
  .asString();
```

```php WebRTC_createClientToken_example
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://api.vorbal.dev/v1/client_tokens', [
  'body' => '{
  "identity": "agent-17",
  "ttl_seconds": 3600
}',
  'headers' => [
    'Authorization' => 'Bearer <token>',
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();
```

```csharp WebRTC_createClientToken_example
using RestSharp;

var client = new RestClient("https://api.vorbal.dev/v1/client_tokens");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer <token>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"identity\": \"agent-17\",\n  \"ttl_seconds\": 3600\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift WebRTC_createClientToken_example
import Foundation

let headers = [
  "Authorization": "Bearer <token>",
  "Content-Type": "application/json"
]
let parameters = [
  "identity": "agent-17",
  "ttl_seconds": 3600
] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://api.vorbal.dev/v1/client_tokens")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```