> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# WhatsApp Business Calling

> Voice calls between your business and WhatsApp users — provisioning, permissions, billing, and lifecycle webhooks.

# WhatsApp Business Calling

WhatsApp Business Calling lets a customer place a one-tap voice call to your business directly from a WhatsApp chat, and lets your business call any contact who has granted call permission. Calls run over WhatsApp's voice transport — no PSTN, no SIP trunk on the customer side — and are billed per-minute by destination country.

This page covers the full lifecycle: enabling calling on a number, the permission state machine, initiating outbound calls, lifecycle webhooks, and billing.

## Capability matrix

| Capability                                   | Status                                  |
| -------------------------------------------- | --------------------------------------- |
| Inbound calls (customer calls your business) | GA in 195+ countries                    |
| Outbound calls (business calls customer)     | Requires explicit permission            |
| Audio leg                                    | WhatsApp voice transport (no PSTN cost) |
| Per-second metering, 1-minute floor          | Yes                                     |
| Recording (optional, opt-in)                 | Phase 6 — opt-in per call               |
| Multi-WABA per organisation                  | Yes                                     |

## Country availability

Meta does not yet permit WhatsApp Business Calling in every country. Orbit refuses to enable calling on a phone number whose country is on the blocklist; the block is enforced server-side and surfaced in the dashboard.

The following countries are blocked at the time of writing:

| ISO 3166-1 alpha-2 | Country       |
| ------------------ | ------------- |
| `US`               | United States |
| `CA`               | Canada        |
| `EG`               | Egypt         |
| `VN`               | Vietnam       |
| `NG`               | Nigeria       |

Calling enables fine on numbers from any other country. Meta updates this list periodically; the dashboard surfaces the live list under **Channels → WhatsApp → Calling**.

## Step 1 — Enable calling on a phone number

Calling is opt-in per WABA phone number. Owner / admin tier roles only.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/whatsapp/connections/{phoneNumberId}/calling/enable \
  -H "X-API-Key: dv_live_sk_..."
```

### Response

```json theme={null}
{
  "data": {
    "phone_number_id": "1234567890",
    "calling_enabled": true,
    "country_code": "GB",
    "updated_at": "2026-05-09T10:14:22Z"
  },
  "meta": {
    "request_id": "req_xyz789",
    "timestamp": "2026-05-09T10:14:22Z"
  }
}
```

If the phone number's country is on the blocklist the call returns `422` with code `WHATSAPP_CALLING_COUNTRY_BLOCKED`.

To disable:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/whatsapp/connections/{phoneNumberId}/calling/disable \
  -H "X-API-Key: dv_live_sk_..."
```

To read the current state without changing it:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/whatsapp/connections/{phoneNumberId}/calling \
  -H "X-API-Key: dv_live_sk_..."
```

The state response is cached for 5 minutes — the dashboard reflects mutations immediately because every enable / disable invalidates the cache.

### Migrate an older WABA to calling-aware webhooks

WABAs onboarded before 2026-05-09 do not have the `calls` field on their Meta webhook subscription. Calling lifecycle webhooks will not arrive until you re-register. Owner / admin only:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/whatsapp/connections/{phoneNumberId}/calling/register-webhook \
  -H "X-API-Key: dv_live_sk_..."
```

The endpoint is idempotent — re-running on a connection that already subscribes to `calls` is a 200 no-op. Newly-onboarded numbers register the field automatically during Embedded Signup.

### Platform-shared calling phone (operator setup)

When a tenant has no calling-enabled WABA of their own, Orbit can originate from a **platform-shared** demo calling number. This is operator configuration, not a tenant API surface — set these four env vars (mirrored into the `devotel-api-env` K8s Secret):

| Env var                                        | What it is                                                                                                                                                                                                                                                                                                                                        | Where to obtain                                                                                                                                                                    |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DEVOTEL_SHARED_WABA_ID`                       | **Set this first — the other three hang off it.** The production-verified WhatsApp Business Account id that owns the shared calling phone. Distinct from `DEVOTEL_WHATSAPP_BUSINESS_ACCOUNT_ID` (the unverified OTP-delivery fallback); the shared-calling WABA must be production-verified with a calling-enabled phone registered to Cloud API. | Meta Business Manager → WhatsApp Manager → the shared account → WhatsApp Business Account ID.                                                                                      |
| `DEVOTEL_SHARED_WABA_CALLING_PHONE_ID`         | Meta phone-number-id of the shared calling phone.                                                                                                                                                                                                                                                                                                 | Meta Business Manager → WhatsApp Manager → the shared number's API setup.                                                                                                          |
| `DEVOTEL_SHARED_WABA_CALLING_PHONE_E164`       | E.164 display number shown on the recipient's ring screen and the dashboard demo-mode banner.                                                                                                                                                                                                                                                     | Same number, in `+E.164` form.                                                                                                                                                     |
| `DEVOTEL_SHARED_WABA_CALLING_REGISTRATION_PIN` | **Required for shared-WABA calling registration.** The 6-digit two-step-verification PIN supplied to the Cloud API `POST /{phone-id}/register` round-trip. Stored as a secret (treated like a DB password — never logged, never returned, never on FE).                                                                                           | The PIN you chose when registering the shared phone with Meta. Held so Orbit can self-heal by re-registering after a Meta-side credential rotation. See `secret-rotation.md` §4.4. |

If the PIN (or phone-id / access token) is unset, shared calling registration soft-degrades rather than crashing — calls from tenants without their own calling WABA fail with `WHATSAPP_NO_CALLING_PHONE`.

### Media relay (rtpengine NG control)

WhatsApp Business Calling media rides a media relay that bridges Meta's WebRTC (DTLS-SRTP) leg to the plain-RTP softswitch leg. The API programs that relay by sending rtpengine NG-control commands over UDP. Point it at your rtpengine instance with these two operator env vars (mirrored into the `devotel-api-env` K8s Secret). Neither is secret.

| Env var                                | What it is                                                                                                                                                                                                            | Default |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `DEVOTEL_WA_CALLING_RTPENGINE_NG_HOST` | Hostname or IP of the rtpengine NG-control endpoint. In-cluster this is the rtpengine service (for example `rtpengine-ng.jambonz.svc.cluster.local`). **This is the only required value** — set it to wire the relay. | *unset* |
| `DEVOTEL_WA_CALLING_RTPENGINE_NG_PORT` | UDP port of the NG-control endpoint, matching rtpengine's `--listen-udp` value.                                                                                                                                       | `22222` |

When `DEVOTEL_WA_CALLING_RTPENGINE_NG_HOST` is unset, the relay transmitter is a no-op: the call lifecycle (signalling, billing, webhooks) still completes, but the media leg is never programmed and the call degrades to signalling-only. This lets you provision the media gateway and carrier before the NG endpoint is reachable from the API without breaking the call lifecycle in that window. Set the host once rtpengine is reachable; leave the port unset to accept the `22222` default.

## Step 2 — Calling permissions

Outbound calls require an active permission row for the `(contact, waba, phone_number_id)` triple. Permission states:

| State     | Meaning                                                                           |
| --------- | --------------------------------------------------------------------------------- |
| `none`    | The contact has never granted call permission for this WABA / number.             |
| `granted` | An active grant exists. May have a `expires_at` if it came from an in-app prompt. |
| `revoked` | The contact explicitly toggled off call permission. Outbound calls fail.          |
| `expired` | The TTL on an in-app-prompt grant has lapsed. Behaves the same as `none`.         |

Grants come from three sources:

* **Template-authorized** — the customer replies to a template with category `call_permission_request`. Persistent grant (no TTL).
* **In-app prompt** — Meta surfaces the call-permission UI in the WhatsApp client during a chat. Often time-bounded (24h).
* **Explicit consent** — the customer taps a `wa_call_permission_grant_*` button you sent. Persistent grant.

Read all permissions for a contact:

```bash theme={null}
curl -G https://api.orbit.devotel.io/api/v1/whatsapp/calling/permissions \
  -H "X-API-Key: dv_live_sk_..." \
  --data-urlencode "contact_id=ctc_abc123"
```

Response:

```json theme={null}
{
  "data": {
    "items": [
      {
        "id": "waCallPerm_...",
        "contact_id": "ctc_abc123",
        "waba_id": "111222333",
        "phone_number_id": "1234567890",
        "status": "granted",
        "granted_at": "2026-05-09T10:00:00Z",
        "expires_at": null,
        "source": "template_authorized",
        "is_expired": false
      }
    ]
  }
}
```

Permission state mutations are driven by Meta lifecycle webhooks — your application does NOT need to maintain this state manually.

## Step 3 — Initiate an outbound call

Owner / admin / developer roles. The call is gated through the full pre-flight chain:

1. WABA connection lookup (404 if unknown phone\_number\_id).
2. Country blocklist (422 `WHATSAPP_CALLING_COUNTRY_BLOCKED` if blocked).
3. Calling enabled on the number (422 `WHATSAPP_CALLING_NOT_ENABLED`).
4. Recipient is a known contact (422 `WHATSAPP_CALLING_NO_PERMISSION`).
5. Active call permission for `(contact, waba, phone_number_id)` (422 `WHATSAPP_CALLING_NO_PERMISSION`).
6. Wallet balance covers the worst-case spend (402 `WHATSAPP_CALLING_INSUFFICIENT_BALANCE`).
7. Daily cost cap not exceeded (429 `WHATSAPP_CALLING_DAILY_CAP_EXCEEDED`).
8. Meta `POST /{phone-number-id}/calls` round-trip.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/whatsapp/calling/calls \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+447700900123",
    "from_phone_number_id": "1234567890",
    "conversation_id": "conv_abc",
    "recording_consent": false,
    "biz_opaque_callback_data": "support-call-9821"
  }'
```

### Body fields

| Field                      | Type    | Required | Notes                                                                                    |
| -------------------------- | ------- | -------- | ---------------------------------------------------------------------------------------- |
| `to`                       | string  | yes      | E.164 destination. Recipient must be a known contact with active permission.             |
| `from_phone_number_id`     | string  | yes      | Meta phone number ID of the calling business sender.                                     |
| `conversation_id`          | string  | no       | Orbit conversation ID to thread the call into.                                           |
| `recording_consent`        | boolean | no       | `true` = recording will run if Phase 6 recording is enabled.                             |
| `biz_opaque_callback_data` | string  | no       | Up to 512 bytes echoed back on lifecycle webhooks.                                       |
| `sdp_offer`                | string  | no       | Override the platform-supplied SDP offer. Ignored unless you control your own audio leg. |

### Response

```json theme={null}
{
  "data": {
    "ok": true,
    "meta_call_id": "wacid.HBgMNzcwMDkwMDEyMxUCABEYEjg4QkU0NDQyN0M5NUVBOEU2NQA=",
    "wa_call_log_id": "waCall_abc123",
    "from": "+18005551234",
    "to": "+447700900123",
    "country_code": "GB"
  },
  "meta": { "request_id": "req_xyz789", "timestamp": "2026-05-09T10:14:22Z" }
}
```

### Operator actions on inbound calls

When a customer calls your business, you can accept or decline programmatically:

```bash theme={null}
# Accept
curl -X POST https://api.orbit.devotel.io/api/v1/whatsapp/calling/calls/{metaCallId}/accept \
  -H "X-API-Key: dv_live_sk_..."

# Decline (preferred over leaving the call to ring out — no billing impact, cleaner UX)
curl -X POST https://api.orbit.devotel.io/api/v1/whatsapp/calling/calls/{metaCallId}/decline \
  -H "X-API-Key: dv_live_sk_..."
```

The dashboard's incoming-call modal at `/messages/whatsapp` ties the same actions to a UI button.

## Lifecycle webhooks

Subscribe to these event types via your tenant webhook endpoint. Each event carries the `wa_call_log_id` you can use to join Orbit's persisted call row.

| Event type                         | Triggered when                                                        |
| ---------------------------------- | --------------------------------------------------------------------- |
| `whatsapp.call.received`           | Inbound ring lands on your number (legacy event, preserved for BAU).  |
| `whatsapp.call.connected`          | Outbound call has been initiated by Meta.                             |
| `whatsapp.call.accepted`           | Inbound call was accepted (by you or by an agent).                    |
| `whatsapp.call.terminated`         | Call ended. Carries `duration_seconds`, `end_reason`, and final cost. |
| `whatsapp.call.permission_granted` | Customer granted call permission for a `(waba, phone_number_id)`.     |
| `whatsapp.call.permission_revoked` | Customer toggled off call permission.                                 |

Example `whatsapp.call.terminated` payload:

```json theme={null}
{
  "type": "whatsapp.call.terminated",
  "data": {
    "wa_call_log_id": "waCall_abc123",
    "meta_call_id": "wacid....",
    "waba_id": "111222333",
    "phone_number_id": "1234567890",
    "from": "+18005551234",
    "to": "+447700900123",
    "direction": "outbound",
    "status": "completed",
    "duration_seconds": 174,
    "end_reason": "normal_clearing",
    "cost_amount_cents": 6,
    "cost_currency": "USD",
    "created_at": "2026-05-09T10:14:22Z",
    "ended_at": "2026-05-09T10:17:16Z"
  }
}
```

Per-tenant webhook signing is the same Stripe/Svix-style HMAC scheme used across Orbit — see [Webhooks → Security](/webhooks/security).

## Billing

| Rule                | Detail                                                                                                                                                                    |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Granularity         | Per-second metering, **1-minute floor**. A 5-second answered call bills 1 minute.                                                                                         |
| Direction           | Both inbound AND outbound calls are billable.                                                                                                                             |
| Failed / no-answer  | NOT billed. Cost is stamped `0` for idempotency.                                                                                                                          |
| Recording (Phase 6) | Separate billable item; refundable independently of the call leg.                                                                                                         |
| Pricing tiers       | Low / mid / high tier per destination country (`public.wa_calling_rates` seed).                                                                                           |
| Currency            | Meta bills the platform in USD; the wallet debit converts to your wallet currency at end-of-call. The audit ledger captures both source USD cents and the debited amount. |

### Pre-flight balance check

The platform refuses to initiate a call when the wallet cannot cover the worst-case spend:

```
worst_case_cents = max_call_minutes × per_minute_rate_cents
```

Where `max_call_minutes` defaults to **60** (Meta's documented soft-cap on a single call). Override per-org via the dashboard at **Settings → WhatsApp → Calling → Max call minutes** (writes `organizations.settings.whatsapp_calling_max_call_minutes`).

When the wallet is below the worst-case the API responds `402 WHATSAPP_CALLING_INSUFFICIENT_BALANCE`. Top up to retry.

### Daily cost cap

Optional opt-in per organisation. When set, the platform tracks total WA-calling spend per UTC day per org and refuses new calls once the cap is reached:

```
HTTP/1.1 429 Too Many Requests
{
  "error": {
    "code": "WHATSAPP_CALLING_DAILY_CAP_EXCEEDED",
    "message": "Today's WhatsApp calling spend has reached the configured daily cap."
  }
}
```

Configure under **Settings → WhatsApp → Calling → Daily spend cap** (writes `organizations.settings.whatsapp_calling_daily_cap_cents`). Default is unlimited (cap = 0).

## Dashboard surfaces

The WhatsApp dashboard at `/messages/whatsapp` exposes:

* **Calls tab** — paginated list of `wa_call_logs` with PII-masked phone numbers for viewer-tier roles.
* **KPI panel** — last-30-day call volume, accept rate, average duration, total spend.
* **Per-conversation Call button** — surfaced inside the inbox conversation header. Disabled (with permission-state hint) when permission is missing / expired / revoked.
* **Permission indicator** — shows the current permission state in the contact-details panel; offers a "Send re-engagement template" CTA that pre-fills a compose dialog with the `call_permission_request` template.
* **Incoming-call modal** — SSE-driven; mounted next to the PSTN softphone. Accepts or declines the call.

## Common errors

| Code                                    | HTTP | Cause                                                                                                          |
| --------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------- |
| `WHATSAPP_NOT_CONNECTED`                | 404  | No WhatsApp connection for the supplied `from_phone_number_id`.                                                |
| `WHATSAPP_CONNECTION_INVALID`           | 422  | Connection has no resolved WABA id; reconnect via Embedded Signup.                                             |
| `WHATSAPP_CALLING_COUNTRY_BLOCKED`      | 422  | Phone number's country is on Meta's blocklist.                                                                 |
| `WHATSAPP_CALLING_NOT_ENABLED`          | 422  | Calling not enabled on the WABA phone number; call `/calling/enable` first.                                    |
| `WHATSAPP_CALLING_NO_PERMISSION`        | 422  | Recipient is not a contact, or has no active call permission. Send a `call_permission_request` template first. |
| `WHATSAPP_CALLING_INSUFFICIENT_BALANCE` | 402  | Wallet balance cannot cover the worst-case spend. Top up.                                                      |
| `WHATSAPP_CALLING_DAILY_CAP_EXCEEDED`   | 429  | Per-org daily cap exceeded for today (UTC).                                                                    |

## API reference

* [WhatsApp API → Calls](/api-reference/whatsapp) — full endpoint reference for the calling surface.
* [Messaging API](/api-reference/endpoints/messaging) — for sending the `call_permission_request` template that elicits a grant.

## See also

* [WhatsApp content policy](/compliance/whatsapp-content-policy)
* [Webhooks → Events](/webhooks/events)
* [Channels → WhatsApp](/channels/whatsapp)
