n8n WhatsApp AI agent: a production setup, step by step
Build an n8n WhatsApp AI agent that holds up in production: signed webhook, instant 200, duplicate protection, AI Agent node with memory, retried replies, handoff.

Key takeaways
- An n8n WhatsApp AI agent needs only a Webhook node, a Code node that verifies the signature, an AI Agent node with memory, and an HTTP Request node that sends the reply.
- Turn on the Webhook node's Raw Body option, because the signature covers the exact bytes whasp sent and a re-serialized JSON body can fail the check.
- Answer 200 right after verification, drop duplicates with a Remove Duplicates node keyed on the event id, and send replies with an Idempotency-Key so a retry never sends twice.
- Use Postgres Chat Memory or another database-backed memory in production, because Simple Memory lives in n8n's process memory and forgets a chat after an hour of silence.
- Human handoff lives in n8n, not in whasp: keep a per-chat flag in a Data table and check it before every AI call.
To build an n8n WhatsApp AI agent, point a Webhook node at the agent’s inbound events, verify the signature in a Code node, answer 200 at once, drop duplicate events, run an AI Agent node with a chat model and memory keyed by the sender’s phone, and send the reply with an HTTP Request node. This guide uses whasp for the WhatsApp side and was run on n8n 2.43.8.
Why not use n8n’s WhatsApp node?
n8n’s built-in WhatsApp Business Cloud node sends messages, templates and media through Meta’s official WhatsApp Business Platform. Since 15 January 2026 the terms of that platform bar general-purpose AI assistants, as WhatsApp’s AI chatbot ban explains, and the other options each carry their own trade-offs. This guide uses a number that whasp runs. n8n needs no special node for it: whasp posts events to a URL and takes replies over a plain HTTPS API, so the Webhook and HTTP Request nodes are enough.
The chain has eight steps: Webhook, Verify signature (Code), Respond to Webhook, Text message? (If), Remove Duplicates, Is STOP? (If), AI Agent with its model and memory, Send reply (HTTP Request). We ran everything except the Postgres node on n8n 2.43.8 on 19 October 2026, with the language model and the whasp API stubbed.
You need n8n reachable over public HTTPS (behind a reverse proxy, set N8N_WEBHOOK_URL), a whasp number, an API key and a model credential.
How do you receive whasp events in n8n?
Add a Webhook node and set:
- HTTP Method: POST. Path:
whasp. - Respond: Using ‘Respond to Webhook’ Node.
- Options, Raw Body: on.
The Webhook node has a test URL and a production URL. The test URL (/webhook-test/whasp) works while you click Listen for Test Event. The production URL (/webhook/whasp) works only after you publish the workflow. Register the production URL with whasp. The answer includes the signing secret (whsec_...), which you store as WHASP_WEBHOOK_SECRET:
curl -X PUT https://api.whasp.ai/v1/webhook \
-H "Authorization: Bearer $WHASP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://n8n.example.com/webhook/whasp"}'
Raw Body matters. whasp signs the exact bytes it sends, per the Standard Webhooks scheme. With the option on, the node keeps those bytes in binary.data next to the parsed body. Re-serializing the parsed JSON can change whitespace or key order and fail the check.
Call POST /v1/webhook/test to send a webhook.test event and watch it arrive.
How do you verify the signature in a Code node?
Add a Code node named Verify signature, mode Run Once for All Items, language JavaScript:
const crypto = require('crypto');
if (!$env.WHASP_WEBHOOK_SECRET) throw new Error('Set WHASP_WEBHOOK_SECRET (the whsec_ secret from whasp)');
const headers = $input.first().json.headers;
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
// The exact bytes whasp signed: the Webhook node's Raw Body option keeps them in binary.data.
const raw = await this.helpers.getBinaryDataBuffer(0, 'data');
const key = Buffer.from($env.WHASP_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');
const expected = crypto.createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(raw)
.digest();
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300; // 5 minutes
const signed = String(headers['webhook-signature'] ?? '').split(' ').some((part) => {
const [version, sig] = part.split(',');
if (version !== 'v1' || !sig) return false;
const given = Buffer.from(sig, 'base64');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!id || !timestamp || !fresh || !signed) return [{ json: { valid: false } }];
const event = JSON.parse(raw.toString('utf8'));
const m = event.data?.message ?? {};
return [{
json: {
valid: true,
eventId: id,
type: event.type,
number: m.number, // the whasp number that received it
chat: m.chat, // the sender's phone, E.164
name: m.sender_name ?? null,
text: m.text ?? '',
},
}];
The code follows whasp’s rule: HMAC-SHA256 over <id>.<timestamp>.<raw body>, keyed with the base64-decoded part of the secret after whsec_, and a timestamp no older than five minutes. It returns valid: false instead of throwing. An error before the Respond to Webhook node makes n8n answer 500, and whasp would retry the event up to 12 times. A clean 401 is better.
Two settings trip people up on self-hosted n8n 2.x, and both fail with a clear message. Since n8n 2.0 the Code node blocks environment variables by default, and without N8N_BLOCK_ENV_ACCESS_IN_NODE=false this node fails with “access to env vars denied”. Built-in modules are blocked too: without NODE_FUNCTION_ALLOW_BUILTIN=crypto you get “Module ‘crypto’ is disallowed”. With external task runners, that second variable belongs in the runner’s own configuration. On n8n Cloud, crypto is allowed. If you cannot set environment variables, create a custom variable where your plan has them and read $vars.WHASP_WEBHOOK_SECRET instead.
How do you answer fast and skip duplicates?
whasp treats any 2xx within 10 seconds as delivered. Otherwise it retries after 30 seconds, 2 minutes, 10 minutes, 30 minutes, then hourly, up to 12 times. Events can arrive twice and out of order. Three nodes handle this.
- Respond to Webhook. Respond With: No Data. Response Code:
{{ $json.valid ? 200 : 401 }}. The node passes its input on after responding, so the workflow keeps running while whasp already has its answer. - Text message? (If). Continue only when
validis true,typeequalsmessage.receivedandtextis not empty. Everything else (message.sent,number.status_changed, a photo with no text) falls to the false branch. Wiremessage.failedandnumber.status_changedto an alert there. - Remove Duplicates. Operation: Remove Items Processed in Previous Executions. Keep Items Where: Value Is New. Value to Dedupe On:
{{ $json.eventId }}. The default history is 10,000 items.
We sent the same signed event twice and a tampered one: the first got one reply, the duplicate got a 200 and no second run, and the tampered one got a 401. One limit: once you have answered 200, a crash inside n8n loses that event, because whasp will not resend it. If that matters, write each event to a table or queue before acknowledging. WhatsApp webhooks for AI agents covers the pattern.
How do you set up the AI Agent node, memory and the reply?
AI Agent. Source for Prompt (User Message): Define below. Prompt (User Message): {{ $json.text }}. Under Options, System Message, write who the agent is and how it should text, for example “You are Ava, a friendly assistant texting on WhatsApp. Plain text only, no Markdown. Ask one question at a time.” For a fuller prompt, see how to make an AI agent text like a person on WhatsApp. The agent’s answer arrives in $json.output.
Chat model. Attach an OpenAI Chat Model or an Anthropic Chat Model. Both load the model list from your account, so pick a current model there.
Memory. Attach Postgres Chat Memory. Set Session ID to Define below and Key to {{ $json.number }}:{{ $json.chat }}, so each person has one history per number. The default table is n8n_chat_histories and Context Window Length defaults to 5, so raise it to what your prompt budget allows. Avoid Simple Memory in production. Its source keeps sessions in a map inside the n8n process and deletes any session idle for an hour, and its docs say it does not work in queue mode. whasp’s servers keep no messages, so the history is yours, and a database you control is the right home. Conversation memory for a WhatsApp AI agent goes deeper.
Send reply. Add an HTTP Request node:
| Field | Value |
|---|---|
| Method, URL | POST, https://api.whasp.ai/v1/messages |
| Authentication | Generic Credential Type, Header Auth: name Authorization, value Bearer wsk_... |
| Send Headers | Idempotency-Key: {{ $('Verify signature').first().json.eventId }} |
| Send Body, JSON, Using JSON | {{ { to: $('Verify signature').first().json.chat, text: $json.output } }} |
| Settings | Retry On Fail on, Max Tries 3, Wait Between Tries 2000 ms |
Build the body as an expression that returns an object. Gluing model text into a JSON string breaks on the first quote or line break. The idempotency key makes retries safe: the same key returns the first message and never sends twice. n8n allows at most 5 tries and 5 seconds between them, so it covers short blips, not outages. whasp answers 202 and sends messages from one number in order, about a second apart.
How do you handle STOP, errors and bursts?
whasp opts a chat out by itself when the person sends STOP, UNSUBSCRIBE, OPTOUT, CANCEL, END, QUIT or any spelling of “opt out”, and later sends to that chat return 403 chat_opted_out. The message still reaches your webhook, so add an Is STOP? If node after Remove Duplicates. Set the value to the expression {{ /^(STOP|UNSUBSCRIBE|OPTOUT|CANCEL|END|QUIT)$/.test($json.text) || /^opt[\s_-]*out$/i.test($json.text) }} and the condition to Boolean, is true. On the true branch, skip the model and send one HTTP Request with "override_optout": true and a line such as “You are unsubscribed. Write any time to talk again.” Polite requests like “please stop” are yours to catch.
For failures, open Workflow Settings, set an Error workflow, and build it from an Error Trigger node that posts to Slack or email. Read the 429s: rate_limited means more than 100 API requests in 10 seconds per account, and number_daily_limit means the number sent 1,500 messages in 24 hours. A 409 number_unavailable means the number is banned, offline or retired.
Bursts need a decision. A person who sends three texts in five seconds starts three runs and gets three replies. Builders in n8n’s forum recommend debouncing a customer’s rapid messages into one turn before the model is called. We did not build that part.
How do you hand a chat to a human?
whasp is a pipe: it has no inbox and no takeover, so handoff state lives in n8n. A workable design uses a Data table named chat_state with chat and handoff columns. We did not run this part, but the node operations are documented.
- After Remove Duplicates, add a Data table node: Row, If Row Exists, with
chatequal to{{ $json.chat }}andhandofftrue. The true branch forwards the message to Slack or your helpdesk and stops. The false branch continues to the agent. - Set the flag with a Data table Upsert row when the text matches a rule such as
/human|real person/i, or when your agent asks for it. Then send “A person will reply here soon.” - Build a second workflow that triggers when staff answer, calls
POST /v1/messageswith the same chat, and upsertshandoffback to false on a resume command.
Check the flag again right before sending the model’s reply, because a handoff can start while the model is thinking.
What to do next
Publish the workflow, register its production URL with PUT /v1/webhook, send POST /v1/webhook/test, then text your number from your own phone. If nothing arrives, GET /v1/webhook/deliveries shows each attempt and status. The quickstart walks through the API. For the same flow in code, see Build a WhatsApp AI agent with the OpenAI API.
Questions
How do I connect n8n to WhatsApp for an AI agent?
Receive inbound messages with a Webhook node, run an AI Agent node, and send the reply with an HTTP Request node that calls POST https://api.whasp.ai/v1/messages. n8n's own WhatsApp Business Cloud node uses Meta's official API, which does not suit a general-purpose assistant.
Why does my n8n Code node say that the module crypto is disallowed?
Since n8n 2.0 the Code node runs on task runners and blocks built-in modules by default. On self-hosted n8n, set NODE_FUNCTION_ALLOW_BUILTIN=crypto where the task runner reads its environment. n8n Cloud already allows crypto.
Why does my n8n Code node say access to env vars denied?
n8n 2.0 blocks environment variables in the Code node by default. On self-hosted n8n set N8N_BLOCK_ENV_ACCESS_IN_NODE=false, or store the secret as a custom variable and read it with $vars.
How do I stop n8n from answering the same WhatsApp message twice?
Put a Remove Duplicates node after the Webhook, set to remove items processed in previous executions, and dedupe on the event id from the webhook-id header. Then send the reply with the same id as the Idempotency-Key.
Which n8n memory node should a WhatsApp agent use?
Use Postgres Chat Memory or Redis Chat Memory, with the sender's phone number as the session key. Simple Memory is in-process, is dropped after an hour without use, and does not work in queue mode.


