IntermediateWhatsApp

WhatsApp AI Bot

Connect WhatsApp to an AI assistant with Twilio webhook verification and HMAC-SHA1 signature validation.

Click to expand
Flow StartAPI POSTCodedataQuery DataqueryDataModelmodelCodeformattedCodeHTTP RequestTwilio API

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-Signature header using HMAC-SHA1
  • Parses the application/x-www-form-urlencoded payload
  • 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

  1. Log in to the Twilio Console
  2. Copy your Account SID (starts with AC...)
  3. Copy your Auth Token (click to reveal)

2. Set up WhatsApp Sandbox (for testing)

If you don't have a production WhatsApp number:

  1. Go to Twilio Console → Messaging → Try it out → Send a WhatsApp message
  2. Follow the instructions to join the sandbox (send a code to +14155238886)
  3. 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_endpoint must 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:

  1. Start with the full webhook URL (your whatsapp_endpoint secret)
  2. Sort all POST parameters alphabetically by key
  3. Append each key-value pair to the URL string (no separators)
  4. Compute HMAC-SHA1 of that string using your Auth Token as the key
  5. Base64-encode the raw HMAC bytes
  6. Compare with the X-Twilio-Signature header

Twilio sends application/x-www-form-urlencoded bodies, not JSON. The Code node manually parses the raw body using variables._rawBody because 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 topK and similarityThreshold based 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~ strikethrough
`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. The From number must match your Twilio WhatsApp number (sandbox or production).

Configuring the Twilio Webhook

After deploying the workflow, point Twilio to your endpoint:

For Sandbox

  1. Go to Twilio Console → Messaging → Try it out → Send a WhatsApp message
  2. In the Sandbox Configuration section, set:
    • When a message comes in: https://workflow.ubex.ai/api/v1/YOUR_ID/whatsapp
    • Method: POST

For Production

  1. Go to Twilio Console → Phone Numbers → Manage → Active Numbers
  2. Select your WhatsApp-enabled number
  3. Under Messaging, set:
    • A message comes in: https://workflow.ubex.ai/api/v1/YOUR_ID/whatsapp
    • Method: POST

The URL you set here must match your whatsapp_endpoint secret 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