Back to blog
Chatmaid Developers··11 min read

How to Receive and Reply to WhatsApp Messages via Webhook

Get incoming WhatsApp messages in your app with Chatmaid webhooks: register a URL, read the payload, verify the signature and reply from Node.js or Python.

Chatmaid Team
How to Receive and Reply to WhatsApp Messages via Webhook

To receive WhatsApp messages in your own app, connect your number to Chatmaid, add an HTTPS URL on the Webhooks page of the dashboard and subscribe to the message.received event. Each time someone messages that number, Chatmaid sends a signed JSON POST to your URL with the sender, the text and the message type. Your server checks the X-Chatmaid-Signature header, answers with a 2xx status, and can reply through POST /v1/messages/send. You don't run a WhatsApp session or a WebSocket yourself.

This guide, current as of October 2026, covers the setup, the exact payload, signature checks in Node.js and Python, retries, and a working receive-and-reply example.

How Chatmaid webhooks work

You register one webhook URL per account and choose which events it receives. Chatmaid can send these events:

EventWhen it fires
message.receivedSomeone sent a message to your connected number, in a direct chat or a group
message.outgoingSomeone typed a message on the connected phone itself, not through the API
message.sentA message you sent through the API went out
message.deliveredA message you sent reached the recipient's device
message.readThe recipient read a message you sent (only if they have read receipts on)
message.failedA message you sent could not be delivered
phone.connectedOne of your phone numbers connected to WhatsApp
phone.disconnectedOne of your phone numbers disconnected from WhatsApp

For a chatbot, support inbox or AI agent, message.received is the one that matters. Add message.outgoing if a person also answers from the phone, so your bot knows when to step back.

Every event arrives as a POST with a JSON body of the form { "event", "timestamp", "data" } and three headers:

  • Content-Type: application/json
  • X-Chatmaid-Event: the event name
  • X-Chatmaid-Signature: an HMAC-SHA256 signature in the form t=<unix time>,v1=<hex>

Setting up your webhook

Before you start. Only live numbers receive messages, and live numbers and live API keys need the Chatmaid Developers plan ($7.99 a month flat). Connect your WhatsApp number on the Phone Numbers page of the Developers dashboard by scanning the QR code. Then create a live key on the API Keys page. Its default scopes, Send Messages and Read Messages, cover everything in this guide.

Step 1: Expose a public HTTPS endpoint

Chatmaid only accepts https:// URLs, so your server must be reachable from the internet. For local development, a tunnel does the job. With ngrok (free account; run ngrok config add-authtoken <your-token> once):

ngrok http 3000

Or with a Cloudflare Quick Tunnel, which needs no Cloudflare account:

cloudflared tunnel --url http://localhost:3000

Both print a public HTTPS address. Your webhook URL is that address plus the route you'll build in step 3, for example https://your-tunnel-address/webhooks/chatmaid. A Quick Tunnel gets a new address every time you start it, so update the URL in Chatmaid when it changes.

Step 2: Register the URL in the Chatmaid dashboard

In the Chatmaid Developers dashboard:

  1. Open Webhooks in the sidebar.
  2. Paste your URL into Webhook URL.
  3. Under Events to receive, tick Message Received. Tick Message Sent From Phone too if a person will also reply from the phone.
  4. Click Configure Webhook.
  5. Copy the secret shown under Your webhook secret (it starts with whsec_) and store it as the CHATMAID_WEBHOOK_SECRET environment variable. The dashboard warns that you won't see it again. If you lose it, Regenerate Secret issues a new one. New deliveries are signed with it immediately, but retries of deliveries that were already pending still carry the old signature, so expect a few rejected retries right after you switch.

Store your live API key (it starts with sk_live_) as CHATMAID_API_KEY. The examples use it to send replies.

Step 3: Build the endpoint

This Express server needs only Node.js 18 or later and npm install express. Save it as server.mjs and run node server.mjs:

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const WEBHOOK_SECRET = process.env.CHATMAID_WEBHOOK_SECRET;
const API_KEY = process.env.CHATMAID_API_KEY;
const TOLERANCE_SECONDS = 15 * 60; // retries keep the original timestamp

if (!WEBHOOK_SECRET || !API_KEY) {
  throw new Error("Set CHATMAID_WEBHOOK_SECRET and CHATMAID_API_KEY first");
}

const app = express();

function isValidSignature(rawBody, header) {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(",").map((part) => {
      const i = part.indexOf("=");
      return [part.slice(0, i).trim(), part.slice(i + 1).trim()];
    }),
  );
  const timestamp = Number(parts.t);
  if (!parts.v1 || !Number.isInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", WEBHOOK_SECRET)
    .update(`${parts.t}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && timingSafeEqual(a, b);
}

// express.raw keeps the exact bytes Chatmaid signed
app.post("/webhooks/chatmaid", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.get("x-chatmaid-signature");
  if (!Buffer.isBuffer(req.body) || !isValidSignature(req.body, signature)) {
    return res.status(401).json({ error: "invalid signature" });
  }

  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200); // acknowledge first, then do the work

  handleEvent(event).catch((err) => console.error("webhook handler failed:", err));
});

async function handleEvent(event) {
  if (event.event !== "message.received") return;

  const { messageId, from, type, content } = event.data;
  console.log(`${type} message ${messageId} from ${from}: ${content ?? "(no text)"}`);

  // Your logic goes here: an AI reply, a database lookup, a handoff to a person.
}

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

Two details matter here. The route uses express.raw instead of express.json, because the signature covers the exact bytes Chatmaid sent, and re-serializing parsed JSON is not guaranteed to reproduce them. If your app calls app.use(express.json()) globally, register it after this route. And the handler answers 200 before doing any slow work, such as an AI call, so the delivery never times out.

On serverless platforms, work that continues after the response can be cut off. There, put the event on a queue and process it in a background job.

Step 3 in Python (Flask)

The same receiver in Python, including the reply. Install the dependencies with pip install flask requests, save the file as app.py and run python app.py:

import hashlib
import hmac
import json
import os
import threading
import time

import requests
from flask import Flask, abort, request

WEBHOOK_SECRET = os.environ["CHATMAID_WEBHOOK_SECRET"]
API_KEY = os.environ["CHATMAID_API_KEY"]
TOLERANCE_SECONDS = 15 * 60  # retries keep the original timestamp

app = Flask(__name__)


def is_valid_signature(raw_body: bytes, header: str) -> bool:
    try:
        parts = dict(item.strip().split("=", 1) for item in header.split(","))
        timestamp = int(parts["t"])
        provided = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(WEBHOOK_SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(provided, expected)


def handle_event(event: dict) -> None:
    if event.get("event") != "message.received":
        return
    msg = event["data"]
    if msg["isGroup"]:
        return  # this example only answers direct chats

    response = requests.post(
        "https://developers-api.chatmaid.net/v1/messages/send",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={
            "fromPhoneId": msg["to"],
            "to": msg["from"],
            "content": "Thanks, we got your message.",
            "idempotencyKey": f"reply-{msg['messageId']}",
        },
        timeout=30,
    )
    if not response.ok:
        print("send failed:", response.status_code, response.text)


@app.post("/webhooks/chatmaid")
def chatmaid_webhook():
    raw_body = request.get_data()
    if not is_valid_signature(raw_body, request.headers.get("X-Chatmaid-Signature", "")):
        abort(401)
    event = json.loads(raw_body)
    # Answer right away and do the work in the background
    threading.Thread(target=handle_event, args=(event,), daemon=True).start()
    return "", 200


if __name__ == "__main__":
    app.run(port=3000)

A thread is enough to try things out. In production, hand the event to a task queue such as Celery or RQ. For the sending side in Flask, the guide to WhatsApp notifications with Python and Flask goes further.

The message.received payload

A direct message to your number arrives like this:

{
  "event": "message.received",
  "timestamp": "2026-10-22T14:21:09.000Z",
  "data": {
    "messageId": "inmsg_8f2c41d9a7b3e6f015c2d4a9",
    "from": "+15557654321",
    "to": "+15551234567",
    "content": "Hi, what are your opening hours?",
    "type": "text",
    "isGroup": false,
    "groupId": null,
    "receivedAt": "2026-10-22T14:21:08.000Z"
  }
}

What each field in data means:

FieldMeaning
messageIdChatmaid's ID for the inbound message, starting with inmsg_. Use it to spot duplicates.
fromThe sender's number in E.164 format
toYour connected number that received the message
contentThe text, or the caption of a media message. null when there is none.
typetext, image, video, audio, document, sticker, location, contact or other
isGrouptrue when the message was posted in a WhatsApp group
groupIdThe group's ID (ending in @g.us) for group messages, otherwise null
receivedAtWhen the message arrived, in ISO 8601

For a photo, voice note or file, type tells you what was sent and content holds the caption, if there is one. As of October 2026, the payload does not include the media file or a link to download it.

In a group, from identifies the member who wrote and groupId identifies the group. To answer in the group rather than privately, send your reply to the groupId.

How to verify the X-Chatmaid-Signature header

Without a signature check, anyone who finds your URL can post fake events to it. Each Chatmaid delivery is signed with your webhook secret:

  1. Split the header t=1792678869,v1=5d41... into the timestamp t and the signature v1.
  2. Build the signed string: the timestamp, a period, then the raw request body (t + "." + body).
  3. Compute HMAC-SHA256 of that string with your webhook secret as the key, as lowercase hex.
  4. Compare it to v1 with a constant-time comparison, and reject the request if they differ.
  5. Optionally, reject timestamps that are too old. Retries reuse the timestamp of the first attempt, so allow at least 15 minutes, or late retries will fail your check.

The isValidSignature function and its Python twin is_valid_signature in step 3 do exactly this. The webhooks reference has the same check in other forms.

Using the JavaScript SDK

The official SDK, @chatmaid/sdk, has a verifyWebhook helper that checks the signature and returns a typed event. It throws a WebhookVerificationError when the check fails. Install it with npm install express @chatmaid/sdk (Node.js 18 or later) and save the file as server.mjs:

import express from "express";
import { Chatmaid, verifyWebhook, WebhookVerificationError } from "@chatmaid/sdk";

const cm = new Chatmaid({ apiKey: process.env.CHATMAID_API_KEY });
const app = express();

app.post("/webhooks/chatmaid", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = verifyWebhook({
      body: req.body,
      signature: req.get("x-chatmaid-signature"),
      secret: process.env.CHATMAID_WEBHOOK_SECRET,
      toleranceSeconds: 15 * 60,
    });
  } catch (err) {
    if (err instanceof WebhookVerificationError) return res.status(400).send(err.code);
    throw err;
  }

  res.sendStatus(200);

  if (event.event === "message.received" && !event.data.isGroup) {
    cm.messages
      .send({
        fromPhoneId: event.data.to,
        to: event.data.from,
        content: "Thanks, we got your message.",
        idempotencyKey: `reply-${event.data.messageId}`,
      })
      .catch((err) => console.error("reply failed:", err));
  }
});

app.listen(3000);

Keep two things in mind:

  • Pass toleranceSeconds. The default window is 5 minutes, which is shorter than the retry schedule, so the last retry of a failed delivery would be rejected.
  • message.outgoing is not recognized yet. Version 0.3.0, the latest on npm, treats it as an unknown event, so the example above would answer it with a 400 and Chatmaid would retry the delivery. If you subscribe to it, check signatures with your own function, like the one in step 3.

How to reply to an incoming message

A reply is a normal send. Use the to of the incoming message (your connected number) as fromPhoneId, and its from as the new to. With cURL:

curl -X POST https://developers-api.chatmaid.net/v1/messages/send \
  -H "Authorization: Bearer sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "fromPhoneId": "+15551234567",
    "to": "+15557654321",
    "content": "Thanks, we got your message.",
    "idempotencyKey": "reply-inmsg_8f2c41d9a7b3e6f015c2d4a9"
  }'

Here is the handleEvent function from step 3, extended into a small FAQ bot. Replace the one in server.mjs with it:

const handedOff = new Set(); // chats a person has taken over

async function handleEvent(event) {
  if (event.event !== "message.received") return;

  const msg = event.data;
  if (msg.isGroup || handedOff.has(msg.from)) return; // direct chats only, until a person takes over

  const text = (msg.content ?? "").toLowerCase();
  const answers = {
    hours: "We're open Monday to Friday, 9am to 6pm.",
    address: "You'll find us at 12 Example Street.",
    human: "Got it. A member of the team will reply here shortly.",
  };
  const keyword = Object.keys(answers).find((k) => text.includes(k));
  if (keyword === "human") handedOff.add(msg.from);
  const reply = keyword
    ? answers[keyword]
    : "Thanks for your message. Reply HOURS, ADDRESS or HUMAN.";

  const response = await fetch("https://developers-api.chatmaid.net/v1/messages/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      fromPhoneId: msg.to, // your connected number
      to: msg.from,
      content: reply,
      idempotencyKey: `reply-${msg.messageId}`,
    }),
  });

  if (!response.ok) {
    console.error("send failed:", response.status, await response.text());
  }
}

The idempotencyKey is built from the inbound messageId. If the same event reaches you twice, the second send returns the first message instead of answering twice. To reply inside a group, send to msg.groupId instead of msg.from. The connected number has to be a member that is allowed to post there.

Once someone asks for HUMAN, the bot stops answering that chat, so it doesn't talk over the person who takes over. The bot answers every direct message the number receives, so run it on the number customers write to, not a personal one. In production, keep the hand-off list in your database, and also add a chat to it when message.outgoing shows a teammate answered from the phone.

Replies come from your own number, so normal WhatsApp rules apply. Account restrictions are very unlikely with normal use; they happen when a number behaves like spam, such as sending unsolicited bulk messages to people who don't have you saved. A bot that answers people who wrote to you first is the low-risk kind.

Retries, timeouts and missed messages

A delivery attempt fails when your endpoint returns a status outside 2xx, can't be reached, or doesn't answer within 30 seconds. As of October 2026, Chatmaid makes up to three attempts: one right away, one about a minute later, and a last one about five minutes after that. Then the delivery is marked as failed. The Recent Deliveries list on the Webhooks page shows each delivery's status, event, HTTP code, response time and when it was sent.

This has three consequences for your code:

  • Expect duplicates. If your server handled an event but answered too late, the retry delivers it again. Check data.messageId before acting, and use idempotency keys on replies.
  • Keep the timestamp window wide. The t in the signature comes from the first attempt, so a 5-minute window rejects the last retry. Use 15 minutes.
  • Recover from downtime by listing messages. Chatmaid stores each incoming message before it tries your webhook, so if your endpoint was down, fetch what you missed, newest first:
curl "https://developers-api.chatmaid.net/v1/messages/inbound?limit=100" \
  -H "Authorization: Bearer sk_live_your_api_key"

The endpoint takes page, limit (up to 100) and phoneNumberId (the number's ID from GET /v1/phone-numbers or the dashboard, not its +E.164 number), and needs a key with the messages:read scope. Each item's id is the same inmsg_ value the webhook sent as data.messageId, so you can skip the ones you already handled. Webhooks are optional: if you can't expose a public URL at all, polling this endpoint works too, just not in real time.

Testing without waiting for a real message

The Test Webhook button. On the Webhooks page, it sends a signed sample event to your URL and shows whether your server answered with a 2xx status and how long it took. The sample has X-Chatmaid-Event: test, a body whose event is message.sent, and "test": true inside data. The examples above ignore it, since they only act on message.received.

Sandbox keys. Test keys (sk_test_) are free. Sends made with them are simulated and still fire message.sent to your webhook URL. Inbound messages only exist on live numbers, though, so the sandbox never produces message.received. To test receiving end to end, connect a live number and message it from another phone.

A signed request from your terminal. This builds a valid signature with openssl and posts a fake message.received to your local server. Put your own second phone number in from and your connected number in to, because the FAQ bot above will send a real answer. Change messageId on each run, since the reply's idempotency key comes from it and a repeated ID returns the earlier reply instead of sending a new one:

SECRET="whsec_your_webhook_secret"
BODY='{"event":"message.received","timestamp":"2026-10-22T12:00:00.000Z","data":{"messageId":"inmsg_localtest0001","from":"+15557654321","to":"+15551234567","content":"hours?","type":"text","isGroup":false,"groupId":null,"receivedAt":"2026-10-22T12:00:00.000Z"}}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

curl -X POST http://localhost:3000/webhooks/chatmaid \
  -H "Content-Type: application/json" \
  -H "X-Chatmaid-Event: message.received" \
  -H "X-Chatmaid-Signature: t=$T,v1=$SIG" \
  -d "$BODY"

Receiving WhatsApp messages in n8n

In n8n, start the workflow with a Webhook node, set HTTP Method to POST, and paste the node's Production URL into Chatmaid. The Test URL only answers while the editor is listening for a test event, and the Production URL only works after you publish (activate) the workflow. The request body sits under body in the node's output, so the event name is at body.event and the message text at body.data.content.

The Webhook node's built-in authentication options (basic auth, header auth and JWT) don't check an HMAC signature such as X-Chatmaid-Signature. To check it, add a Code node that rebuilds the signed string with JSON.stringify(body): Chatmaid sends compact JSON, so re-serializing the parsed body in JavaScript reproduces the signed bytes, and you can leave the node's Raw Body option off. On self-hosted n8n, set NODE_FUNCTION_ALLOW_BUILTIN=crypto so the Code node can load crypto. The Production URL must be HTTPS, because Chatmaid rejects plain http:// URLs. The n8n customer service agent guide has the Code node and a complete workflow built on this trigger.

What you can build with incoming messages

Autoresponder and keyword routing. Answer new messages with a fixed reply or a short menu, or route keywords such as HELP, ORDER and AGENT to different handlers, like the FAQ bot above.

AI agent. Pass data.content to a language model and send its answer back. The guide to connecting an AI agent to WhatsApp covers Claude, ChatGPT and Gemini, and adding conversation memory shows how to keep context between messages.

Lead capture. Save every new sender to your CRM, send a welcome message, and alert your sales team.

Human takeover. Subscribe to message.outgoing. When someone on your team answers from the phone, pause the bot for that chat so it doesn't talk over them.

Internal commands. Let your team trigger jobs or ask for status by messaging a number, and answer with the result. The guide to WhatsApp ops alerts covers alerts and command-style workflows.

The Chatmaid Developers API costs $7.99 a month flat, and the sandbox is free. See pricing for every Chatmaid plan.

Frequently asked questions

Can I receive WhatsApp messages in the free sandbox?

No. Inbound messages only exist on live numbers, so sandbox keys never trigger message.received, and their inbound list is always empty. The sandbox is still useful for checking your endpoint: simulated sends fire message.sent, and the Test Webhook button sends a signed sample.

Do I need Meta Business verification or the WhatsApp Business API to receive messages?

No. Chatmaid connects your own WhatsApp number by QR code, the way WhatsApp Web does, so there is no Meta Business verification and no template approval. The guide to the WhatsApp API without Meta Business verification explains the trade-offs. Because it is a normal WhatsApp account, only message people who expect to hear from you.

Do webhooks include images, voice notes and documents?

They tell you a media message arrived: type is image, video, audio, document or sticker, and content holds the caption if the sender added one. The file itself is not included in the payload.

What happens if my server is down when a message arrives?

Chatmaid tries the delivery up to three times over about six minutes, then marks it as failed. The message is still stored, so you can fetch it later with GET /v1/messages/inbound.

Can I receive messages from WhatsApp groups?

Yes. Group messages arrive as message.received with isGroup set to true and the group's ID in groupId. Send your reply to that groupId to answer in the group.

Can I receive WhatsApp messages without a webhook?

Yes. Poll GET /v1/messages/inbound with a live key that has the messages:read scope. It returns stored inbound messages newest first, up to 100 per page. A webhook is faster, because it arrives as the message does.