# Voice apps, PBX apps, clients and pricing

A voice app decides what happens on a call: fetch CoXML, send events to your call-control webhook, run a hosted
AI agent, or run a flow. PBX apps are ready-made IVRs, ring groups and voicemail boxes. Client tokens let the
browser and mobile voice SDKs make and take calls. Pricing shows what the project pays.

All paths are relative to `https://api.contactivity.io/v1/projects/{project_id}`. Authentication, errors, paging
and idempotency work as described in the [overview](/guides/overview.md). AI agent versions, tests and analytics are in
[ai-agents.md](/guides/ai-agents.md); flow versions in [flows.md](/guides/flows.md).

| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | `/voice-apps` | `apps:read` | List voice apps (paged; filter `kind`, comma-separated) |
| POST | `/voice-apps` | `apps:write` | Create a voice app |
| GET | `/voice-apps/{app_id}` | `apps:read` | Fetch a voice app |
| PATCH | `/voice-apps/{app_id}` | `apps:write` | Update a voice app |
| DELETE | `/voice-apps/{app_id}` | `apps:write` | Archive a voice app |
| GET | `/pbx-apps` | `pbx:read` | List PBX apps (paged) |
| POST | `/pbx-apps` | `pbx:write` | Create a PBX app |
| GET | `/pbx-apps/{app_id}` | `pbx:read` | Fetch a PBX app |
| PATCH | `/pbx-apps/{app_id}` | `pbx:write` | Update a PBX app |
| DELETE | `/pbx-apps/{app_id}` | `pbx:write` | Archive a PBX app |
| GET | `/pbx-apps/{app_id}/messages` | `pbx:read` | List voicemail messages (paged) |
| DELETE | `/pbx-apps/{app_id}/messages/{message_id}` | `pbx:write` | Archive a voicemail message |
| POST | `/clients/tokens` | `clients:write` | Create a client access token |
| DELETE | `/clients/{identity}/tokens` | `clients:write` | Revoke an identity's tokens |
| GET | `/clients/settings` | `clients:read` | Fetch client settings |
| PATCH | `/clients/settings` | `clients:write` | Update client settings |
| GET | `/push-credentials` | `clients:read` | List push credentials (paged) |
| POST | `/push-credentials` | `clients:write` | Create a push credential |
| DELETE | `/push-credentials/{credential_id}` | `clients:write` | Archive a push credential |
| GET | `/pricing` | `pricing:read` | The project's price list |
| GET | `/pricing/check` | `pricing:read` | Price one call |

PATCH requests change only the fields you send; the result is checked like a create. Request bodies refuse
unknown fields (`400 unknown_parameter`).

## Voice apps

### The voice app object

| Field | Type | Description |
|---|---|---|
| `id` | string | `app_…` (`AP…` for apps made through the compatibility API) |
| `kind` | string | `coxml`, `call_control`, `ai_agent` or `flow` |
| `name` | string | |
| `markup` | string | `coxml`, `plivo` or `auto` (detect) |
| `param_style` | string | `contactivity` or `twilio`: the callback dialect (see below) |
| `voice_url`, `voice_method` | string | Where CoXML is fetched for each call (`GET` or `POST`) |
| `fallback_url` | string or null | Fetched when `voice_url` fails |
| `status_callback` | string or null | Receives call status changes |
| `hangup_url` | string or null | Called when the call ends |
| `webhook_url` | string or null | Call-control events |
| `webhook_failover_url` | string or null | Tried when `webhook_url` fails |
| `agent` | object or null | `ai_agent` apps: the agent config, with every default filled in |
| `flow` | object or null | `flow` apps: the draft flow graph |
| `flow_version` | integer or null | `flow` apps: the published version that runs |
| `enabled` | boolean | A disabled app never runs: calls routed to it are rejected (logged in `/errors`, category `app`) and `POST /calls` with it returns `409 app_disabled` |
| `created_at` | timestamp | |

### Create or update a voice app

| Field | Type | Rules |
|---|---|---|
| `kind` | string | Required on create. Cannot be changed |
| `name` | string | Up to 100 characters |
| `enabled` | boolean | `true` (default) or `false` to disable the app |
| `markup` | string | `coxml` (default), `plivo` or `auto` |
| `param_style` | string | `contactivity` (default for new apps) or `twilio`. `contactivity`: call-control apps get the native event envelope ([events.md](/guides/events.md)) and CoXML requests carry snake_case params; `twilio`: Twilio/Telnyx-compatible names. An update keeps the stored value |
| `voice_url` | string | Required for `coxml` apps |
| `voice_method` | string | `GET` or `POST` (default) |
| `webhook_url` | string | Required for `call_control` apps |
| `fallback_url`, `status_callback`, `hangup_url`, `webhook_failover_url` | string | Optional; `""` clears |
| `agent` | object | Required for, and only allowed on, `ai_agent` apps. `instructions` is required; see the `AgentConfig` schema in the OpenAPI spec for every field |
| `flow` | object | Required for, and only allowed on, `flow` apps. Validated on save (see `POST /flows/validate`) |

Every URL must be a public `http(s)` URL.

Errors: `400 invalid_parameter` (`param` names the field, including agent and flow fields), `400 unknown_parameter`,
`400 invalid_json`, `404 not_found`. Deleting an app that a route or a PBX app uses returns `409 conflict`.

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/voice-apps \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"kind": "coxml", "name": "Main menu", "voice_url": "https://example.com/voice"}'

curl https://api.contactivity.io/v1/projects/$PROJECT_ID/voice-apps \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"kind": "ai_agent", "name": "Receptionist", "webhook_url": "https://example.com/agent-events",
       "agent": {"instructions": "You are the receptionist for Acme Dental.", "greeting": "Hi, how can I help?"}}'
```

## PBX apps

### The PBX app object

| Field | Type | Description |
|---|---|---|
| `id` | string | `pa_…` |
| `name` | string | |
| `kind` | string | `ivr`, `ring_group` or `voicemail` |
| `config` | object | The config of the kind, with defaults filled in |
| `created_at`, `updated_at` | timestamp | |

### Create or update a PBX app

`name` (1-100 characters) and `kind` are required on create; `kind` cannot be changed. A `config` you send
replaces the stored one whole. Unknown config fields are refused.

An **action** is `{"type", "target"}`. `type` is `extension` (an extension or SIP username of this project),
`trunk`, `number` (E.164, not blocked), `sip_uri`, `pbx_app`, `voice_app`, `voicemail` (a voicemail PBX app) or
`hangup` (empty target). Ring group members take only `extension`, `trunk`, `number` or `sip_uri`.

A **greeting** is `{"say"?, "voice"?, "play_url"?}`: text up to 1000 characters, a voice id (1-64 letters, digits,
`_`, `.`, `-`) and/or a public `https` audio URL.

| Kind | Config field | Rules |
|---|---|---|
| `ivr` | `greeting` | Greeting |
| | `timeout_s` | 1-30, default 5 |
| | `max_digits` | 1-6, default 1 |
| | `retries` | 0-5, default 2 |
| | `invalid_message` | Up to 1000 characters |
| | `options` | Up to 20 `{"digits", "action"}`; digits are 1-6 of `0-9*#`, unique, at most `max_digits` long |
| | `default_action` | Action |
| `ring_group` | `strategy` | Required: `simultaneous` or `sequential` |
| | `ring_timeout_s` | 5-120, default 25 |
| | `members` | Required: 1-20 member actions |
| | `no_answer_action` | Action |
| `voicemail` | `greeting` | Greeting |
| | `max_length_s` | 5-300, default 120 |
| | `email_to` | Up to 10 email addresses |

Errors: `400 invalid_parameter` (`param` names the field, e.g. `name`, `kind`, `strategy`, `options[0].digits`,
`members[1].target`), `400 unknown_parameter`, `400 invalid_json`, `404 not_found`. Deleting an app that a route or
another PBX app points at returns `409 conflict`.

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/pbx-apps \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"name": "Main IVR", "kind": "ivr", "config": {
        "greeting": {"say": "Press 1 for sales, 2 for support."},
        "options": [{"digits": "1", "action": {"type": "extension", "target": "101"}},
                    {"digits": "2", "action": {"type": "number", "target": "+15551234567"}}]}}'
```

### Voicemail messages

`GET /pbx-apps/{app_id}/messages` lists messages newest first: `id` (a recording id), `from`, `to`, `duration_secs`,
`created_at` and `download_url` (signed, valid 10 minutes, or null). `DELETE …/messages/{id}` archives
the message (the audio is kept; `?archived=true` lists archived messages).

## Clients (in-app calling)

### Access tokens

Mint a token on your server and hand it to the browser or mobile SDK. It signs in as `client:<identity>`, so calls
to `client:<identity>` ring it.

| Field | Type | Rules |
|---|---|---|
| `identity` | string | Required. 1-64 letters, digits, `_`, `.` or `-` |
| `ttl` | integer | 60-3600 seconds, default 3600 |
| `voice_app_id` | string | Voice app for calls the client places (default: client settings) |
| `push_credential_id` | string | Push credential the mobile SDK registers its push token with |

Response `201`: `{"token": "ctk1.…", "identity", "expires_at"}`. Errors: `400 invalid_parameter` (param
`identity`, `ttl`, `voice_app_id`, `push_credential_id`).

`DELETE /clients/{identity}/tokens` ends every unexpired token of the identity and returns `{"revoked": <count>}`.
Devices already registered stay until their registration is refreshed.

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/clients/tokens \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"identity": "alice", "ttl": 600}'
```

### Client settings

`GET`/`PATCH /clients/settings`: `ring_time` (10-180 seconds a call to a client rings) and `voice_app_id`
(default app for calls clients place; `""` clears it). Errors: `400 invalid_parameter` (param `ring_time`,
`voice_app_id`).

### Push credentials

Your APNs key or Firebase service account, used to wake the mobile SDK for incoming calls. Secrets are stored
encrypted and never returned.

| Field | Type | Rules |
|---|---|---|
| `kind` | string | Required: `apns` or `fcm` |
| `name` | string | Required. 1-100 characters |
| `team_id`, `key_id` | string | `apns`: 10 characters, A-Z and 0-9 |
| `bundle_id` | string | `apns`: your app's bundle identifier |
| `private_key` | string | `apns`: contents of the `.p8` key (PKCS#8 P-256) |
| `sandbox` | boolean | `apns`: development builds |
| `service_account` | object | `fcm`: the Firebase service-account JSON file |

The object returned is `{"id": "pcr_…", "kind", "name", "team_id", "key_id", "bundle_id", "project_id",
"sandbox", "created_at"}`. Errors: `400 invalid_parameter` (param `name`, `kind`, `private_key`,
`service_account`, or no param for a bad APNs id), `404 not_found` (delete). `DELETE` archives the credential: it is no longer used to wake devices.

## Pricing

`GET /pricing` returns the project's price list: `currency`, `billing_increment`, `regions` (voice rates for +1
numbers by region and direction), `international` (prefix rates; the longest matching prefix applies),
`custom_rates` (your organization's rates, which replace standard prices for matching prefixes), `number_fees`,
`number_rates` and `number_rates_source` (`standard` or `custom`), `blocked_prefixes`, `ai_rates_per_min` and
`ai`. Amounts are money objects (`{"amount": "0.009000", "currency": "USD"}`, 6 decimals); each rate has its own `increment` (e.g. `60/60`: per 60
seconds, rounded up).

`GET /pricing/check?to=+442071234567&direction=outbound&duration=120` prices one call:

| Query | Rules |
|---|---|
| `to` | Required. E.164 (a missing `+` is added) |
| `direction` | `outbound` (default) or `inbound` (a carrier calling your number `to`) |
| `duration` | 1-86400 seconds, default 60 |

The response has `to`, `direction`, `duration`, then either `blocked: true` (outbound to a blocked destination),
or `custom`, `custom_prefix`, `billing_increment`, `billed_seconds` and `available`. When `available` is true it
also has `rate_per_min` and `cost` as money objects. Errors: `400 invalid_parameter` (param `to`, `direction`,
`duration`).

```sh
curl "https://api.contactivity.io/v1/projects/$PROJECT_ID/pricing/check?to=%2B442071234567&duration=120" \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY"
```
