# SIP trunks, routes and project settings

SIP trunks connect your phones, PBX or SIP provider to Contactivity. Routes decide where a number's inbound calls
go. This page also covers the project's limits and the webhook public key.

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).

| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | `/trunks` | `trunks:read` | List trunks (paged) |
| POST | `/trunks` | `trunks:write` | Create a trunk |
| GET | `/trunks/{trunk_id}` | `trunks:read` | Fetch a trunk |
| PATCH | `/trunks/{trunk_id}` | `trunks:write` | Update a trunk |
| DELETE | `/trunks/{trunk_id}` | `trunks:write` | Archive a trunk |
| GET | `/trunks/{trunk_id}/credentials` | `trunks:read` | List SIP credentials (one page) |
| POST | `/trunks/{trunk_id}/credentials` | `trunks:write` | Create a SIP credential |
| PUT | `/trunks/{trunk_id}/credentials/order` | `trunks:write` | Set the ring order of SIP credentials |
| DELETE | `/trunks/{trunk_id}/credentials/{username}` | `trunks:write` | Archive a SIP credential |
| GET | `/trunks/{trunk_id}/ips` | `trunks:read` | List allowed IPs (one page) |
| POST | `/trunks/{trunk_id}/ips` | `trunks:write` | Allow an IP or range |
| DELETE | `/trunks/{trunk_id}/ips/{ip_id}` | `trunks:write` | Archive an allowed IP |
| GET | `/routes` | `routes:read` | List routes (paged; filter `type`) |
| POST | `/routes` | `routes:write` | Create a route |
| GET | `/routes/{route_id}` | `routes:read` | Fetch a route |
| PATCH | `/routes/{route_id}` | `routes:write` | Update a route |
| DELETE | `/routes/{route_id}` | `routes:write` | Archive a route |
| GET | `/limits` | any key | The project's limits and today's spend |
| GET | `/webhook-public-key` | any key | The Ed25519 key that signs webhooks |
| POST | `/webhook-public-key/rotate` | `events:write` | Rotate the project's signing key |

PATCH requests change only the fields you send; the result is checked like a create. Every write is recorded in
the project's audit log with the API key's id.

## Trunks

### The trunk object

| Field | Type | Description |
|---|---|---|
| `id` | string | `trk_…` |
| `name` | string | |
| `auth_type` | string | `register` (SIP credentials), `ip` (IP allowlist) or `host` (calls go to and come from fixed hosts) |
| `hosts` | string[] | `host[:port]` targets of a `host` trunk, in failover order. Empty for other trunks |
| `default_caller_id` | string or null | Caller ID used when a call from the trunk has none of the project's |
| `recording` | boolean | Record calls of this trunk |
| `ring_strategy` | string | How an inbound call rings a `register` trunk's devices (see [Ring strategy](#ring-strategy)) |
| `ring_secs` | integer | Seconds each device rings before the next one (not for `ring_all`) |
| `created_at` | timestamp | |

### Create or update a trunk

| Field | Type | Rules |
|---|---|---|
| `name` | string | Required on create. 1-100 characters, trimmed |
| `auth_type` | string | Required on create: `register`, `ip` or `host`. Cannot be changed |
| `hosts` | string[] | Required for, and only allowed on, `host` trunks. Each `host[:port]` must resolve to public addresses. An IP host that another trunk or a carrier already uses is refused |
| `default_caller_id` | string or null | A number of this project or a verified number. `""` or `null` clears it |
| `recording` | boolean | Default `false` |
| `ring_strategy` | string | `ring_all` (default), `sequential`, `round_robin`, `longest_idle` or `random`. Other trunks than `register` take only `ring_all` |
| `ring_secs` | integer | 5-120, default 20 |
| `cps_limit`, `session_limit` | | Refused. Calls per second and concurrent calls are account limits set by Contactivity (see `/limits`) |

Errors: `400 invalid_parameter` (param `name`, `auth_type`, `hosts`, `default_caller_id`, `ring_strategy`, `ring_secs` or `cps_limit`),
`400 invalid_request` (a host that is invalid, not public or already in use), `400 unknown_parameter`,
`400 invalid_json`, `404 not_found` (PATCH), `409 already_exists`.

Archiving a trunk (`DELETE`) also archives its SIP credentials and IPs: they stop authenticating and routing at
once. A trunk that an active route or PBX app uses is refused with `409 in_use`. An archived SIP credential's
username and an archived IP can be used again; `?archived=true` on the credentials and IPs lists shows archived
ones. An archived route stops routing: numbers pointing at it are unrouted in effect.

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/trunks \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"name": "Office PBX", "auth_type": "host", "hosts": ["203.0.113.10:5060"], "recording": true}'
```

### SIP credentials (`register` trunks)

Phones and PBXs register with a username and password. Only a digest hash of the password is stored.

| Field | Type | Rules |
|---|---|---|
| `username` | string | Required. 3-64 letters, digits, `_`, `.` or `-`. Unique on the platform |
| `password` | string | Required. 12-128 characters. Never returned |
| `position` | integer | 0-10000. Ring order; default after the trunk's last credential |

The response is `{"username", "domain", "trunk_id", "position"}`; `domain` is where the phone registers (your
organization's SIP domain). The list returns `{"username", "domain", "position"}` items in ring order, in one page
(`has_more` is always `false`).

Errors: `400 invalid_parameter` (param `username`, `password`, `position`), `400 invalid_request` (the trunk is not a
`register` trunk), `404 not_found`, `409 already_exists` (username taken). Deleting a phone that a PBX app rings
returns `409 in_use`.

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/trunks/$TRUNK_ID/credentials \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"username": "frontdesk-101", "password": "correct-horse-battery"}'
```

### Ring strategy

Each SIP credential of a `register` trunk is one device (all phones registered under it ring together). An inbound
call, or a PBX app dialing the trunk, rings them by the trunk's `ring_strategy`:

| Strategy | Rings |
|---|---|
| `ring_all` | Every device at once; the first to answer takes the call |
| `sequential` | One device at a time, for `ring_secs` each, in `position` order |
| `round_robin` | As `sequential`, starting with the device after the one that started the previous call |
| `longest_idle` | As `sequential`, the device whose last call was answered or ended longest ago first |
| `random` | As `sequential`, in a new random order each call |

Devices with no registration are skipped. The route's ring timeout still ends the call. A device that is busy or does
not answer passes the call to the next one; a decline (`603`) ends it. When none is left the caller gets `486`
(last one busy) or `480`.

`PUT /trunks/{trunk_id}/credentials/order` with `{"usernames": [...]}` sets the order: every active credential of the
trunk once, first to ring first. It returns the credentials in their new order. Errors: `400 invalid_parameter`
(param `usernames`), `400 invalid_request` (not a `register` trunk), `404 not_found`.

```sh
curl -X PUT https://api.contactivity.io/v1/projects/$PROJECT_ID/trunks/$TRUNK_ID/credentials/order \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" -H "Content-Type: application/json" \
  -d '{"usernames": ["frontdesk-101", "frontdesk-102"]}'
```

### Allowed IPs (`ip` trunks)

`POST {"cidr": "203.0.113.0/29"}` allows a public IP (`203.0.113.7`) or a network of at most /29 (IPv4) or /125
(IPv6); host bits are masked. A range that overlaps any trunk or carrier range is refused with
`409 already_exists`. Items are `{"id", "ip_addr", "mask"}` (`id` is a numeric string); the list is one page and
also shows the IP hosts of a `host` trunk.

Errors: `400 invalid_parameter` (param `cidr`), `400 invalid_request` (the trunk is not an `ip` trunk),
`404 not_found`, `409 already_exists`.

## Routes

A route sends inbound calls somewhere. Point a number at it with `PATCH /numbers/{e164}` (`route_id`).

### The route object

| Field | Type | Description |
|---|---|---|
| `id` | string | `rt_…` |
| `type` | string | `trunk`, `forward`, `sip_uri`, `app`, `coxml`, `call_control`, `ai_agent` or `flow` |
| `target` | string | Trunk id, E.164 number, SIP URI, PBX app id, or voice app id (by `type`) |
| `mode` | string | `priority` or `percentage` |
| `fallback_route_id` | string or null | Route tried when this one fails with one of `failover_codes` |
| `recording` | boolean | Record calls on this route |
| `failover_codes` | string | Comma-separated SIP codes |
| `ring_timeout` | integer | Seconds |
| `created_at` | timestamp | |

### Create or update a route

| Field | Type | Rules |
|---|---|---|
| `type` | string | Required on create. See above |
| `target` | string | Required on create. Must match `type`: a trunk, PBX app (`app`) or voice app of that kind of this project; an E.164 number that is not blocked (`forward`); a SIP URI to a public host (`sip_uri`) |
| `mode` | string | `priority` (default) or `percentage` (`forward` routes only) |
| `fallback_route_id` | string or null | Another route of this project. `""` or `null` clears it |
| `recording` | boolean | Default `false` |
| `failover_codes` | string | Default `408,500,502,503,504`. SIP codes 100-699, comma-separated |
| `ring_timeout` | integer | 5-600 seconds, default 30 |
| `scope` | | Refused (carrier routes only) |

Errors: `400 invalid_parameter` (param `type`, `target`, `mode`, `failover_codes`, `ring_timeout`,
`fallback_route_id`), `400 invalid_request` (percentage mode on a non-forward route, `scope` given, blocked
destination), `400 unknown_parameter`, `404 not_found` (PATCH).

Deleting a route unroutes the numbers that pointed at it and clears other routes' fallback to it.

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/routes \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d '{"type": "trunk", "target": "'$TRUNK_ID'", "ring_timeout": 25}'

curl "https://api.contactivity.io/v1/projects/$PROJECT_ID/routes?type=trunk&limit=20" \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY"
```

## Limits

`GET /limits` (any key of the project) returns:

| Field | Type | Description |
|---|---|---|
| `project_id` | string | |
| `status` | string | Project status |
| `kyc_approved` | boolean | |
| `cps_limit` | integer | New calls per second |
| `session_limit` | integer | Concurrent calls |
| `recording_default` | boolean | |
| `intl_enabled` | boolean | International calling allowed |
| `intl_prefixes` | string[] | Allowed international prefixes (empty: all) |
| `max_rate_per_min` | money or null | Calls to destinations above this price are refused |
| `daily_spend_limit` | money or null | Spend cap per UTC day |
| `spend_today` | money | Spend so far today (UTC) |

Limits are set by Contactivity; contact support to change them.

## Webhook public key

`GET /webhook-public-key` (any key of the project) returns:

```json
{"algorithm": "ed25519", "public_key": "<base64>", "previous_public_key": "<base64>" | null,
 "previous_expires_at": "2026-10-23T09:00:00.000Z" | null, "rotated_at": "2026-10-22T09:00:00.000Z" | null}
```

`public_key` verifies the `webhook-signature` header on every webhook, CoXML fetch and AI tool call. Until the
project rotates its key this is the platform key and `rotated_at` is null. The SDKs'
`verifyStandardWebhook` / `verify_standard_webhook` take this key.

`POST /webhook-public-key/rotate` (scope `events:write`; in the portal, an organization owner/admin or project
admin) makes a new key and returns the same body. The replaced key keeps signing for 24 h
(`previous_public_key` until `previous_expires_at`): during that time `webhook-signature` carries two
space-separated `v1a,` entries, the new key's first, so a receiver that trusts either key keeps working. Other
servers pick up a rotation within a minute; rotating again within 24 h drops the oldest key at once.

```sh
curl https://api.contactivity.io/v1/projects/$PROJECT_ID/webhook-public-key \
  -H "Authorization: Bearer $CONTACTIVITY_API_KEY"
```
