WhatsApp Engines
Audience: developers who need to understand how an outbound message picks a provider, where credentials live, and the per-engine quirks. All three engines share one engine-selection step and two send dispatchers — learn those and the rest is detail.
The Three Engines
Every workspace sends WhatsApp through exactly one of three engines. They are interchangeable from the product's point of view — the inbox, campaigns, flows, and templates all adapt — but the underlying protocol of each is completely different.
| Engine | How it sends |
|---|---|
| Unofficial API | Node bridge → WhatsApp Web connection |
| WhatsApp Cloud API (Meta) | Node bridge → Meta Cloud API |
| Twilio WhatsApp | Direct from the app → Twilio REST API |
Where Credentials Live
Each workspace stores its chosen engine and that engine's credentials together (a workspace may have several connections when running multiple Cloud API numbers; one is marked primary). Credentials are encrypted at rest — in code, always read and write them through the provided helpers, never the raw stored value.
What gets stored differs by engine:
Unofficial API : server URL, device id, when it was paired
Cloud API : app id/secret, WABA id, business id,
phone number id, access token,
webhook verify token, catalog id, register PIN
Twilio : account SID, auth token, from-number, sandbox flag
A Meta Ads connection is not a send engine. It holds Click-to-WhatsApp ad credentials only and is ignored when choosing an engine — a workspace can run Meta Ads while sending via the Unofficial API or Twilio.
How a Send Picks an Engine
Both dispatchers decide the engine for a message in this order:
- Admin allow-list first. The admin sets which engines are allowed (all three by default). Any engine not on this list is ignored even if a workspace is configured for it — this is how "single-engine mode" works.
- Workspace connection. Use the engine the workspace is connected to, if it is actually connected and on the allow-list.
- Legacy hint. An explicit engine choice carried on older scheduled records is honoured if allowed (covers data created before connections were tracked the current way).
- Platform default. The admin's default engine (falling back to the Unofficial API). If even that is not allowed, the first allowed engine is used.
The chosen engine is recorded on each sent message so dashboards and the Devices stats cards can filter by engine. The same order is used when filtering on read (device pickers, stats cards).
The Two Dispatchers
There are two send entry points, kept separate so each can evolve independently:
- One for chat, campaigns, broadcasts, and scheduled sends.
- One for team-inbox messages. It uses the same bridge endpoints underneath.
Both share the same guards and the same result. Before any send they check:
- Emergency halt — the admin's platform-wide send freeze. When on, the send is refused.
- Monthly quota — the workspace's monthly message allowance (counting both chat/campaign and inbox messages), with credit-based overflow billing when exceeded.
Every send returns:
[ 'ok' => bool, // did the provider accept it? 'provider_id' => ?str, // the message id, if sent 'platform' => str, // which engine handled it (or 'halted') 'local_only' => bool, // saved but not actually sent out 'error' => ?str ] // failure reason, if any
local_only = true means the message was saved but not actually sent (missing credentials, a Cloud API message queued for later, or a halt). For the caller it counts as saved, not delivered.
Engine 1 — Unofficial API
Unofficial API sends go to the Node bridge. The bridge URL and the sending number are resolved first, then the correct endpoint is picked by message type — sending the wrong type to the wrong endpoint silently does nothing:
POST {server_url}/api/send-message/:phone text (+ optional buttons/footer)
POST {server_url}/api/send-media-message/:phone media + caption (+ buttons)
POST {server_url}/api/send-media-only/:phone media, no caption
POST {server_url}/api/send-location/:phone location
POST {server_url}/api/schedule-message/:phone single scheduled send
Key behaviours:
- Bridge URL comes from the workspace's connection, then the admin's Unofficial API Server URL setting, then the
SERVER_URLenvironment value. - Sending number prefers the number set on the message itself (so multi-device sends route to the right session), then the connection's number, then the most recently connected device.
- Media is sent inline (encoded in the request body) rather than as a URL, carrying the real file type and original filename — this avoids a deadlock against the single-threaded PHP dev server and keeps correct file extensions on the recipient's side.
- Interactive buttons (open-URL, call, copy-code, quick-reply) and an optional title/footer are merged in from the message's stored details.
- Extras — reactions, pin/star, edit/delete, and voice notes are Unofficial API only. On Cloud API/Twilio they are silently skipped.
Voice notes: sending a message as a voice note forces the audio/ogg; codecs=opus format so WhatsApp shows the round play-button bubble instead of a generic audio file.
Engine 2 — WhatsApp Cloud API (Meta)
This is Meta's official Cloud API. Crucially, in WaDesk Cloud API sends also go through the Node bridge — the bridge holds each number's Cloud API credentials and makes the call to Meta itself:
POST https://graph.facebook.com/{version}/{phone_number_id}/messages
Authorization: Bearer {access_token}
{ "messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "<digits, no +>",
"type": "text|image|video|document|location|interactive|reaction",
... }
- API version is admin-configurable (default
v23.0). - Credentials never sit in the app's memory during a send. The bridge fetches them per-number from the app over a token-protected endpoint, which decrypts them on the way out.
- Error hints are mapped so the UI can react — e.g. Meta error
131047→ "needs a template" (the 24-hour customer-service window has closed),130429→ "rate limited". - Engine-specific features: native WhatsApp Forms require Cloud API because they rely on Meta Flows. The app gates these automatically.
Inactive failover path. A complete PHP-only path to Meta also exists in the code, but it is intentionally not used. It is kept only as a reference in case the bridge is ever down. The live path always goes through the bridge.
Engine 3 — Twilio WhatsApp
Twilio is the one engine sent directly from the app, because the bridge's main send path has no Twilio transport. Credentials come from the workspace's connection, then the admin Twilio settings, then the environment:
POST https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json
(HTTP Basic auth: account_sid : auth_token)
From: whatsapp:+{from}
To: whatsapp:+{to}
StatusCallback: {APP_URL}/api/twilio/status
- The
+is mandatory — Twilio's WhatsApp transport requires thewhatsapp:+E164format, so it is always sent. - Sandbox mode — when a workspace is in sandbox mode, the From is forced to Twilio's shared sandbox sender
+14155238886regardless of the configured production number. - Content Templates — the load-bearing Twilio detail. When a message uses a template that has a registered Twilio Content SID, the send switches from plain text to Twilio's Content SID + Content Variables path. Content Variables is a JSON string keyed by position (
{"1":"John","2":"ABC123"}) matching the{{1}}/{{2}}placeholders — Twilio does not support named keys. Sending marketing/utility/authentication messages as free text outside the 24-hour window risks Twilio suspending your number, so this path is what keeps them compliant. - Delivery status — without the status callback, Twilio never reports delivered/read/failed and messages stay stuck at "sent". The receiver at
/api/twilio/statusverifies Twilio's signature.
Twilio sends from the bridge too: the bridge also has a Twilio sender so flows and campaigns can send via Twilio. It is given the Twilio credentials the same way the other engines get theirs and supports the same text / media / location / template messages.
Capability Comparison
| Capability | Unofficial API | WABA (Meta) | Twilio |
|---|---|---|---|
| Setup | QR scan, instant | Meta-verified WABA + approved templates | Twilio account + WhatsApp sender |
| Transport from PHP | via Node | via Node | direct + Node helper |
| Text / media / location | Yes | Yes | Yes |
| Interactive buttons | Native (CTA) | Interactive type | Only via Content Templates |
| Templates outside 24h | n/a | Approved templates | ContentSid required |
| WhatsApp Forms (Meta Flows) | No | Yes | No |
| Reactions / pin / star / edit | Yes | No (no-op) | No (no-op) |
| Scheduling | Via the bridge | Via the bridge | Via the bridge |
Extending or Adding an Engine
If you need a fourth provider, the points to touch are:
- Register the new engine (its name, label, and icon).
- Add a send path for it in both dispatchers, and a branch that routes to it.
- Teach the engine resolver about the new engine and where its sending device comes from.
- Define what credentials it stores and add the connect screen under Devices.
- Add it to the allowed-engines and default-engine handling in the admin panel.
Keep the uniform result so every caller (chat, campaigns, broadcasts, flows) keeps working unchanged.
Related Pages
- Architecture Overview — how the dispatchers fit the wider system.
- Node Bridge & Realtime — the Node endpoints the Unofficial API and WABA sends call.