Guide to 360dialog Webhooks: Features and Best Practices
360dialog webhooks carry everything that happens on your WhatsApp Business numbers: inbound messages from customers, delivery and read receipts for the messages you send, and errors. 360dialog is a WhatsApp Business Solution Provider, and it forwards the WhatsApp Business Platform's notifications to a URL you choose. Its docs describe webhooks as "core infrastructure, not optional": without a working webhook URL you can send WhatsApp messages but you never receive replies.
This guide covers how 360dialog webhooks are configured and authenticated, the shape of message and status notifications, the strict delivery rules, and the best practices for running a 360dialog receiver at WhatsApp volumes.
What are 360dialog webhooks?
When you set a webhook URL in 360dialog, the WhatsApp Business Platform sends a JSON notification to it whenever an event occurs. The bodies follow Meta's WhatsApp Cloud API format. 360dialog's docs name three main objects inside them:
- messages: a new inbound message, including its content.
- statuses: a change in the status of a message you sent.
- errors: operational or delivery problems outside the normal message flow.
You can set the URL at two levels. A phone number webhook receives events for that number only, and is the recommended setup. A WABA webhook (for a WhatsApp Business Account) receives events for every number in the account that has no webhook of its own.
360dialog's webhook docs don't describe a signature. The authentication they document is a header you attach when you set the URL: Authorization: Basic base64(USER:PASS). 360dialog sends that header with every notification.
360dialog webhook features
| Feature | Details |
|---|---|
| Configuration | 360dialog Hub (API Settings on the number), or the Messaging API on waba-v2.360dialog.io |
| Levels | Phone number webhook (preferred) or WABA webhook (fallback) |
| URL rules | HTTPS with a valid certificate. No underscores in the domain and no explicit port |
| Body | Meta's Cloud API notification: object, then entry[].changes[] with field and value |
| Event identifier | entry[].changes[].field, then which array is present in value |
| Authentication | Custom headers set with the URL, documented as Authorization: Basic base64(USER:PASS). No signature |
| Success response | HTTP 200 within 5 seconds |
| Retries | Exponential backoff. The webhook page says up to 7 days; the reference page says typically 24 hours |
| Volume | About three status callbacks per outbound message, plus one per inbound message |
| Source IPs | Not published |
Common events
The field on each change says what kind of notification it is. Nearly all traffic is messages, and within it you dispatch on which array value contains.
field | In value | Meaning |
|---|---|---|
messages | messages[] | An inbound message. messages[].type says whether it's text, an image, a reaction, an interactive reply and so on |
messages | statuses[] | A status change for a message you sent: sent, delivered, read, failed or played |
messages | errors[] | An error outside the normal message flow |
smb_message_echoes | message_echoes | WhatsApp Coexistence: a message the business sent from the WhatsApp Business app |
Two more Coexistence events, history and smb_app_state_sync, use a different body of { id, event, data }, with the type in the top-level event field.
The status values map to what users see in WhatsApp: sent is one checkmark, delivered two, read two blue checkmarks, failed a red error triangle, and played means a voice message was played for the first time.
Here is 360dialog's example of a failed status, trimmed:
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "102290129340398",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15550783881",
"phone_number_id": "106540352242922"
},
"statuses": [
{
"id": "wamid.HBgLMTIxMTU1NTc5NDcVAgARGBIyRkQxREUxRDJFQUJGMkQ3NDIA",
"status": "failed",
"timestamp": "1689380458",
"recipient_id": "15551234567",
"errors": [
{
"code": 130472,
"title": "User's number is part of an experiment"
}
]
}
]
}
}
]
}
]
}
timestamp is a string of Unix seconds, and entry and changes are arrays, so loop over both rather than reading index 0. metadata.phone_number_id tells you which of your numbers the event belongs to, which matters on a WABA webhook.
360dialog's pages list only the fields above. Meta's Cloud API defines other webhook fields, such as template status updates, and 360dialog doesn't say whether it forwards them, so acknowledge any field you don't recognise.
Setting up 360dialog webhooks
Choose a username and a long random password, for example with
openssl rand -hex 32, and base64-encode them asUSER:PASS:printf '%s' "$USER:$PASS" | base64Set the webhook URL with an
Authorizationheader in itsheadersobject. In the 360dialog Hub, select the number, open API Settings, edit the number's webhook or the WABA webhook, enter the endpoint and the header, save, and confirm the verification code 360dialog emails you. Through the API, send the URL and headers with yourD360-API-KEY:curl -X POST "https://waba-v2.360dialog.io/v1/configs/webhook" \ -H "D360-API-KEY: $D360_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/360dialog", "headers": { "Authorization": "Basic dGVzdHVzZXI6dGVzdHBhc3M=" } }'The WABA-level equivalent is
POST /waba_webhook. The API also has a multi-webhook feature whose named destinations each take their ownurlandheaders.In Hookdeck. Create a source with the 360dialog type and enter the same username and password. Use the source URL as the webhook URL. Hookdeck reads the
Authorization: Basicheader and compares both values.
Because the credentials are a header 360dialog sends on every request, they arrive on the first attempt. Hookdeck never sends a 401 challenge, and doesn't need to.
Securing 360dialog webhooks
Decode the Authorization header, split it on the first colon, and compare the username and password with your stored values in constant time.
const crypto = require('crypto');
const express = require('express');
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on buffers of different lengths
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function verifyBasicAuth(header, user, pass) {
if (!user || !pass) return false; // fail closed when unconfigured
const match = /^basic\s+(\S+)\s*$/i.exec(header || '');
if (!match) return false;
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
const i = decoded.indexOf(':'); // passwords may contain ':', usernames may not
if (i === -1) return false;
const userOk = safeEqual(decoded.slice(0, i), user);
const passOk = safeEqual(decoded.slice(i + 1), pass);
return userOk && passOk;
}
const app = express();
app.post('/webhooks/360dialog', express.raw({ type: '*/*' }), (req, res) => {
const ok = verifyBasicAuth(
req.get('authorization'),
process.env.THREE_SIXTY_DIALOG_WEBHOOK_USERNAME,
process.env.THREE_SIXTY_DIALOG_WEBHOOK_PASSWORD
);
if (!ok) return res.sendStatus(401);
let body;
try {
body = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
// Acknowledge inside 5 seconds, then process.
res.sendStatus(200);
for (const entry of body.entry ?? []) {
for (const change of entry.changes ?? []) {
// queue change.field and change.value for a worker
}
}
});
The same check in Python:
import base64
import binascii
import hmac
import os
import re
def verify_basic_auth(header, user, password) -> bool:
if not user or not password:
return False # fail closed
match = re.fullmatch(r"(?i)basic\s+(\S+)\s*", header or "")
if not match:
return False
try:
decoded = base64.b64decode(match.group(1), validate=True)
except (binascii.Error, ValueError):
return False
received_user, sep, received_pass = decoded.partition(b":")
if not sep:
return False
user_ok = hmac.compare_digest(received_user, user.encode("utf-8"))
pass_ok = hmac.compare_digest(received_pass, password.encode("utf-8"))
return user_ok and pass_ok
# verify_basic_auth(request.headers.get("authorization"),
# os.environ["THREE_SIXTY_DIALOG_WEBHOOK_USERNAME"],
# os.environ["THREE_SIXTY_DIALOG_WEBHOOK_PASSWORD"])
Basic credentials prove the sender knows them. They don't prove the body is unchanged, and a captured request can be sent again and still pass, because nothing is signed and no timestamp is sent. Serve the endpoint over HTTPS only, use a long random password, and rotate it by setting the webhook URL again with new headers if it may have leaked.
Make 360dialog webhooks production-ready. Hookdeck Event Gateway checks the Basic credentials on every notification, answers inside 360dialog's 5-second window, and queues each event durably for your service.
360dialog webhook limitations and pain points
Five seconds to answer
The Problem: A handler that does real work before responding sees duplicates, a growing retry queue and delayed messages.
Why It Happens: A notification only counts as delivered if your URL returns HTTP 200 OK within 5 seconds. Any other response, or a slow one, puts it in a retry queue. 360dialog warns that "poor webhook performance can therefore delay message processing", and its own guidance is to acknowledge immediately and process afterwards.
Workarounds:
- Return
200straight after the credential check and process the notification in a background job. - Aim for 360dialog's latency targets: a median under 250 ms and fewer than 1% of requests over one second.
How Hookdeck Can Help: Hookdeck acknowledges each notification as it arrives and stores it, then delivers it to your service at a pace it can handle, with retries you control. Slow processing on your side no longer counts as a failed WhatsApp delivery.
Status callbacks outnumber messages
The Problem: Traffic is several times what you expected from your message volume, and spikes during campaigns.
Why It Happens: Each outbound message typically produces sent, delivered and read callbacks. 360dialog's sizing rule is capacity for three times your outbound volume as status callbacks, plus your inbound volume. Its example: sending 1,000 messages per second with a 30% reply rate means up to 3,000 status webhooks and about 300 inbound message webhooks per second.
Workarounds:
- Size your receiver from 360dialog's rule, not from your inbound traffic.
- Store only the newest status per message ID and drop older ones.
How Hookdeck Can Help: Hookdeck absorbs bursts at the edge and can rate-limit delivery to your service. Filters can send status callbacks and inbound messages to different destinations, so a campaign's receipts never slow down customer replies.
A static credential, not a signature
The Problem: Anyone who learns the Basic credentials can post fake inbound messages or statuses, and nothing detects a changed body.
Why It Happens: 360dialog's documented authentication is a header you set once. Its webhook pages don't describe a signature, and nothing in the request is computed over the body.
Workarounds:
- Use HTTPS only and a long random password, and keep the encoded header out of logs.
- Deduplicate on the message ID (
messages[].idorstatuses[].id), so a replayed notification has no further effect.
How Hookdeck Can Help: Hookdeck's 360dialog source type checks the Basic username and password on every request. Hookdeck answers 360dialog with a 200 either way, then records a request with missing or wrong credentials as failed verification, so it never reaches your destination.
Unclear retry window
The Problem: You can't say how long an outage you can survive before notifications are lost.
Why It Happens: 360dialog's webhook page says Meta retries failed webhooks "for up to 7 days" with exponential backoff. Its webhook reference says retries continue for "typically 24 hours, though this may vary".
Workarounds:
- Plan for the shorter figure, 24 hours.
- After a long outage, reconcile message state rather than assuming every status will arrive.
How Hookdeck Can Help: Once a notification has reached Hookdeck it's stored and can be replayed whenever your service is ready, so the length of your outage no longer depends on Meta's retry window.
Best practices
Dispatch on field, then on the array present
Loop over entry[] and changes[]. For field messages, check for value.messages, value.statuses or value.errors. Handle the Coexistence shapes separately, and acknowledge anything you don't recognise.
Deduplicate on message IDs
Retries resend the same notification. Use the wamid in messages[].id or statuses[].id, together with the status value, as your idempotency key. See our guide to webhook idempotency.
Don't assume order
Statuses can arrive out of order or late. Keep a rank for each status (sent < delivered < read) and never move a message backwards. failed should always win.
Acknowledge fast and process asynchronously
This is the rule 360dialog stresses most. Verify, persist, return 200, then process. See why to process webhooks asynchronously.
Prefer phone number webhooks
A phone number webhook only receives that number's events, which keeps routing simple and limits the impact of a bad deploy. Use a WABA webhook only as a fallback, and read metadata.phone_number_id if you do.
Conclusion
360dialog forwards WhatsApp Business Platform notifications in Meta's Cloud API format, protected only by the Authorization: Basic header you set with the URL. Check those credentials in constant time over HTTPS, dispatch on field and the array present, deduplicate on message IDs, and answer within 5 seconds, because the status traffic alone can be three times your send volume.
Hookdeck Event Gateway sits in front of your receiver, checks the Basic credentials, answers 360dialog immediately, and holds every notification until your service has processed it.
Get started with Hookdeck for free and handle 360dialog webhooks reliably in minutes.