API reference
Receiving messages
The inbound webhook payload, how to secure it, and handling button replies.
Set a callback URL with PATCH /v1/phones/:phoneNumberId/webhook. Every inbound message on that number is then POSTed to your URL as JSON.
This is also the only way to know a customer has messaged you, which is what opens the 24-hour window, and therefore what makes a free-form reply possible at all.
The payload
Body the platform POSTs to you
| Field | Required | Type | Description |
|---|---|---|---|
| phoneNumberId | Yes | string | Your Meta phone number id, which of your numbers received the message. |
| from | Yes | string | The sender's WhatsApp id: digits only, no +. This is the value to pass back as to when you reply.Example: 919876543210 |
| type | Yes | string | text, interactive, button, image, video, audio, document, location, contacts, sticker, reaction or order. |
| message | Yes | object | The raw Meta message object, verbatim. Everything type-specific lives in here, along with message.id (the wamid) and message.context when the customer replied to something. |
| contact | No | object | { wa_id, profile: { name } }. Absent when Meta sent no matching contact record, do not assume it exists, and never read contact.profile.name without a guard. |
| timestamp | Yes | string | ISO 8601 time the platform forwarded the message, not the time the customer sent it. That is message.timestamp, a Unix seconds string. |
{
"phoneNumberId": "1030974986765331",
"from": "919876543210",
"type": "text",
"message": {
"from": "919876543210",
"id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhgU...",
"timestamp": "1785931200",
"type": "text",
"text": { "body": "Where is my order?" }
},
"contact": { "wa_id": "919876543210", "profile": { "name": "Alex" } },
"timestamp": "2026-08-05T10:00:00.000Z"
}Delivery behaviour
- Fire and forget
- The POST has a 5-second timeout and the platform does not wait for your response before continuing.
- No retries
- A timeout, a non-2xx, or a connection error means the message is not re-delivered. Ever. Acknowledge fast and process asynchronously.
- Your body is ignored
- Return 200 immediately. Nothing you send back is read.
- Messages only
- Status callbacks (
delivered,read) are not forwarded. PollGET /v1/notifications/status/:messageIdorGET /v1/messagesfor those.
There is no signature header
https://your-server.com/wa/inbound/9f2c1e7b4a8d3f6019e5c8b27a4d1f30- Serve it over HTTPS only. Over HTTP the token is the request line, in clear text.
- Keep it out of logs, error reports and analytics, a full request URL in a stack trace is the usual way one of these leaks.
- Rotate it with
PATCH /v1/phones/:phoneNumberId/webhookif it does leak. The old URL stops receiving the moment the new one is saved. - Compare with
crypto.timingSafeEqual, not===. String comparison returns early on the first differing byte, which leaks the token one byte at a time to anyone willing to measure. - Answer
404on a bad token, not403. A403confirms the path exists.
Because the payload is unauthenticated, never trust what it claims about itself. The only field you can act on is from, and only after you have verified that this sender is entitled to the thing they are asking for, which is what the example below does.
Handling a button reply
A customer taps a reply button you sent with type: "interactive". The forwarded payload:
{
"phoneNumberId": "1030974986765331",
"from": "919876543210",
"type": "interactive",
"message": {
"from": "919876543210",
"id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhgU...",
"timestamp": "1785931205",
"type": "interactive",
"context": { "from": "15556052643", "id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI..." },
"interactive": {
"type": "button_reply",
"button_reply": { "id": "confirm_cod:ORD-9001", "title": "Confirm" }
}
},
"contact": { "wa_id": "919876543210", "profile": { "name": "Alex" } },
"timestamp": "2026-08-05T10:00:05.000Z"
}message.context.id is the messageId of the message the customer replied to. Use it to tie the answer back to the exact question you asked, rather than to the customer's most recent order.
Verify the sender owns what the button acts on
A button id is data the client sends back, on an unauthenticated callback. It says which order the tap refers to; it does not prove the tapper is that order's customer. Anyone who learns your URL can post confirm_cod:ORD-9002 from any number.
So before acting, load the object the id names and check it belongs to from, and that it is still in a state where the action makes sense.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
const TOKEN = process.env.WA_WEBHOOK_TOKEN; // the random segment in the URL
app.post("/wa/inbound/:token", (req, res) => {
// Constant-time compare — a plain === leaks the token one byte at a time.
const given = Buffer.from(req.params.token);
const want = Buffer.from(TOKEN);
if (given.length !== want.length || !crypto.timingSafeEqual(given, want)) {
return res.sendStatus(404); // 404, not 403, do not confirm the path exists
}
// Acknowledge inside the 5-second budget, then work.
res.sendStatus(200);
const { from, type, message } = req.body;
if (type === "interactive" && message?.interactive?.type === "button_reply") {
handleButton(from, message.interactive.button_reply.id, message).catch(console.error);
return;
}
// Quick-reply buttons on a TEMPLATE arrive as type "button", not "interactive".
if (type === "button") {
handleButton(from, message.button.payload, message).catch(console.error);
return;
}
// Marketing templates carry "Reply STOP to unsubscribe" — you must honour it.
if (type === "text" && /^\s*stop\s*$/i.test(message.text.body)) {
optOut(from).catch(console.error);
}
});
async function handleButton(from, buttonId, message) {
// The wamid is stable, so this is the de-duplication key. Meta can deliver the
// same message twice; there are no retries from us, but there is no guarantee
// of exactly-once from upstream either.
if (await alreadyHandled(message.id)) return;
await markHandled(message.id);
const [action, orderId] = buttonId.split(":");
if (action !== "confirm_cod") return;
const order = await orders.findById(orderId);
if (!order) return;
// ── The check that matters ──────────────────────────────────────────────
// The payload is unauthenticated: the button id is whatever was POSTed to
// us. Confirm the sender is this order's customer before touching it, or a
// forged callback confirms somebody else's COD order.
//
// "from" is digits only, no "+", so normalise the stored number the same way.
const owner = order.customerPhone.replace(/\D/g, "");
if (owner !== from) {
console.warn("Button reply for an order the sender does not own", { orderId, from });
return;
}
// Tie the answer to the question actually asked, not to the latest order.
if (message.context?.id && message.context.id !== order.confirmationMessageId) {
console.warn("Reply context does not match the confirmation we sent", { orderId });
return;
}
// Only act when the order is still waiting for this answer.
if (order.status !== "awaiting_cod_confirmation") return;
await orders.confirm(orderId);
}Four checks, each closing a different hole:
- The token, is this callback from us at all.
- The de-duplication, has this exact
wamidalready been processed. - The ownership check, does the sender own the order the button names. This is the one that cannot be skipped, because nothing else in the payload is authenticated.
- The state check, is the order still waiting for this answer, so a replayed tap on an old message cannot re-open a shipped order.
Honouring STOP
Marketing templates carry an automatic Reply STOP to unsubscribe footer. The footer is added for you; acting on the reply is not.
A STOP arrives as an ordinary type: "text" message. Nothing suppresses future marketing sends to that number unless your own code records the opt-out and checks it before every cart_recovery, review_request and back_in_stock send. Continuing to message someone who has replied STOP is what drives a number's quality rating to RED, at which point Meta throttles everything you send, transactional messages included.
// Match generously: customers send "STOP", "stop.", " Stop " and "STOP ALL".
const STOP = /^\s*stop\b/i;
if (type === "text" && STOP.test(message.text.body)) {
await optOut(from);
}Testing your handler
You do not need a real inbound message to test the endpoint. POST the example payload above at your own URL with the correct token and confirm three things: it answers 200 in well under five seconds, it answers 404 when the token is wrong, and it ignores a payload whose from does not match the order named in the button id.
curl -X POST https://your-server.com/wa/inbound/9f2c1e7b4a8d \
-H "Content-Type: application/json" \
-d '{
"phoneNumberId": "1030974986765331",
"from": "919876543210",
"type": "text",
"message": {
"from": "919876543210",
"id": "wamid.test-1",
"timestamp": "1785931200",
"type": "text",
"text": { "body": "Where is my order?" }
},
"timestamp": "2026-08-05T10:00:00.000Z"
}'Note that the test payload omits contact, which is exactly what a real delivery does when Meta sends no matching contact record. If your handler needs a name, it has to cope without one.
