Skip to main content

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_KEY set. Sends are simulated and recorded locally; nothing leaves the server. This is the default locally and in any demo session.
  • Live modeWHATSAPP_API_KEY set and a live Supabase backend. Outbound messages hit 360dialog and persist to the communications table.

1. Environment variables

Set these in Netlify (production) and .env.local (local live testing):

VariableRequiredPurpose
WHATSAPP_API_KEYLive sendsThe 360dialog API key (D360-API-KEY). When absent, the feature runs in demo mode.
WHATSAPP_WEBHOOK_SECRETRecommendedShared 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_TOKENIf using Meta's GET handshakeToken echoed back during the GET verification handshake (hub.verify_token).
NEXT_PUBLIC_SITE_URLYesUsed 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 set WHATSAPP_WEBHOOK_SECRET in production and append ?token=<secret> to the webhook URL you register in 360dialog. Without it the endpoint accepts any caller. 360dialog cannot forward Meta's X-Hub-Signature-256, which is why we use our own shared secret instead.


2. 360dialog dashboard configuration

  1. Provision the number +34 696 247 355 in 360dialog and obtain the API key.
  2. Set the API key as WHATSAPP_API_KEY (Netlify + local).
  3. Register the inbound webhook URL in the 360dialog dashboard: https://app.mandato.es/api/webhooks/whatsapp?token=<WHATSAPP_WEBHOOK_SECRET>
  4. 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:

StatusMeaningAction
LiveKey authenticates, number provisionedReady to send. Detail shows verified name + quality rating when available.
Demo modeNo WHATSAPP_API_KEYSet the key to go live.
Unauthorized360dialog rejected the key (401/403)Check WHATSAPP_API_KEY.
Not provisionedNumber unknown to 360dialog (404)Confirm the number is provisioned on this account.
Rate-limited429 from 360dialogWait and retry.
UnreachableNetwork error / timeout (10 s)Check connectivity / provider status.
ErrorAny other non-OK responseInspect the HTTP <code> detail.

4. How it works (code map)

ConcernFile
Domain model, templates, endpoints, status typessrc/lib/whatsapp.ts
Send / receive / read / status writes, health checksrc/lib/data/whatsapp.ts
Inbound webhook (GET verify + POST messages/statuses)src/app/api/webhooks/whatsapp/route.ts
Shared-secret webhook authsrc/lib/webhook-auth.ts
Settings UI + test message + connection checksrc/components/settings/whatsapp-settings.tsx
Settings server actionssrc/app/[locale]/(dashboard)/settings/actions.ts
Inbox / threadssrc/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. Echoes hub.challenge when hub.verify_token matches WHATSAPP_WEBHOOK_VERIFY_TOKEN; otherwise 403.
  • POST — gated on WHATSAPP_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 returns 200 so 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:

ConditionSurfaced 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 API template message 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_URL in src/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.