Prerequisites
- Node.js 18+ installed
- A WhatsApp Business Account connected through Dualhook
- Webhook Override configured so Meta delivers messages directly to your server
- Your webhook verify token (for Meta's GET handshake — set during connection setup in Dualhook)
- Your phone number's access token for outbound Graph API sends, such as sending messages
Set these environment variables:
WEBHOOK_VERIFY_TOKEN=your_verify_token
WHATSAPP_ACCESS_TOKEN=your_access_token
PHONE_NUMBER_ID=your_phone_number_id
WABA_ID=your_waba_id
META_GRAPH_VERSION=v25.0
No
META_APP_SECRET? In the Embedded Signup flowX-Hub-Signature-256is signed by Dualhook's Meta app and isn't customer-verifiable — validate inbound webhook payload shape instead. See Messaging Webhook → Inbound Webhook POST Validation.
Create a Webhook Endpoint
Set up an Express.js server that handles both the GET verification challenge and POST webhook events. Meta requires a quick 200 response (within ~10 seconds) on POST webhooks and will retry on non-2xx responses.
import express from "express";
const app = express();
app.use(express.json());
const VERIFY_TOKEN = process.env.WEBHOOK_VERIFY_TOKEN;
const EXPECTED_PHONE_NUMBER_ID = process.env.PHONE_NUMBER_ID;
const EXPECTED_WABA_ID = process.env.WABA_ID;
// GET — Meta sends this to verify your endpoint
app.get("/webhook", (req, res) => {
const mode = req.query["hub.mode"];
const token = req.query["hub.verify_token"];
const challenge = req.query["hub.challenge"];
if (mode === "subscribe" && token === VERIFY_TOKEN) {
return res.status(200).send(challenge);
}
return res.sendStatus(403);
});
// POST — Meta sends message and status webhooks here
app.post("/webhook", (req, res) => {
if (!isExpectedPayload(req.body)) {
return res.sendStatus(401);
}
// Always respond 200 quickly to avoid Meta retries
res.sendStatus(200);
// Process the webhook payload asynchronously
handleWebhook(req.body);
});
app.listen(3000, () => console.log("Webhook server running on port 3000"));
Validate the Webhook Payload
Validate the envelope, WABA ID, and phone_number_id against the values you stored for this connection, and serve the endpoint on a private, high-entropy URL path.
function isExpectedPayload(body) {
if (!body || body.object !== "whatsapp_business_account") return false;
if (!Array.isArray(body.entry) || body.entry.length === 0) return false;
for (const entry of body.entry) {
if (entry.id !== EXPECTED_WABA_ID) return false;
if (!Array.isArray(entry.changes) || entry.changes.length === 0) return false;
for (const change of entry.changes) {
if (change.field !== "messages") return false;
const phoneNumberId = change.value?.metadata?.phone_number_id;
if (phoneNumberId !== EXPECTED_PHONE_NUMBER_ID) return false;
}
}
return true;
}
Handle Incoming Messages
Parse the webhook payload to extract message content. Meta sends a nested structure with changes grouped by phone number.
function handleWebhook(body) {
if (body.object !== "whatsapp_business_account") return;
for (const entry of body.entry ?? []) {
for (const change of entry.changes ?? []) {
if (change.field !== "messages") continue;
const value = change.value;
const phoneNumberId = value.metadata?.phone_number_id;
// Handle incoming messages
for (const message of value.messages ?? []) {
console.log(`Message from ${message.from}: ${message.text?.body}`);
// Route to your application logic here
}
// Handle delivery statuses
for (const status of value.statuses ?? []) {
console.log(`Status update: ${status.id} → ${status.status}`);
}
}
}
}
Send a Template Message
Use the Cloud API directly to send template messages. Your server talks to Meta directly — Dualhook configures the webhook routing but is not in the sending path.
The to field must be in E.164 format (e.g. +14155552671). WhatsApp rejects numbers that are not E.164-formatted.
const PHONE_NUMBER_ID = process.env.PHONE_NUMBER_ID;
const ACCESS_TOKEN = process.env.WHATSAPP_ACCESS_TOKEN;
const GRAPH_API_VERSION = process.env.META_GRAPH_VERSION || "v25.0";
async function sendTemplate(to, templateName, languageCode = "en") {
const response = await fetch(
`https://graph.facebook.com/${GRAPH_API_VERSION}/${PHONE_NUMBER_ID}/messages`,
{
method: "POST",
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
to,
type: "template",
template: {
name: templateName,
language: { code: languageCode },
},
}),
}
);
const data = await response.json();
if (!response.ok) {
console.error("Send failed:", data.error);
}
return data;
}
For templates with variables and media headers, see the Sending Template Messages documentation.
Test with Dualhook
- Connect your number through Embedded Signup — Dualhook configures the webhook URL via Meta's Webhook Override automatically.
- Set your server's URL as the webhook endpoint in the Dualhook dashboard.
- Send a test message to your WhatsApp number. The message webhook arrives directly at your Express endpoint from Meta.
- Check Dualhook's webhook activity logs to verify management events are being received for operational monitoring.
Your messages flow from Meta directly to your server. Dualhook primarily handles management events (template status changes, quality updates) for dashboard monitoring — this is a Dualhook-specific routing configuration, not a Meta platform default.