# Voice calls

Route inbound calls to a phone, a SIP address or an AI agent, add voicemail, business hours and an IVR menu, and place, transfer and end outbound calls.

Every Pozy number takes and makes regular phone calls as soon as it is active. Inbound calls go wherever you point them: a mobile, a SIP endpoint, or an [AI voice agent](https://pozy.ai/docs/telephony/ai-voice-agents). Outbound calls dial from your number with one request. Every call, in either direction, lands in one [call history](https://pozy.ai/docs/telephony/call-recordings).

Calls are billed per connected minute by destination. Unanswered, busy and failed calls are not charged.

> **Note:**
> 
> These are phone network calls. Voice calls inside a WhatsApp chat are a separate feature: see [WhatsApp calling](https://pozy.ai/docs/whatsapp/calling). Both appear in the same history.

## Route inbound calls

All inbound settings live on one idempotent endpoint, `POST /v1/phone-numbers/{id}/voice`. It writes only the fields you send, so you can call it again later to change one setting.

```bash cURL
curl -X POST https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/voice \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "forwardTo": "tel:+13125550188",
    "forwardCallerId": "caller",
    "voicemailEnabled": true,
    "voicemailGreeting": "You have reached Acme. Leave a message and we will call you back."
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/voice", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    forwardTo: "tel:+13125550188",
    forwardCallerId: "caller",
    voicemailEnabled: true,
    voicemailGreeting: "You have reached Acme. Leave a message and we will call you back.",
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.enabled, data.pstnForwardTo);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/voice",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "forwardTo": "tel:+13125550188",
        "forwardCallerId": "caller",
        "voicemailEnabled": True,
        "voicemailGreeting": "You have reached Acme. Leave a message and we will call you back.",
    },
    timeout=30,
)
print(resp.json())
```

The response is the full effective configuration (`pstnForwardTo`, voicemail, hours, IVR, blocklist, recording settings).

### Destinations

| `forwardTo` | Sends the call to |
| --- | --- |
| `tel:+13125550188` | A phone number |
| `sip:agent@pbx.example.com` | A SIP endpoint or a hosted voice agent |
| `wss://agent.example.com/media` | Your own media server, for a custom AI agent |

Leave `forwardTo` out to keep the current destination. An empty string clears it, which leaves the number able to call out but not to ring anywhere on inbound.

### Inbound features

| Feature | Fields | Behaviour |
| --- | --- | --- |
| Voicemail | `voicemailEnabled`, `voicemailGreeting` | On by default. Catches unanswered calls (about 25 seconds of ringing), failed bridges and after-hours calls. Records up to 2 minutes. |
| Business hours | `businessHoursEnabled`, `businessHoursTimezone`, `businessHours` | Rings only inside the windows. Outside them the call goes to voicemail. |
| IVR menu | `ivrEnabled`, `ivrPrompt`, `ivrOptions` | Plays a menu and routes by keypress. Replaces the plain forward. |
| Blocklist | `blockedCallers` | Rejects listed numbers with a busy signal. Each write replaces the whole list. |
| Caller ID | `forwardCallerId` | Show your number (`business`, default) or the original caller (`caller`) on the forwarded leg |
| Recording | `recordingEnabled` | Off by default. A consent notice plays first. |
| Transcription | `transcriptionEnabled`, `transcriptionLanguage` | Off by default. Language `auto`, `en` or `es`. |

Business hours and the IVR menu take structured values:

```json
{
  "businessHoursEnabled": true,
  "businessHoursTimezone": "Europe/Madrid",
  "businessHours": [
    { "day": 1, "open": "09:00", "close": "18:00" },
    { "day": 2, "open": "09:00", "close": "18:00" },
    { "day": 6, "open": "10:00", "close": "14:00" }
  ],
  "ivrEnabled": true,
  "ivrPrompt": "Press 1 for sales, 2 for support, 3 to talk to our assistant.",
  "ivrOptions": [
    { "digit": "1", "forwardTo": "tel:+13125550188", "label": "Sales" },
    { "digit": "2", "forwardTo": "sip:support@pbx.example.com", "label": "Support" },
    { "digit": "3", "forwardTo": "wss://agent.example.com/media", "label": "Assistant" }
  ]
}
```

- A window has `day` (0 is Sunday through 6), `open` and `close` as `HH:MM` in the timezone. A day with no window is closed. A window whose `close` is before its `open` runs past midnight. Up to 21 windows.
- A menu option has a `digit` (`0` to `9`, `*` or `#`), its own `forwardTo` and an optional `label`. Up to 12 options. No input or an unknown digit ends the call.

### The order an inbound call follows

1. **Blocklist.** A blocked caller gets a busy signal before the call is answered: no charge, no voicemail, no webhook.
2. **`call.received` fires.**
3. **Business hours.** Outside the windows, the call goes straight to voicemail (or hangs up if voicemail is off).
4. **IVR menu**, if on. The digit picks a destination.
5. **Ring the destination.** If nobody answers or the bridge fails, voicemail takes the call.
6. **`call.ended`** (or `call.failed` on a hard error).

Voicemail sends the account an email. The recording, and the transcript when transcription is on, attach to the call record. There is no separate voicemail webhook: `call.ended` carries `isVoicemail: true`.

### Turn calling off

`DELETE /v1/phone-numbers/{id}/voice` disables calls on the number. Pozy keeps the settings, so enabling again restores them.

## Place an outbound call

`POST /v1/voice/calls` with `to`. When the callee answers, Pozy bridges them to the number's forward destination, or to a per-call `forwardTo`. An optional `greeting` is spoken to the callee first.

```bash cURL
curl -X POST https://api.pozy.ai/v1/voice/calls \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Idempotency-Key: callback-ticket-5521" \
  -H "Content-Type: application/json" \
  -d '{
    "fromNumber": "+14155550142",
    "to": "+13125550188",
    "forwardTo": "tel:+14155550100",
    "greeting": "Hi, this is Acme returning your call. Connecting you now."
  }'
```
```ts Node
const callRes = await fetch("https://api.pozy.ai/v1/voice/calls", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Idempotency-Key": "callback-ticket-5521",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    fromNumber: "+14155550142",
    to: "+13125550188",
    forwardTo: "tel:+14155550100",
    greeting: "Hi, this is Acme returning your call. Connecting you now.",
  }),
});
const call = await callRes.json();
if (!callRes.ok) throw new Error(call.error);
console.log(call.callId, call.status); // "dialing"
```
```python Python
resp = requests.post(
    "https://api.pozy.ai/v1/voice/calls",
    headers={
        "Authorization": f"Bearer {os.environ['POZY_API_KEY']}",
        "Idempotency-Key": "callback-ticket-5521",
    },
    json={
        "fromNumber": "+14155550142",
        "to": "+13125550188",
        "forwardTo": "tel:+14155550100",
        "greeting": "Hi, this is Acme returning your call. Connecting you now.",
    },
    timeout=30,
)
print(resp.json()["callId"])
```

A `200` means the call is dialing. Everything after that is asynchronous: follow it with `GET /v1/voice/calls/{id}`, or with `call.ended` and `call.failed`. Outbound phone calls do not send `call.received`.

| Field | What it does |
| --- | --- |
| `to` | Number to call (required) |
| `fromNumber` | Which of your numbers to call from. Optional when you own exactly one voice-enabled number. |
| `forwardTo` | Who the callee is bridged to: `tel:`, `sip:` or `wss://` (an AI agent) |
| `greeting` | Spoken to the callee before the bridge, up to 1,000 characters |
| `amd` | Answering-machine detection. The bridge waits until Pozy knows whether a person answered. |
| `voicemailDropMessage` | With `amd`, speak this to an answering machine and hang up |
| `recordOverride`, `transcribeOverride`, `transcriptionLanguage` | Override the number's defaults for this call |
| `ringTimeoutSeconds` | How long to ring. Default 30, up to 120. |

Send an `Idempotency-Key` so a retry does not dial (and bill) a second call.

### Estimate the cost first

`GET /v1/voice/calls/estimate?to=%2B13125550188&minutes=5&recording=true` returns `perMinuteUsd` and a `breakdown` whose `billableCostUSD` is what you will be charged, using the same formula as the invoice. Use it to show prices to your users or to block expensive routes. Estimates cover outbound destinations only.

### Transfer and hang up

- `POST /v1/voice/calls/{id}/transfer` with `to` (a `+E164` number or a `sip:` URI) hands a live call to someone else. The new leg always shows your number. `wss://` is not a valid transfer target.
- `POST /v1/voice/calls/{id}/end` hangs up. It is safe to repeat: ending a finished call returns `200` with its current status. It also ends WhatsApp calls.

Final duration and cost are written when the hangup lands, so a call record can show its previous status for a moment.

## Webhook events

| Call | Events, in order |
| --- | --- |
| Inbound | `call.received` at ring time, then `call.ended` or `call.failed` |
| Outbound phone call (API or browser) | `call.ended` or `call.failed` only |

`call.ended` carries `durationSeconds`, `endReason` (`hangup`, `no_answer`, `rejected` or `error`), `isVoicemail`, `callErrors`, a cost breakdown in `billing`, and a signed `recordingUrl` when recording was on. Unanswered, busy and rejected calls end with `call.ended`, so branch on `endReason`. `call.failed` is for hard errors before or during the bridge.

> **Missed-call text-back:**
> 
> On an inbound `call.ended` with `endReason: "no_answer"`, text the caller back: send an SMS from `call.to` (your number) to `call.from`. The caller gets an answer even when nobody picked up. `call.received` also carries a `conversationId` if you would rather reply in the existing inbox thread.

| Event | When it fires |
| --- | --- |
| [`call.received`](https://pozy.ai/docs/webhooks/events#call-received) | Call received. |
| [`call.ended`](https://pozy.ai/docs/webhooks/events#call-ended) | Call ended. |
| [`call.failed`](https://pozy.ai/docs/webhooks/events#call-failed) | Call failed. |
| [`call.permission_request`](https://pozy.ai/docs/webhooks/events#call-permission-request) | Call permission request reply. |

## Limits

| Limit | Value |
| --- | --- |
| Outbound calls | 60 per rolling hour across all numbers on the key. Past it, `429`. |
| Business hours | 21 windows |
| IVR options | 12 |
| Ringing before voicemail | About 25 seconds |
| Voicemail message | 2 minutes |
| Emergency calls | Not supported |

## Errors

| Status | Code | Cause | Fix |
| --- | --- | --- | --- |
| `422` | | No voice-enabled number matches `fromNumber`, or the number has no destination and the call sent no `forwardTo` | Set `forwardTo` on the number or the call |
| `409` | | Transfer on a call that is not connected yet or already ended | Transfer only while live |
| `409` | `invalid_resource_state` | The number is attached to a [SIP trunk](https://pozy.ai/docs/telephony/sip-trunks), which owns its calls | Detach it first |
| `422` | | The number is a WhatsApp number on your own carrier, so Pozy cannot route its calls | Use a number bought or ported into Pozy |
| `429` | | Outbound cap reached | Wait for the rolling hour |
| `502` | | The carrier refused to place the call. It is marked `failed`. | Retry |

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`POST /v1/phone-numbers/{id}/voice`](https://pozy.ai/docs/api/enableVoiceOnNumber) | Enable phone calling on a number |
| [`DELETE /v1/phone-numbers/{id}/voice`](https://pozy.ai/docs/api/disableVoiceOnNumber) | Disable phone calling on a number |
| [`POST /v1/voice/calls`](https://pozy.ai/docs/api/createVoiceCall) | Place an outbound phone call |
| [`GET /v1/voice/calls/estimate`](https://pozy.ai/docs/api/getVoiceCallEstimate) | Estimate call cost |
| [`GET /v1/voice/calls`](https://pozy.ai/docs/api/listVoiceCalls) | List phone calls |
| [`GET /v1/voice/calls/{id}`](https://pozy.ai/docs/api/getVoiceCall) | Get a phone call |
| [`POST /v1/voice/calls/{id}/transfer`](https://pozy.ai/docs/api/transferVoiceCall) | Blind-transfer a live call |
| [`POST /v1/voice/calls/{id}/end`](https://pozy.ai/docs/api/endVoiceCall) | Hang up a live call |
