WhatsApp AI Bot
Connect a WhatsApp number to an AI-powered documentation assistant using Ubex workflows and Twilio.
Overview
This workflow receives WhatsApp messages via Twilio webhooks, validates the request signature using HMAC-SHA1, searches a knowledge base using semantic search, generates an AI response with an LLM, converts the formatting for WhatsApp, and sends the reply back through the Twilio API. It's a complete AI chatbot with cryptographic webhook verification — no external bot framework needed.
This is a great starting point for building AI-powered WhatsApp bots for customer support, documentation assistants, or any conversational use case where you need secure webhook handling.
Prerequisites
- A Twilio account with a WhatsApp-enabled number (or the Twilio Sandbox for WhatsApp)
- Your Twilio Account SID and Auth Token (from the Twilio Console)
- A datasource with documents or knowledge base content for RAG
What You'll Build
A WhatsApp bot that:
- Receives user messages via Twilio webhook
- Validates the
X-Twilio-Signatureheader using HMAC-SHA1 - Parses the
application/x-www-form-urlencodedpayload - Searches your knowledge base using semantic search (RAG)
- Generates a contextual AI response using Claude
- Converts markdown to WhatsApp-compatible formatting
- Sends the formatted reply back via the Twilio Messages API
Endpoint: POST /api/v1/YOUR_ID/whatsapp
Flow:
WhatsApp Message → Twilio → API Trigger → Verify Signature & Parse → Query Knowledge Base → LLM → Format for WhatsApp → Send Reply via Twilio
Setting Up Twilio for WhatsApp
1. Get your Twilio credentials
- Log in to the Twilio Console
- Copy your Account SID (starts with
AC...) - Copy your Auth Token (click to reveal)
2. Set up WhatsApp Sandbox (for testing)
If you don't have a production WhatsApp number:
- Go to Twilio Console → Messaging → Try it out → Send a WhatsApp message
- Follow the instructions to join the sandbox (send a code to
+14155238886) - Note the sandbox number:
whatsapp:+14155238886
3. Store secrets in Ubex
In your Ubex workspace, go to Settings → Vault and add:
| Key | Value |
|---|---|
twilio_username |
Your Twilio Account SID |
twilio_password |
Your Twilio Auth Token |
whatsapp_endpoint |
Full webhook URL (e.g. https://workflow.ubex.ai/api/v1/YOUR_ID/whatsapp) |
Twilio_Link |
Twilio Messages API URL (e.g. https://api.twilio.com/2010-04-01/Accounts/YOUR_SID/Messages.json) |
The
whatsapp_endpointmust match exactly the URL Twilio sends webhooks to. Even a single character difference (trailing slash, wrong path segment) will cause signature validation to fail. This is the most common issue when setting up Twilio webhook verification.
Workflow Nodes
1. Flow Start - API Endpoint
| Setting | Value |
|---|---|
| Trigger Type | API |
| Method | POST |
| Custom Path | whatsapp |
| Rate Limit | 3/min |
| Timeout | 60s |
| Auth | None (signature validated in Code node) |
Set the timeout to 60 seconds. LLM responses can take a few seconds, and Twilio may retry the webhook if it doesn't get a response quickly. The low rate limit (3/min) prevents abuse at the API level.
2. Code - Verify Signature & Parse Payload
This is the most important node in the workflow. It validates the Twilio webhook signature and parses the URL-encoded body into structured data.
Output variable: data
var authToken = variables.secrets.twilio_password;
var url = variables.secrets.whatsapp_endpoint;
var headers = variables._trigger.headers;
var signature = headers.Get("X-Twilio-Signature");
if (!signature) {
throw new Error("Missing X-Twilio-Signature — rejected");
}
var raw = variables._rawBody;
var parts = raw.split("&");
var result = {};
for (var i = 0; i < parts.length; i++) {
var pair = parts[i].split("=");
var key = decodeURIComponent(pair[0].replace(/\+/g, " "));
var val = decodeURIComponent(pair[1].replace(/\+/g, " "));
result[key] = val;
}
var sortedKeys = Object.keys(result).sort();
var dataString = url;
for (var j = 0; j < sortedKeys.length; j++) {
dataString += sortedKeys[j] + result[sortedKeys[j]];
}
var hmacHex = hmacSHA1(dataString, authToken);
var expected = base64Encode(hexDecode(hmacHex));
if (signature !== expected) {
throw new Error("Invalid Twilio signature — rejected");
}
({
profileName: result["ProfileName"],
message: result["Body"],
from: result["From"],
to: result["To"],
waId: result["WaId"],
messageSid: result["MessageSid"],
messageType: result["MessageType"],
smsStatus: result["SmsStatus"],
accountSid: result["AccountSid"],
numMedia: result["NumMedia"],
apiVersion: result["ApiVersion"]
});
How Twilio signature validation works
Twilio signs every webhook request so you can verify it's authentic:
- Start with the full webhook URL (your
whatsapp_endpointsecret) - Sort all POST parameters alphabetically by key
- Append each key-value pair to the URL string (no separators)
- Compute HMAC-SHA1 of that string using your Auth Token as the key
- Base64-encode the raw HMAC bytes
- Compare with the
X-Twilio-Signatureheader
Twilio sends
application/x-www-form-urlencodedbodies, not JSON. The Code node manually parses the raw body usingvariables._rawBodybecause the API trigger's auto-parsing doesn't apply to this content type.
Understanding the Twilio WhatsApp payload
| Field | Description |
|---|---|
From |
Sender's WhatsApp number (e.g. whatsapp:+1234567890) |
To |
Your Twilio WhatsApp number |
Body |
The message text |
ProfileName |
Sender's WhatsApp display name |
WaId |
Sender's phone number without the whatsapp: prefix |
MessageSid |
Unique Twilio message ID |
AccountSid |
Your Twilio Account SID |
NumMedia |
Number of media attachments |
MessageType |
Message type (usually text) |
3. Query Data - Search Knowledge Base
Searches your datasource using semantic similarity to find relevant documentation for the user's question.
| Setting | Value |
|---|---|
| Datasource | Your knowledge base datasource |
| Query | {{data.message}} |
| Search Type | Similarity |
| Top K | 5 |
| Similarity Threshold | 0.7 |
Output variable: queryData
The query uses the parsed message text from the Twilio payload. Adjust
topKandsimilarityThresholdbased on your knowledge base size and quality.
4. LLM - Generate Response
Uses Claude to generate a response based on the retrieved documentation context.
| Setting | Value |
|---|---|
| Model | Claude 4.5 Sonnet (or any supported model) |
| Prompt | {{queryData}} |
| Temperature | 0.7 |
| Max Tokens | 2048 |
System instructions should include your assistant's personality, rules, and the retrieved context. Add a WhatsApp formatting directive:
You are an AI assistant. Answer questions based on the provided documentation.
ADDITIONAL INFORMATION
{{queryData}}
Important! RETURN THE RESPONSE COMPATIBLE WITH WHATSAPP
Output variable: model
WhatsApp supports a limited set of formatting:
*bold*,_italic_,~strikethrough~, and`code`. The LLM instructions should tell it to use these instead of markdown.
5. Code - Format for WhatsApp
Converts any remaining markdown in the LLM response to WhatsApp-compatible formatting.
Output variable: formattedCode
var text = variables.model;
// Convert markdown bold **text** to WhatsApp bold *text*
text = text.replace(/\*\*([^*]+)\*\*/g, "*$1*");
// Convert markdown headers ## to just bold
text = text.replace(/^#{1,3}\s+(.+)$/gm, "*$1*");
// Clean up markdown list dashes that have bold
text = text.replace(/^- \*(.+)\*$/gm, "• $1");
// Clean up remaining markdown list dashes
text = text.replace(/^- /gm, "• ");
({
cleanResponse: text
});
WhatsApp formatting reference
| Syntax | Renders as |
|---|---|
*text* |
bold |
_text_ |
italic |
~text~ |
|
`text` |
monospace |
```text``` |
code block |
Unlike Telegram (which uses HTML), WhatsApp uses its own lightweight formatting syntax. The Code node converts markdown to WhatsApp-native formatting.
6. HTTP Request - Send Reply via Twilio
Posts the formatted response back to WhatsApp through the Twilio Messages API.
| Setting | Value |
|---|---|
| Method | POST |
| URL | {{secrets.Twilio_Link}} |
| Auth | Basic (username: {{secrets.twilio_username}}, password: {{secrets.twilio_password}}) |
Headers:
| Key | Value |
|---|---|
| Content-Type | application/x-www-form-urlencoded |
Body (x-www-form-urlencoded):
| Key | Value |
|---|---|
| From | whatsapp:+14155238886 |
| To | {{data.from}} |
| Body | {{formattedCode.cleanResponse}} |
Output variable: twilio_response
The Twilio Messages API expects
application/x-www-form-urlencoded, not JSON. Use the form data body type in the HTTP Request node. TheFromnumber must match your Twilio WhatsApp number (sandbox or production).
Configuring the Twilio Webhook
After deploying the workflow, point Twilio to your endpoint:
For Sandbox
- Go to Twilio Console → Messaging → Try it out → Send a WhatsApp message
- In the Sandbox Configuration section, set:
- When a message comes in:
https://workflow.ubex.ai/api/v1/YOUR_ID/whatsapp - Method: POST
- When a message comes in:
For Production
- Go to Twilio Console → Phone Numbers → Manage → Active Numbers
- Select your WhatsApp-enabled number
- Under Messaging, set:
- A message comes in:
https://workflow.ubex.ai/api/v1/YOUR_ID/whatsapp - Method: POST
- A message comes in:
The URL you set here must match your
whatsapp_endpointsecret exactly. Copy-paste it to avoid typos.
Debugging Signature Validation
If you get "Invalid Twilio signature — rejected", the issue is almost always a URL mismatch. Use this temporary debug Code node to diagnose:
var authToken = variables.secrets.twilio_password;
var url = variables.secrets.whatsapp_endpoint;
var headers = variables._trigger.headers;
var signature = headers.Get("X-Twilio-Signature");
var raw = variables._rawBody;
var parts = raw.split("&");
var result = {};
for (var i = 0; i < parts.length; i++) {
var pair = parts[i].split("=");
var key = decodeURIComponent(pair[0].replace(/\+/g, " "));
var val = decodeURIComponent(pair[1].replace(/\+/g, " "));
result[key] = val;
}
var sortedKeys = Object.keys(result).sort();
var dataString = url;
for (var j = 0; j < sortedKeys.length; j++) {
dataString += sortedKeys[j] + result[sortedKeys[j]];
}
var hmacHex = hmacSHA1(dataString, authToken);
var computed = base64Encode(hexDecode(hmacHex));
({
received: signature,
computed: computed,
match: signature === computed,
urlLength: url.length,
lastTenChars: url.substring(url.length - 10),
paramCount: sortedKeys.length
});
Common causes of mismatch:
| Issue | Fix |
|---|---|
| Wrong URL in secret | Copy the exact URL from Twilio Console |
| Trailing slash mismatch | Add or remove the trailing / to match |
| HTTP vs HTTPS | Twilio always uses HTTPS |
| Old endpoint path | Update the secret after changing the workflow path |
Testing
Send a test message
Open WhatsApp and send a message to your Twilio number (or sandbox number). You should receive an AI-generated response within a few seconds.
Using curl to simulate a Twilio webhook
You can't easily simulate a signed Twilio request with curl (you'd need to compute the HMAC-SHA1 yourself). Instead, use the Twilio Console's webhook testing tools, or temporarily disable signature validation for testing.
What to verify
| Check | Expected |
|---|---|
| Signature validation | Unsigned requests are rejected |
| Message parsing | data.message contains the WhatsApp message text |
| Knowledge base search | Query Data returns relevant results |
| LLM generates response | Model output contains a contextual answer |
| Formatting | WhatsApp bold/bullets render correctly |
| Reply appears in chat | Bot sends the response back to the correct number |
| Secrets not exposed | All credentials use {{secrets.*}} references |
Extending the Bot
Add media handling
Check numMedia in the parsed payload to detect images or documents:
if (parseInt(result["NumMedia"]) > 0) {
// Media URL is in result["MediaUrl0"]
({
message: result["Body"] || "[media message]",
mediaUrl: result["MediaUrl0"],
mediaType: result["MediaContentType0"],
from: result["From"]
});
}
Add session memory
Enable session memory on the LLM node using {{data.waId}} as the session ID. This gives each WhatsApp user their own conversation history based on their phone number.
Add a welcome message
Use a Condition node after parsing to check if the message is a first-time greeting:
var text = result["Body"].toLowerCase().trim();
var isGreeting = text === "hi" || text === "hello" || text === "hey" || text === "start";
Route greetings to a static welcome response instead of the LLM.
Security Checklist
| Control | Status |
|---|---|
| HMAC-SHA1 signature validation | ✅ |
| Auth token stored as secret | ✅ |
| POST only endpoint | ✅ |
| Rate limiting (3/min) | ✅ |
| 60s timeout for LLM latency | ✅ |
| Twilio credentials as secrets | ✅ |
| Twilio API URL as secret | ✅ |
| Webhook URL as secret | ✅ |
| No real secrets in tutorial JSON | ✅ |
| WhatsApp-native formatting | ✅ |