Documentation Index
Fetch the complete documentation index at: https://orbit-docs.devotel.io/llms.txt
Use this file to discover all available pages before exploring further.
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.
curl -X POST https://orbit-api.devotel.io/api/v1/whatsapp/connections/{phoneNumberId}/calling/enable \
-H "X-API-Key: dv_live_sk_..."
Response
{
"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:
curl -X POST https://orbit-api.devotel.io/api/v1/whatsapp/connections/{phoneNumberId}/calling/disable \
-H "X-API-Key: dv_live_sk_..."
To read the current state without changing it:
curl https://orbit-api.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:
curl -X POST https://orbit-api.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.
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:
curl -G https://orbit-api.devotel.io/api/v1/whatsapp/calling/permissions \
-H "X-API-Key: dv_live_sk_..." \
--data-urlencode "contact_id=ctc_abc123"
Response:
{
"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:
- WABA connection lookup (404 if unknown phone_number_id).
- Country blocklist (422
WHATSAPP_CALLING_COUNTRY_BLOCKED if blocked).
- Calling enabled on the number (422
WHATSAPP_CALLING_NOT_ENABLED).
- Recipient is a known contact (422
WHATSAPP_CALLING_NO_PERMISSION).
- Active call permission for
(contact, waba, phone_number_id) (422 WHATSAPP_CALLING_NO_PERMISSION).
- Wallet balance covers the worst-case spend (402
WHATSAPP_CALLING_INSUFFICIENT_BALANCE).
- Daily cost cap not exceeded (429
WHATSAPP_CALLING_DAILY_CAP_EXCEEDED).
- Meta
POST /{phone-number-id}/calls round-trip.
curl -X POST https://orbit-api.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
{
"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:
# Accept
curl -X POST https://orbit-api.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://orbit-api.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:
{
"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.
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
See also