# 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.

Source: https://whasp.ai/blog/n8n-whatsapp-ai-agent · 2026-10-20

## 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](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.whatsapp/) 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](https://whasp.ai/blog/whatsapp-ai-chatbot-ban-2026) explains, and [the other options](https://whasp.ai/blog/put-ai-agent-on-whatsapp) 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](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/) 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`:

```bash
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:

```js
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](https://docs.n8n.io/2-0-breaking-changes/) 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](https://docs.n8n.io/code/code-node/). 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.

1. **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.
2. **Text message? (If).** Continue only when `valid` is true, `type` equals `message.received` and `text` is not empty. Everything else (`message.sent`, `number.status_changed`, a photo with no text) falls to the false branch. Wire `message.failed` and `number.status_changed` to an alert there.
3. **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](https://whasp.ai/blog/whatsapp-webhook-handling) 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](https://whasp.ai/blog/write-for-whatsapp-ai-agent-prompts). 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](https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts) keeps sessions in a map inside the n8n process and deletes any session idle for an hour, and [its docs](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.memorybufferwindow/) 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](https://whasp.ai/blog/whatsapp-ai-agent-memory) 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](https://community.n8n.io/t/title-building-a-production-ready-whatsapp-automation-with-n8n-architecture-security-rate-limiting-retries-and-human-handoff/322131) 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](https://docs.n8n.io/data/data-tables/) named `chat_state` with `chat` and `handoff` columns. We did not run this part, but the node operations are documented.

1. After Remove Duplicates, add a Data table node: Row, If Row Exists, with `chat` equal to `{{ $json.chat }}` and `handoff` true. The true branch forwards the message to Slack or your helpdesk and stops. The false branch continues to the agent.
2. 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."
3. Build a second workflow that triggers when staff answer, calls `POST /v1/messages` with the same chat, and upserts `handoff` back 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](https://whasp.ai/docs#quickstart) walks through the API. For the same flow in code, see [Build a WhatsApp AI agent with the OpenAI API](https://whasp.ai/blog/whatsapp-ai-agent-openai).

## 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.
