- Create a Meta Business Account at business.facebook.com → Settings → Business Info. You need a verified business — usually GST cert + a utility bill for the registered address.
- Inside Meta Business Suite, go to WhatsApp Accounts → Add WhatsApp Business Account. Tie it to a phone number you control (not a personal one — once enrolled, the number cannot be used in the regular WhatsApp app).
- In Meta for Developers (developers.facebook.com), create an App → Business type → add the WhatsApp product. Note the App ID and App Secret.
- In your App → WhatsApp → API Setup, generate a permanent System User Access Token. Set the token's expiry to "Never." This is the token n8n will use for outbound messages.
- Add your test number first, then your production number. Meta charges ₹0 to verify; SMS OTP confirms ownership.
- Submit your business for verification (required to lift the 250 unique users/24h sandbox cap). Verification takes 1–5 business days and asks for GST cert, business website, and authorised signatory.
- Configure the webhook: in App → WhatsApp → Configuration, set the Callback URL to your n8n webhook (must be HTTPS), set the Verify Token (any string — n8n must return it on the GET handshake), and subscribe to the `messages` field.
hub.mode=subscribe, hub.verify_token, and hub.challenge query params. You must return the challenge as plain text.
## Block 1 — The Webhook node with verify-token handshake
Two webhook paths: one for the verification GET, one for the POST messages. n8n's Webhook node v2 supports multiple HTTP methods on the same path if you use a custom routing pattern. Simpler: two separate webhook nodes.
The verification webhook:
{
"parameters": {
"httpMethod": "GET",
"path": "wa/incoming",
"responseMode": "responseNode",
"options": {}
},
"id": "wa-verify-001",
"name": "Meta Verify GET",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 200]
}// Verify Meta's challenge
const VERIFY_TOKEN = $env.META_VERIFY_TOKEN;
const mode = $json.query['hub.mode'];
const token = $json.query['hub.verify_token'];
const challenge = $json.query['hub.challenge'];
if (mode === 'subscribe' && token === VERIFY_TOKEN) {
return { json: { ok: true, body: challenge } };
}
return { json: { ok: false, body: 'unauthorized' } };{{ $json.body }} as plain text. Once this passes, Meta marks the webhook "verified" and starts sending messages.
The message webhook is a separate POST endpoint at the same path or a different one — we use /wa/incoming for POST and /wa/verify for GET to keep them isolated.
## Block 2 — Parse the inbound payload + intent extraction
Meta's webhook POST shape is nested. The actual message text lives at body.entry[0].changes[0].value.messages[0].text.body. Extract once into a clean object:
const entry = $json.body.entry?.[0];
const change = entry?.changes?.[0]?.value;
const msg = change?.messages?.[0];
if (!msg) return []; // status updates, deliveries — ignore
return {
json: {
from: msg.from, // E.164 phone, e.g. 919876543210
msg_id: msg.id, // wamid for read-receipt later
timestamp: msg.timestamp,
type: msg.type, // text | image | audio | interactive
text: msg.text?.body || '',
profile_name: change.contacts?.[0]?.profile?.name || 'Unknown',
business_phone: change.metadata?.display_phone_number
}
};type is not text, we route to a branch that asks the user to send a text (for images/audio we have a separate Whisper pipeline; that is Post 1 territory).
Claude Haiku call, identical structure to Post 1, but a different system prompt:
You are a WhatsApp customer support classifier for Tvashtri Skincare, a Pune-based D2C brand.
Read the customer message and return strict JSON only:
{
intent: 'order_status' | 'product_question' | 'return_refund' | 'discount_query' | 'human_handoff' | 'spam',
order_id: string | null,
product_name: string | null,
language: 'en' | 'hi' | 'mr' | 'hinglish',
sentiment: 'positive' | 'neutral' | 'negative',
confidence: number 0-1
}
If the user explicitly asks for a human, agent, or executive, intent='human_handoff' with confidence 0.99.intent fans out. Three of the most common branches:
order_status: HTTP Request to Shopify Admin GraphQL (https://{store}.myshopify.com/admin/api/2026-04/graphql.json) with a query that pulls the order by name (#{{ order_id }}):
query getOrder($name: String!) {
orders(first: 1, query: $name) {
edges { node {
id name displayFulfillmentStatus displayFinancialStatus
shippingLine { title }
fulfillments {
trackingInfo { number url }
estimatedDeliveryAt
}
} }
}
}question, answer, and embedding (we keep a cached OpenAI text-embedding-3-small vector for each FAQ in a separate column). The matching is a cosine-similarity in a Code node — for ~120 FAQs this is fast enough to skip a vector DB.
human_handoff or low-confidence anything: the workflow skips the reply and posts a structured Slack message to #wa-support with the customer's profile name, phone, full message, and a "Reply as you" link that opens WhatsApp Web with the right contact preselected.
## Block 4 — Send the reply via Cloud API
The Cloud API endpoint for outbound is POST https://graph.facebook.com/v20.0/{phone_number_id}/messages. The phone_number_id is from Meta App → WhatsApp → API Setup, not the customer-facing number.
Node JSON:
{
"parameters": {
"method": "POST",
"url": "=https://graph.facebook.com/v20.0/{{ $env.WA_PHONE_NUMBER_ID }}/messages",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Authorization", "value": "=Bearer {{ $env.WA_SYSTEM_USER_TOKEN }}" },
{ "name": "Content-Type", "value": "application/json" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"messaging_product\": \"whatsapp\",\n \"recipient_type\": \"individual\",\n \"to\": \"{{ $json.from }}\",\n \"type\": \"text\",\n \"text\": {\n \"preview_url\": true,\n \"body\": \"{{ $json.reply_text }}\"\n }\n}",
"options": { "timeout": 15000, "retry": { "enabled": true, "maxRetries": 3 } }
},
"id": "wa-send-001",
"name": "Send WhatsApp Reply",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1640, 320]
}{
"messaging_product": "whatsapp",
"status": "read",
"message_id": "{{ $json.msg_id }}"
}$env.META_VERIFY_TOKEN and store it as a real environment variable on your n8n container. If your workflow JSON leaks (it will, someone exports it), the token rotates without a code change.
Mistake 3 — Replying with raw Markdown. WhatsApp uses its own formatting: bold, _italic_, ~strikethrough~, no headers, no lists. Markdown asterisks and underscores will render weird. Always sanitize through a Set node.
Mistake 4 — No rate-limit awareness. Meta enforces ~80 messages/second business-tier per phone. If you blast a 5,000-message marketing campaign in one go, you will hit 429s. Use n8n's "Split In Batches" node with a 50-message batch and 1-second wait.
Mistake 5 — Storing customer PII unencrypted. Your n8n executions logs contain phone numbers and message bodies. Set retention to 7 days max (EXECUTIONS_DATA_MAX_AGE=168) and ensure your Hetzner volume is encrypted at rest.
## FAQ
### Can n8n handle WhatsApp media (images, audio, PDFs)?
Yes. The webhook payload contains a media ID. You do a follow-up GET to /v20.0/{media_id} with your bearer token to get a temporary download URL, then fetch the binary. We pipe audio to Whisper, images to Claude Vision, and PDFs to LlamaParse. Adds ~6 nodes per media type.
### What is the difference between WhatsApp Cloud API and an Indian BSP?
Cloud API is hosted by Meta — you call Meta directly. BSPs like AiSensy / Wati sit between you and Meta, charge a markup (₹0.05–₹0.20 per message), and add a dashboard. Cloud API is cheaper and gives you full control; BSPs are faster to start if you do not have an n8n stack.
### How do I handle multilingual queries?
Claude Haiku detects the language in the same classification pass. Branch on language to route to language-specific KB or to translate the reply via a second Claude call. We see ~30% Hinglish, 55% English, 10% Hindi, 5% Marathi for the Pune brand.
### Do I need to opt-in users for each conversation?
For service messages (replies to user-initiated chats within 24h), no. For marketing or utility notifications you send first, yes — you need verifiable opt-in (a checkbox on signup, an order confirmation, a phone-collection form with disclosure). Meta audits BSPs and direct API users; non-compliance gets your number rate-limited or banned.
### Can the bot pretend to be human?
You can give it a name (we call ours "Riya from Tvashtri Support") and friendly tone, but the first interaction must indicate it is automated. Meta's policy and India's DPDP Act both nudge toward disclosure. We open every new conversation with "Hi! I am Riya, the Tvashtri auto-assistant — I will get you a quick answer or hand you off to a human teammate."
### What is the failover if n8n goes down?
Two protections. First, Meta retries failed webhook deliveries for ~7 days, so a 30-minute n8n outage does not lose messages. Second, we have a fallback Cloudflare Worker that catches webhooks if n8n is unreachable, queues them to Cloudflare Queues, and replays once n8n comes back. Adds ₹120/month, worth it for production.
### How do I migrate from Wati or AiSensy?
The webhook endpoint and phone-number-ID transfer cleanly — you swap the destination URL in the WhatsApp App → Configuration. The hardest part is template re-approval; Meta does not let you transfer template IDs across apps. Plan 1 week for re-approval + n8n cutover.
Want this WhatsApp auto-reply system in your business?
We ship the full 22-node n8n workflow above, your KB curated from existing tickets, and Meta Business verification end-to-end in 10 working days. Typical cost: ₹55,000–₹110,000 depending on KB size and integrations. Suitable if you take 500+ WhatsApp conversations a month and want to keep support headcount flat. No slides — your existing chat exports and our honest take.
Book a 20-min Call
