Back to blog
Chatmaid Developers··11 min read

Build a WhatsApp AI Customer Service Agent with n8n

Build an AI customer service agent for WhatsApp in n8n, on your own number with no Meta verification: webhook in, AI reply out, plus human handoff.

Chatmaid Team
Build a WhatsApp AI Customer Service Agent with n8n

To build a WhatsApp customer service agent in n8n, you need one webhook and two HTTP calls. Chatmaid sends a message.received webhook to n8n when a customer writes to your number. n8n asks an AI model for a reply. Then n8n posts that reply to Chatmaid's POST /v1/messages/send endpoint, which delivers it from the same number. Chatmaid Developers connects your own WhatsApp number with a QR scan, through the same protocol WhatsApp Web uses, so you don't need Meta Business verification. The workflow runs around the clock, and this guide builds it node by node.

What you'll build

An n8n workflow that:

  1. Receives incoming customer messages through a Chatmaid webhook
  2. Checks that each request really came from Chatmaid
  3. Sends the message to an AI model (OpenAI's gpt-6-luna in this guide) for a reply
  4. Sends the reply back to the customer on WhatsApp through Chatmaid
  5. Logs each exchange to Google Sheets

When the model can't answer, the workflow hands the conversation to a person. It alerts your team in Slack and tells the customer that someone will follow up. It can also pause itself when a team member replies from the phone.

What you'll need

  • A Chatmaid Developers account. The sandbox (sk_test_ keys) is free and simulates sends, so you can build the reply side for free. Customer messages only reach a live number. Connecting a live number and replying with a live key (sk_live_) both need an active subscription.
  • A WhatsApp number linked to Chatmaid. Add the number in the dashboard and scan the QR code with the WhatsApp app. Sandbox numbers connect automatically, without a scan.
  • n8n, either n8n Cloud or self-hosted. Chatmaid only accepts HTTPS webhook URLs, so a self-hosted instance must be reachable over HTTPS.
  • An OpenAI API key, or a key for another model provider you prefer.

How Chatmaid webhooks work

You choose your webhook URL and events on the Webhooks page of the Chatmaid dashboard. When you save, the dashboard shows your webhook secret, so copy it right away. For this agent, tick Message Received (message.received). Later you'll also tick Message Sent From Phone (message.outgoing) for the human handoff.

When a customer writes to your connected number, Chatmaid POSTs this to your URL:

{
  "event": "message.received",
  "timestamp": "2026-10-19T14:21:09.000Z",
  "data": {
    "messageId": "inmsg_abc123def456",
    "from": "+15557654321",
    "to": "+15551234567",
    "content": "Hi, where is my order #4892?",
    "type": "text",
    "isGroup": false,
    "groupId": null,
    "receivedAt": "2026-10-19T14:21:08.000Z"
  }
}

from is the customer and to is your connected number. Every request carries an X-Chatmaid-Event header and an X-Chatmaid-Signature header in the form t=<unix time>,v1=<hex>. The v1 value is an HMAC-SHA256 of the timestamp, a dot and the raw body, signed with your webhook secret.

Chatmaid makes up to 3 attempts to deliver each event, with backoff between them, so the same event can arrive twice. The workflow below uses the inbound messageId to make sure a customer never gets the same answer twice. The webhook guide and the webhooks reference cover every event and field.

Building the n8n workflow

The names in quotes below are node names. The expressions refer to them, so name your nodes the same way or update the expressions.

Node 1: Webhook trigger

Add a Webhook node:

  • HTTP Method: POST
  • Path: chatmaid
  • Respond: Immediately

n8n gives the node a Test URL and a Production URL. Paste the Production URL (it looks like https://your-n8n-host/webhook/chatmaid) into the Chatmaid Webhooks page. It only receives events while the workflow is published. Responding immediately sends Chatmaid a 2xx right away, which is what the docs ask for, and the rest of the workflow runs after that.

n8n's Webhook node puts the request body under body and the headers under headers. That's why the expressions below start with $json.body.

Node 2: "Verify Signature" (Code node)

Anyone who learns your webhook URL could post fake messages to it. A Code node can check the signature before anything else runs. The crypto module is available on n8n Cloud. On self-hosted n8n, allow it with NODE_FUNCTION_ALLOW_BUILTIN=crypto (set it on the task runners if your instance uses them). Leave the mode on Run Once for All Items:

const crypto = require('crypto');

const WEBHOOK_SECRET = 'whsec_xxx'; // from the Chatmaid Webhooks page

const req = $input.first().json;
const header = req.headers['x-chatmaid-signature'] || '';
const parts = Object.fromEntries(
  header.split(',').map((pair) => {
    const i = pair.indexOf('=');
    return [pair.slice(0, i), pair.slice(i + 1)];
  })
);

const expected = crypto
  .createHmac('sha256', WEBHOOK_SECRET)
  .update(`${parts.t}.${JSON.stringify(req.body)}`)
  .digest('hex');

// Hash both values so timingSafeEqual always compares equal-length buffers
const a = crypto.createHash('sha256').update(String(parts.v1 || '')).digest();
const b = crypto.createHash('sha256').update(expected).digest();

if (!crypto.timingSafeEqual(a, b)) {
  throw new Error('Invalid Chatmaid webhook signature');
}

return $input.all();

Leave the Webhook node's Raw Body option off. The code re-serializes the parsed body, which reproduces the signed bytes exactly because Chatmaid sends compact JSON.

Don't reject a request because its timestamp is old. Chatmaid signs each event once, so a retried delivery carries the original timestamp.

Node 3: "Customer Messages Only" (If node)

Every event you subscribe to arrives at the same URL, including the test event from the dashboard's Test Webhook button. Group messages also reach you as message.received, with isGroup set to true, and so do reactions (more on those in the media section). Add an If node with one condition. Put this expression in the value field and choose the Boolean operator is true:

{{ $json.body.event === 'message.received' && $json.body.data.isGroup === false && $json.body.data.type !== 'other' }}

Leave the false output unconnected.

Node 4: "Extract Message" (Edit Fields node)

Add an Edit Fields (Set) node with these fields:

  • customerPhone: {{ $json.body.data.from }}
  • customerMessage: {{ $json.body.data.content }}
  • messageType: {{ $json.body.data.type }}
  • inboundId: {{ $json.body.data.messageId }}
  • receivedAt: {{ $json.body.data.receivedAt }}

Node 5: "AI Response" (HTTP Request node)

Add an HTTP Request node that calls OpenAI:

  • Method: POST
  • URL: https://api.openai.com/v1/chat/completions
  • Authentication: Generic credential type, Header Auth. Set the name to Authorization and the value to Bearer followed by your OpenAI key.
  • Send Body: on, Body Content Type JSON, Specify Body Using JSON

Paste this into the JSON field. It contains n8n expressions, so the field switches to expression mode:

{
  "model": "gpt-6-luna",
  "reasoning_effort": "none",
  "max_completion_tokens": 300,
  "messages": [
    {
      "role": "system",
      "content": "You are the customer service agent for <your company>. Answer questions about orders, products, shipping and returns using only these facts:\n- Shipping: <your shipping times>\n- Returns: <your return policy>\nKeep replies short and friendly, and reply in the language the customer writes in. If you cannot answer from these facts, or the customer asks for a refund or a person, reply with only the word ESCALATE."
    },
    {
      "role": "user",
      "content": {{ JSON.stringify($('Extract Message').item.json.customerMessage || '[The customer sent a message of type ' + $('Extract Message').item.json.messageType + ' with no text]') }}
    }
  ]
}

As of October 2026, OpenAI is retiring GPT-4o on January 14, 2027 and lists gpt-6-luna as its replacement (OpenAI deprecations). It is a reasoning model. Setting reasoning_effort to none turns reasoning off, so max_completion_tokens caps the reply itself. If you raise the effort, remove the cap or raise it a lot: hidden reasoning counts toward it, and a low cap can return an empty reply.

JSON.stringify keeps the body valid when a customer's message contains quotes or line breaks. The || fallback covers photos and voice notes that come without a caption (see the media section below). The reply text comes back at {{ $json.choices[0].message.content }}.

Node 6: "Send Reply" (HTTP Request node)

Add another HTTP Request node:

  • Method: POST
  • URL: https://developers-api.chatmaid.net/v1/messages/send
  • Authentication: Generic credential type, Header Auth. Set the name to Authorization and the value to Bearer sk_live_xxx (use your sk_test_ key while you build).
  • Send Body: on, Body Content Type JSON, Specify Body Using Fields Below
NameValue
fromPhoneId+15551234567
to{{ $('Extract Message').item.json.customerPhone }}
content{{ $('AI Response').item.json.choices[0].message.content }}
idempotencyKey{{ $('Extract Message').item.json.inboundId }}

fromPhoneId is your connected number in E.164 format, or its ID from GET /v1/phone-numbers. Each key only sees phones in its own environment, so a test key needs your sandbox phone and a live key needs your live phone.

idempotencyKey handles retries. If the same webhook arrives twice, Chatmaid returns the message it already sent instead of sending a second one. If a send failed at WhatsApp, repeating it with the same key returns that same error rather than trying again. Requests Chatmaid rejected before sending, for example because the phone was disconnected, aren't stored, so a retry does send. A successful call returns { "success": true, "data": { "id": "msg_...", "status": "sent", ... } }.

Before you go on, check your key and fromPhoneId with one call. Sandbox sends are simulated, so nothing reaches WhatsApp:

curl -X POST https://developers-api.chatmaid.net/v1/messages/send \
  -H "Authorization: Bearer sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "fromPhoneId": "+15551234567",
    "to": "+15557654321",
    "content": "Test reply from the n8n agent"
  }'

Node 7: "Log Conversation" (Google Sheets node, optional)

Connect a Google Sheets node with the Append Row operation after Send Reply:

ColumnValue
Received at{{ $('Extract Message').item.json.receivedAt }}
Customer phone{{ $('Extract Message').item.json.customerPhone }}
Customer message{{ $('Extract Message').item.json.customerMessage }}
Agent reply{{ $('AI Response').item.json.choices[0].message.content }}
Chatmaid message ID{{ $json.data.id }}

Under Options > Cell Format, choose Let n8n format. The default, Let Google Sheets format, turns +15557654321 into a number without its plus sign, so any later lookup by phone number stops matching.

Adding conversation memory

The workflow above treats every message on its own. Customers expect the agent to remember what they said two messages ago.

The simplest fix is to read this customer's earlier rows from the sheet you already log to, before AI Response, and add them to messages as alternating user and assistant turns. Turn on Always Output Data in that Google Sheets node's Settings: a first-time customer has no rows, and without it the workflow stops before replying. How to add conversation memory to your WhatsApp AI agent has the nodes and code for this workflow.

If you'd rather not manage history yourself, replace AI Response with n8n's AI Agent node and attach an OpenAI Chat Model sub-node and a Simple Memory sub-node. In Simple Memory, set Session ID to Define below and its Key to {{ $('Extract Message').item.json.customerPhone }}. Put the system prompt in the agent's System Message option. The agent's reply is at output, so change $('AI Response').item.json.choices[0].message.content to $('AI Agent').item.json.output in Send Reply and Log Conversation, and $json.choices[0].message.content to $json.output in Needs Human? below. n8n warns not to use Simple Memory when the instance runs in queue mode.

Escalation logic

Some messages need a person. The system prompt tells the model to answer with only ESCALATE when it can't help, or when the customer asks for a refund or a human. Put an If node named "Needs Human?" between AI Response and Send Reply, with this condition (Boolean, is true):

{{ ($json.choices[0].message.content || 'ESCALATE').includes('ESCALATE') }}

The check also catches an empty reply and variations like ESCALATE., so the code word never reaches a customer.

  • False: continue to Send Reply as before.
  • True: send a Slack message to your support channel with the customer's number and message. Then send the customer a fixed holding message, such as "Thanks, a member of our team will reply here shortly." Use a copy of the Send Reply node with that text as content.

Pause the agent when a person takes over

When someone on your team replies from the phone itself, the bot should stop answering that customer. Chatmaid reports these messages as message.outgoing. Chatmaid recognises messages sent through the API and reports them as message.sent, so the agent's own replies don't normally trigger it.

  1. Tick Message Sent From Phone on the Chatmaid Webhooks page.
  2. Add a second If node after Verify Signature that checks {{ $json.body.event === 'message.outgoing' }}. On its true branch, add a Google Sheets node with Append or Update Row on a Paused tab, matching on the customer column. Set customer to {{ $json.body.data.to }} and sentAt to {{ $json.body.data.sentAt }}, and set Cell Format to Let n8n format. Each customer then has one row that holds the latest time.
  3. Between Extract Message and AI Response, add a Google Sheets Get Row(s) node on that tab, filtered on customer = {{ $json.customerPhone }}. In its Settings, turn on Always Output Data: most customers have no row, and without it the workflow stops before replying. Follow it with an If node (Boolean, is true) on {{ !$json.sentAt || DateTime.fromISO($json.sentAt) < $now.minus({ hours: 2 }) }}. Connect true to AI Response and leave false unconnected.

Handling media messages

Customers send photos, voice notes and documents. As of October 2026, the message.received payload doesn't include the file itself. It tells you what arrived through type, which is one of text, image, video, audio, document, sticker, location, contact or other. For media, content carries the caption when there is one.

That's why Node 5 has a fallback. When a photo arrives without a caption, the model is told a photo came in and can ask the customer to describe it or answer ESCALATE. Photos of damaged goods or receipts are usually better handled by a person anyway. You can also route every non-text type except other straight to the escalation branch with one more If node.

Reactions, edited messages and deleted messages can also arrive as message.received, with type set to other and no content. The filter in Node 3 drops them, so a thumbs-up on the agent's reply doesn't start a new answer or page your team.

Sending media works in the other direction. Add a mediaUrls field to the send body with one HTTPS link, such as a PDF of your return policy, and content becomes its caption. With several links, content is sent first as a separate text message. Plain http:// links are rejected.

Testing the workflow

1. Run the workflow in the editor

Click Listen for Test Event on the Webhook node, deactivate the Verify Signature node for this run, and post a sample event to the Test URL:

curl -X POST https://your-n8n-host/webhook-test/chatmaid \
  -H "Content-Type: application/json" \
  -d '{
    "event": "message.received",
    "timestamp": "2026-10-19T14:21:09.000Z",
    "data": {
      "messageId": "inmsg_test0001",
      "from": "+15557654321",
      "to": "+15551234567",
      "content": "Hi, where is my order?",
      "type": "text",
      "isGroup": false,
      "groupId": null,
      "receivedAt": "2026-10-19T14:21:08.000Z"
    }
  }'

The messageId becomes the idempotency key, so change it for each run. Otherwise Chatmaid returns the first test reply instead of sending a new one.

2. Check the signature path

Turn Verify Signature back on, publish the workflow, and click Test Webhook in the Chatmaid dashboard. The test event is signed, so it should pass verification and then stop at the If node.

3. Go live

Customers can only write to a live number. Sandbox numbers never receive messages. Switch Send Reply and its escalation copy to your sk_live_ key and live fromPhoneId. Then message your connected number from a different phone and watch the run in the workflow's Executions tab.

What this costs

  • Chatmaid Developers: the sandbox is free. Production is $7.99 a month, flat. See pricing.
  • n8n: n8n Cloud is a paid subscription, and you can also self-host n8n. Check n8n's pricing page for current plans.
  • AI model: you pay your provider per token. As of October 2026, gpt-6-luna costs $0.10 per million input tokens and $0.50 per million output tokens (model page). Capping replies with max_completion_tokens keeps each answer short and cheap.

If you build in Make instead of n8n, the same loop works there. See how to connect Make to WhatsApp with Chatmaid. If you want the agent to qualify leads and book calls rather than answer support questions, see how to build a WhatsApp sales agent with n8n.

Frequently asked questions

Do I need the WhatsApp Business API or Meta verification?

No. Chatmaid connects your existing WhatsApp number with a QR scan, the way WhatsApp Web does, so there's no Meta Business verification step. WhatsApp API without Meta Business verification explains the difference.

Can I build this without paying?

Mostly. The Chatmaid sandbox is free and simulates sends, and n8n can be self-hosted instead of using n8n Cloud. OpenAI bills per token from the first call. Real customer messages only reach a live number, and connecting one needs the $7.99 a month Chatmaid Developers subscription.

Can the agent see photos or hear voice notes?

Not as of October 2026. The webhook tells you the message type and passes along any caption, but not the file. Route media to a person, or have the agent ask the customer to describe it in text.

Will the agent reply to WhatsApp groups?

Not with the filter in this guide. Group messages arrive with isGroup set to true and a groupId. To reply in a group, pass groupId as to in the send request. Your number must be a member of the group and allowed to post in it.

How do I stop the bot when a team member steps in?

Subscribe to message.outgoing. It fires when someone sends a message from the phone itself, so the workflow can pause replies to that customer.

Can my number get restricted for using an AI agent?

It's very unlikely with normal use. WhatsApp restricts accounts that behave like spam, such as unsolicited bulk messages to people who don't have you saved. An agent that only answers customers who wrote to you first is the normal case.