/api/layer/meta) using your ps_ key, and call webhooks are re-delivered by Pilot Status.
Requirements
- Self-hosted Chatwoot ≥ v4.15 (the voice channel is an enterprise feature; Chatwoot Cloud is not supported — this setup needs a global environment variable).
- A dedicated Chatwoot installation, or one where every WhatsApp Cloud inbox uses a Pilot Status
ps_key — the base-URL override below is global. - A Meta Cloud API (official) number on Pilot Status with calling enabled and messaging tier ≥ 2K. Unofficial (web) numbers are not supported by Chatwoot’s voice channel — use the Pilot Status softphone for those.
- The number’s number-scoped
ps_key (Profile → API), plus its Phone Number ID, WABA ID and Business Account ID — all copyable from the number’s card on the Numbers page.
Step 1 — Point Chatwoot at the Pilot Status Meta layer
Add to the environment of both the Rails and Sidekiq containers, then restart:Step 2 — Create a manual WhatsApp Cloud inbox
In Chatwoot: Settings → Inboxes → Add Inbox → WhatsApp → WhatsApp Cloud (manual/API, not Embedded Signup):| Field | Value |
|---|---|
| Phone number | The number in E.164 with+ (e.g. +15551234567) |
| Phone number ID | The number’s Meta Phone ID |
| Business Account ID | The number’s WABA ID |
| API key | The number’s ps_ key |
?access_token=ps_…), which the Pilot Status layer accepts and strips before forwarding (your ps_ key never reaches Meta).
After creation Chatwoot tries to auto-register its webhook directly against
graph.facebook.com (the URL is hardcoded upstream), gets a 401 and shows a “reauthorization required” banner. This is cosmetic — Step 3 clears it.Step 3 — Enable voice on the account and channel
Chatwoot’s voice path requires three things: thechannel_voice account feature, the channel flag calling_enabled, and a whatsapp_cloud provider. The UI toggle (inbox → Calls → Enable Voice Calling) cannot set the flag in this setup — it also calls the hardcoded Graph URL, fails with 401 before persisting. Enable it from the Rails console instead:
No shell access? Run it idempotently at boot (docker compose)
No shell access? Run it idempotently at boot (docker compose)
If you cannot open a console on the host (managed platforms), run the same script at every boot of the Look for
rails service — it is a no-op once applied. Base64 avoids quoting issues in compose validators:voice-setup ok (or voice-setup skipped: <reason>) in the rails logs after deploy.Step 4 — Deliver call webhooks from Pilot Status
Create a webhook scoped to the number subscribing only thecalls event, pointing at the inbox’s webhook endpoint (the phone in the URL must match the channel’s phone_number exactly, including the +):
- Do not set a
secret— signature verification is waived for manual inboxes without an app secret, which is exactly what makes Pilot Status re-delivery work. - Subscribe only
["calls"]. Addingmessageswould duplicate every inbound message if the number also uses the standard Chatwoot mirror. - Pilot Status delivers the Meta envelope verbatim (single-change
entry[].changes[]withfield: "calls"), which is the exact shape Chatwoot’s WhatsApp events job expects.
Step 5 — Test
- Inbound (user calls the business): call the number from a phone → a call banner rings in the Chatwoot inbox → answer in the browser → two-way audio → hang up; the call is logged in the conversation.
- Outbound (agent calls the user): requires the user’s call permission (Meta blocks with error
138006otherwise) and a payment method on the WABA (error131044otherwise). See Voice Calls — permissions.
Limitations & notes
- Signaling latency — the webhook leg traverses Pilot Status delivery before Chatwoot’s Sidekiq. Meta’s ring window is ~30s; keep an eye on late rings under load.
- Audio path — WebRTC between the agent’s browser and Meta directly. Corporate networks that block UDP/SRTP will connect the call but produce silence; that is outside both Chatwoot’s and Pilot Status’s control.
- Call permission replies are delivered on the
messagesfield, notcalls, so Chatwoot’s permission state is not auto-updated in this setup. Outbound calls without permission fail with a readable138006. - Settings are shared — both Pilot Status (calling default-on) and Chatwoot write to the same Meta
/settingsobject; the last write wins. - The Pilot Status /chat softphone keeps working alongside this — but only one surface should answer a given call.