> ## 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 media relay

> Where a WhatsApp Business Calling conversation's audio actually flows — the seven components, the signalling/media split, the rtpengine NG-control relay, what 'signalling-only' means when the relay is unbound, failure semantics per layer, and how the model differs from PSTN voice.

# WhatsApp Business Calling media relay

The [WhatsApp Business Calling model](/concepts/whatsapp-business-calling-model) explains the capability: who may call whom, how permission works, and how calls are billed. This page goes one layer deeper into the question a customer hits when they diagnose "the call connects, but there is no audio": **where does the media go, and what decides whether it gets wired at all?**

The short answer: signalling and media take different paths. The call is set up over Meta's Cloud API, while the audio leg is bridged between Meta's WebRTC (DTLS-SRTP) side and your plain-RTP side by a relay — rtpengine — that the API programs over rtpengine's NG-control protocol. If the relay host is not configured, everything except the audio still completes: the call record, the billing, the webhooks. That is a **signalling-only** call, and it is the expected behavior — not a crash — in that configuration.

You read this page when you are enabling calling, diagnosing dead audio, or deciding whether the media leg of your deployment is wired at all.

## The seven components and the role each plays

| Component                      | Role                                                                                                                                                                                                                                                 |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Meta Cloud API (signalling)    | Carries the call setup — offers, answers, accepts, terminates — between Meta and Orbit over HTTPS. A call that never reaches this leg never exists.                                                                                                  |
| Orbit WhatsApp calling API     | The pre-flight chain (WABA lookup, country blocklist, calling-enabled, permission, wallet, daily cap) plus the Meta round-trip that originates or accepts the call.                                                                                  |
| RTP relay host                 | The machine that forwards audio packets between two legs. It is passive until programmed; it carries no signalling of its own.                                                                                                                       |
| rtpengine NG-control endpoint  | The control channel of the relay. The API sends bencoded `offer` / `answer` / `delete` commands here over UDP; rtpengine rewrites SDP and binds the two legs. This is the only component the API speaks to directly for media.                       |
| Webhook fanout                 | Every lifecycle state (`whatsapp.call.received`, `.accepted`, `.connected`, `.terminated`, permission changes) lands as a signed webhook on your endpoint, joined to the persisted row by `wa_call_log_id`.                                          |
| Wallet pre-flight and metering | Before a call is placed, the worst-case spend is checked and the call is refused with `402 WHATSAPP_CALLING_INSUFFICIENT_BALANCE` when the wallet cannot cover it. Per-second metering with a one-minute floor stamps the final cost at termination. |
| Optional recording             | Opt-in per call (`recording_consent: true`), billed as a separate line item so a recording refund never touches the call leg.                                                                                                                        |

Of the seven, four are always on: Meta Cloud API, the calling API pre-flight, webhook fanout, and the wallet check. Two are conditional: the relay pair (host + NG-control endpoint), which is bound by configuration, and recording, which is per-call.

The relay pair is the part a customer most often cannot reason about, because "unset" looks identical to "broken" from the outside: the call exists, the webhooks flow, the bill arrives, and there is simply no audio. The rest of this page is about that pair.

## Where signalling ends and media begins

The configuration surface is deliberately thin — one variable binds the relay:

| Variable                               | What it binds                                                                        | Unset behavior                                                                                                                 |
| -------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `DEVOTEL_WA_CALLING_RTPENGINE_NG_HOST` | Points the relay transmitter at your rtpengine NG-control endpoint (hostname or IP). | The transmitter is a no-op. The call still completes — signalling, record, billing, webhooks — but no media leg is programmed. |
| `DEVOTEL_WA_CALLING_RTPENGINE_NG_PORT` | The UDP port of the NG endpoint (rtpengine's `--listen-udp`).                        | Defaults to `22222`.                                                                                                           |

These are operator values, not tenant settings — mirrored into the API's environment from the platform's own secret store. You do not manage them from the dashboard.

**Signalling-only** is the name for the unbound state. It is not an error the call can recover from mid-call; it is a property of the deployment: the API was given no NG endpoint, so no `offer`/`answer` ever reached rtpengine, so the RTP legs were never bridged. The call lifecycle is designed to survive that — a relay program failure degrades rather than aborts the call, by design, so provisioning the media gateway cannot break signalling while it is unreachable.

Below is the media path as a diagram. The dashed segment on the left is Meta's WebRTC/DTLS-SRTP side; the solid segment on the right is the plain-RTP side your SIP endpoint terminates on; the box in the middle is the relay that copies packets between them:

```mermaid theme={null}
flowchart LR
    subgraph Meta side
        WA[WhatsApp client]
        MC[Meta Cloud API]
    end
    subgraph Relay
        RE[rtpengine\nNG-control on UDP 22222]
    end
    subgraph Your side
        API[Orbit calling API]
        SIP[SIP endpoint / softswitch leg]
    end
    WA -- WebRTC / DTLS-SRTP --> RE
    RE -- plain RTP --> SIP
    MC -. signalling HTTPS .-> API
    API -. offer / answer / delete .-> RE
```

The bottom two edges are the control plane; the top two are the media plane. A request on the control plane (`offer`, `answer`, `delete`) is what turns the top edges from an intention into a bridged pair of RTP legs.

## Inbound: WhatsApp to the SIP endpoint

The inbound direction rings from the WhatsApp client into your number, and the accept step is when the two legs get programmed — marking where signalling hands off to media:

```mermaid theme={null}
sequenceDiagram
    participant WAC as WhatsApp client
    participant Meta as Meta Cloud API
    participant API as Orbit calling API
    participant RE as rtpengine relay
    participant SIP as SIP endpoint

    WAC->>Meta: inbound call rings
    Meta->>API: call event + SDP offer (signalling)
    API-->>API: pre-flight: calling enabled, wallet pre-check
    API->>RE: NG offer over UDP (program relay leg 1)
    API->>SIP: route call, derive answer SDP (signalling)
    API->>RE: NG answer over UDP (program relay leg 2)
    API->>Meta: accept call (signalling)
    Meta->>WAC: call connected
    Note over WAC,SIP: media flows — DTLS-SRTP to relay, plain RTP to SIP
    WAC->>Meta: hang up
    Meta->>API: terminate event (signalling)
    API->>RE: NG delete over UDP
    API-->>API: stamp duration + cost, emit whatsapp.call.terminated
```

The two `NG offer` / `NG answer` steps are the entire media story. Everything above them is HTTPS and SIP; everything below them is packet forwarding. If the relay is unbound, the sequence runs unchanged except those two steps are skipped — and the `media flows` note never becomes real.

## Outbound: SIP endpoint to the WhatsApp client

The outbound direction mirrors it, with the wallet pre-flight in front and Meta's round-trip to originate:

```mermaid theme={null}
sequenceDiagram
    participant SIP as SIP endpoint
    participant API as Orbit calling API
    participant RE as rtpengine relay
    participant Meta as Meta Cloud API
    participant WAC as WhatsApp client

    SIP->>API: POST /api/v1/whatsapp/calling/calls (signalling)
    API-->>API: pre-flight: blocklist, enabled, permission, wallet, daily cap
    API->>Meta: originate call + SDP offer (signalling)
    Meta->>WAC: ring the contact
    WAC->>Meta: answer + SDP answer
    Meta->>API: call progress events (signalling)
    API->>RE: NG offer + answer over UDP (bridge legs)
    API->>SIP: bridge to the SIP endpoint (signalling)
    Note over SIP,WAC: media flows — plain RTP to relay, DTLS-SRTP to WhatsApp
    SIP->>API: hang up (signalling)
    API->>Meta: terminate (signalling)
    API->>RE: NG delete over UDP
    API-->>API: stamp duration + cost, emit whatsapp.call.terminated
```

Same split: the call cannot start without the wallet and permission pre-flight, the call cannot carry audio without the relay program step, and the call always ends with the metering stamp and the webhook.

## Failure semantics per layer

Each layer degrades differently, and the differences are the reason "calls connect but no audio" is a configuration diagnosis rather than a crash report:

| Condition                               | Outcome                                                                                                                         | What you observe                                                                                                                                    |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Relay endpoint absent or unreachable    | The NG transmit is a no-op or a logged, counted failure — never thrown. The call completes **signalling-only**.                 | Call rows and webhooks look normal; the `whatsapp.call.terminated` payload arrives with duration and cost; there is simply no audio on either side. |
| Meta Cloud API unreachable or refusing  | The pre-flight Meta round-trip cannot complete, so the call is **refused before it exists** — no call row, no charge to refund. | The `POST /calls` request fails at step 8 of the pre-flight chain; nothing downstream is touched.                                                   |
| Wallet below worst-case spend           | Pre-flight refuses with `402 WHATSAPP_CALLING_INSUFFICIENT_BALANCE`.                                                            | The API response is the refusal; no Meta round-trip is attempted, no call row is written.                                                           |
| Daily spend cap reached                 | Pre-flight refuses with `429 WHATSAPP_CALLING_DAILY_CAP_EXCEEDED`.                                                              | Same shape as the wallet refusal, different code; resets at UTC midnight or when the cap is raised.                                                 |
| Relay transmit times out mid-call setup | The offer/answer datagram is dropped; the transmit is logged and counted but the call lifecycle continues.                      | A signalling-only call indistinguishable from the unbound case, except the relay was configured and the transmit failed.                            |

The design posture is consistent: media-plane failures degrade to signalling-only; signalling-plane and money-plane failures refuse the call outright. A relay outage never produces a half-billed call, and a billing refusal never produces a half-wired relay.

For the operator side of wiring the relay, the guide's [media relay section](/guides/whatsapp/business-calling#step-1--enable-calling-on-a-phone-number) has the exact env-var table and the no-op contract.

## How this differs from PSTN voice

If you already run PSTN voice on the platform, the model above has three sharp edges that PSTN does not:

| Aspect         | PSTN voice                                                                                      | WhatsApp Business Calling                                                                                                                                                                                       |
| -------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Media handoff  | SIP trunks terminate directly; media is plain RTP end to end through the softswitch media path. | One side is Meta's WebRTC (DTLS-SRTP) leg and needs the relay to transcode handoff into plain RTP before it reaches any RTP-side endpoint.                                                                      |
| Authentication | A SIP FROM digest or trunk ACL decides whether the endpoint is allowed to place the call.       | There is no SIP layer on the customer side. The caller identity is the WABA phone number, and the call authority is the contact's grant state — the SIP FROM check is replaced by the permission state machine. |
| Join key       | A `call_sid` / provider call id joins webhooks to the persisted call row.                       | A `wa_call_log_id` joins the webhook payload to the persisted `wa_call_logs` row, independent of retry.                                                                                                         |

The relay handoff is also the reason the optional SDP override field (`sdp_offer` on the initiate-call request) is an escape hatch, not a mainstream path: it matters only when you control your own audio leg explicitly.

## A worked call-receipt trace

This is the shape a reconciling consumer sees — one inbound call, wired, answered, terminated, stamped. The join key on the payload is the `wa_call_log_id`; the persisted row and every retry of the webhook both carry it, so either side is enough to reconcile:

```json theme={null}
{
  "type": "whatsapp.call.terminated",
  "data": {
    "wa_call_log_id": "waCall_9f3e7d2a",
    "meta_call_id": "wacid.HBgMNzcwMDkwMDEyMxUCABEYEjg4QkU...",
    "waba_id": "111222333",
    "phone_number_id": "1234567890",
    "from": "+447700900123",
    "to": "+18005551234",
    "direction": "inbound",
    "status": "completed",
    "duration_seconds": 42,
    "end_reason": "normal_clearing",
    "cost_amount_cents": 1,
    "cost_currency": "USD",
    "recording_consent": false,
    "created_at": "2026-05-09T10:14:22Z",
    "ended_at": "2026-05-09T10:15:04Z"
  }
}
```

Read it against the failure table above: a 42-second call with a one-minute floor bills one minute of cost; a call that never wired the relay still produces this payload; a call refused in pre-flight produces none of it. Recording, when consented, appears as its own line so a refund on the recording never disturbs the leg cost.

The events preceding the termination are the lifecycle fanout described on the model page: `whatsapp.call.received` at ring, `whatsapp.call.accepted` (inbound) or `whatsapp.call.connected` (outbound), then `whatsapp.call.terminated`, all signed with the same HMAC scheme as the rest of the platform ([Webhooks → Security](/webhooks/security)).

## See also

* [WhatsApp Business Calling model](/concepts/whatsapp-business-calling-model) — the capability model this page drills into, including the lifecycle webhook list.
* [WhatsApp Business Calling guide](/guides/whatsapp/business-calling) — the step-by-step endpoint walkthrough, including the env-var table for the relay.
* [Media planes](/concepts/media-planes) — how the platform separates control and media planes generally; this page is the WhatsApp-specific instance of that split.
* [Wallets, credits, and charges](/concepts/wallets-credits-and-charges) — the metering model the wallet pre-flight depends on.
