WhatsApp Integration (360dialog) — Setup & Operations
Mandato sends and receives WhatsApp messages from a firm-owned WhatsApp
Business number through 360dialog, which fronts the Meta/WhatsApp Cloud
API. Messages are stored as ordinary communications rows of type
whatsapp, so they flow into the unified Communications inbox and the
client/case timelines automatically.
- Firm number:
+34 696 247 355 - Inbound webhook:
https://app.mandato.es/api/webhooks/whatsapp - Provider endpoint:
https://waba-v2.360dialog.io(Cloud API)
Like every other integration, WhatsApp runs in two modes:
- Demo mode — no
WHATSAPP_API_KEYset. Sends are simulated and recorded locally; nothing leaves the server. This is the default locally and in any demo session. - Live mode —
WHATSAPP_API_KEYset and a live Supabase backend. Outbound messages hit 360dialog and persist to thecommunicationstable.
1. Environment variables
Set these in Netlify (production) and .env.local (local live testing):
| Variable | Required | Purpose |
|---|---|---|
WHATSAPP_API_KEY | Live sends | The 360dialog API key (D360-API-KEY). When absent, the feature runs in demo mode. |
WHATSAPP_WEBHOOK_SECRET | Recommended | Shared secret that gates the inbound webhook. Supplied by the caller as the x-mandato-webhook-secret header or a ?token= query param on the webhook URL. When unset, the webhook is open (demo/local only). |
WHATSAPP_WEBHOOK_VERIFY_TOKEN | If using Meta's GET handshake | Token echoed back during the GET verification handshake (hub.verify_token). |
NEXT_PUBLIC_SITE_URL | Yes | Used to build the webhook URL shown in Settings (<site>/api/webhooks/whatsapp). |
Security: the webhook lives under
/api, so the locale/auth proxy skips it — it must be publicly reachable by 360dialog. Always setWHATSAPP_WEBHOOK_SECRETin production and append?token=<secret>to the webhook URL you register in 360dialog. Without it the endpoint accepts any caller. 360dialog cannot forward Meta'sX-Hub-Signature-256, which is why we use our own shared secret instead.
2. 360dialog dashboard configuration
- Provision the number
+34 696 247 355in 360dialog and obtain the API key. - Set the API key as
WHATSAPP_API_KEY(Netlify + local). - Register the inbound webhook URL in the 360dialog dashboard:
https://app.mandato.es/api/webhooks/whatsapp?token=<WHATSAPP_WEBHOOK_SECRET> - Confirm the number's display name is approved and the number is verified — otherwise outbound sends fail with a 400/403.
3. Verifying the connection (Settings → WhatsApp)
Settings → WhatsApp shows the connection state and a "Check connection"
button (directors only). It calls checkWhatsAppStatus(), which pings the
360dialog WhatsApp Business profile endpoint (a read-only call — it does
not send a message) and maps the result:
| Status | Meaning | Action |
|---|---|---|
| Live | Key authenticates, number provisioned | Ready to send. Detail shows verified name + quality rating when available. |
| Demo mode | No WHATSAPP_API_KEY | Set the key to go live. |
| Unauthorized | 360dialog rejected the key (401/403) | Check WHATSAPP_API_KEY. |
| Not provisioned | Number unknown to 360dialog (404) | Confirm the number is provisioned on this account. |
| Rate-limited | 429 from 360dialog | Wait and retry. |
| Unreachable | Network error / timeout (10 s) | Check connectivity / provider status. |
| Error | Any other non-OK response | Inspect the HTTP <code> detail. |
4. How it works (code map)
| Concern | File |
|---|---|
| Domain model, templates, endpoints, status types | src/lib/whatsapp.ts |
| Send / receive / read / status writes, health check | src/lib/data/whatsapp.ts |
| Inbound webhook (GET verify + POST messages/statuses) | src/app/api/webhooks/whatsapp/route.ts |
| Shared-secret webhook auth | src/lib/webhook-auth.ts |
| Settings UI + test message + connection check | src/components/settings/whatsapp-settings.tsx |
| Settings server actions | src/app/[locale]/(dashboard)/settings/actions.ts |
| Inbox / threads | src/components/whatsapp/*, src/app/[locale]/(dashboard)/communications/ |
Outbound send (postToProvider)
POST https://waba-v2.360dialog.io/messages with header D360-API-KEY and the
Cloud API body:
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "34696247355",
"type": "text",
"text": { "body": "…", "preview_url": false }
}
The provider message id (messages[0].id) is stored in the row's
metadata.wa_id so delivery receipts can be matched back to it.
Inbound webhook
GET— verification handshake. Echoeshub.challengewhenhub.verify_tokenmatchesWHATSAPP_WEBHOOK_VERIFY_TOKEN; otherwise403.POST— gated onWHATSAPP_WEBHOOK_SECRET. Parses text messages (value.messages[]) and delivery receipts (value.statuses[]) from both the Cloud API (entry[].changes[].value) and legacy On-Premise (root.messages) shapes. Inbound messages are matched to a client by phone (last 9 digits) and stored; status receipts advance the matching outbound message's read receipt. Always returns200so the provider doesn't retry on partial/unmatched payloads.
Delivery receipts
Outbound status advances sent → delivered → read (or failed), matched by
metadata.wa_id. Receipts only ever upgrade status (a late delivered
never overwrites an existing read); failed is terminal. This drives the
single/double/blue ticks in the thread view.
5. Error handling
postToProvider maps provider failures to actionable messages and applies a
10-second timeout:
| Condition | Surfaced message |
|---|---|
| Missing API key | "WhatsApp isn't configured (missing API key)." |
| 401 / 403 (bad/expired key) | "360dialog rejected the API key. Check WHATSAPP_API_KEY…" |
| 404 (number not found) | "WhatsApp number not found on 360dialog…" |
| 429 (rate limited) | "360dialog is rate-limiting WhatsApp messages right now…" |
| 400 (rejected) | Surfaces 360dialog's detail, or notes the number may be unverified / recipient outside the 24-hour window (template required). |
| 5xx | "The WhatsApp service is temporarily unavailable…" |
| Network error | "Couldn't reach the WhatsApp service." |
| Timeout | "The WhatsApp service timed out. Try again." |
Provider error bodies are parsed for both the Cloud API
(error.error_data.details / error.message) and On-Premise
(errors[].details / errors[].title) shapes.
6. Known limitations / future work
- Template messages. Outside the 24-hour customer-service window, WhatsApp
requires a pre-approved template. The app ships template bodies
(
WHATSAPP_TEMPLATES) and renders them as free text, but does not yet send them via the Cloud APItemplatemessage type with registered template names. Until that's wired, the first message to a cold contact may be rejected with a 400 — the error handling above surfaces this. - Health endpoint. The connection check reads the WhatsApp Business profile.
If a given 360dialog account exposes a different path for this, adjust
WHATSAPP_PROFILE_URLinsrc/lib/whatsapp.ts. - Media messages. Only text inbound messages are parsed; media (image / audio / document) inbound payloads are ignored.
7. Manual test checklist
Run the dev server in demo mode (no env vars) and exercise the webhook:
# Inbound text (Cloud API shape) → { ok, received:1, stored:1 }
curl -X POST http://localhost:3000/api/webhooks/whatsapp \
-H 'Content-Type: application/json' \
-d '{"entry":[{"changes":[{"value":{"messages":[{"from":"34696247355","id":"wamid.T1","type":"text","text":{"body":"Hola"}}]}}]}]}'
# Delivery receipt → { ok, statuses:1, updated:0|1 }
curl -X POST http://localhost:3000/api/webhooks/whatsapp \
-H 'Content-Type: application/json' \
-d '{"entry":[{"changes":[{"value":{"statuses":[{"id":"wamid.T1","status":"delivered"}]}}]}]}'
# GET verify with no token configured → 403
curl -i 'http://localhost:3000/api/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=x&hub.challenge=C'
In Settings → WhatsApp: the section shows the firm number, the webhook URL (copyable), a Send a test message form, and a Check connection button. In live mode the check reports the real status from 360dialog.