Let your own AI answer customers — receive unanswered inbound messages via webhook and reply through the API.
External AI Integration
Connect your own AI to Wax so it can answer inbound customer messages over WhatsApp or RCS. Wax calls your webhook when a message reaches the end of your automations unanswered, and your AI replies through the API.
Looking to mirror conversations into a help desk instead? This page describes a request/response integration: Wax asks your service for an answer. If you want a one-way feed of every message on a conversation that needs attention — quick replies, media and the automated messages your flows send included — see External Helpdesk Integration. The two are independent and can run side by side.
Configure this integration in Settings → Organization → External AI.
Overview
The integration works in two steps:
- Inbound webhook -- Wax forwards unanswered customer messages to your URL
- Reply endpoint -- Your system sends a response back via the Wax API
1. Inbound Message Webhook
Wax calls your webhook only for messages nothing else handled. A message is skipped when it:
- matches a quick reply, list option or other flow node, so a flow continues
- answers a step that was waiting for an answer
- matches a keyword that triggers a flow
- is detected as an opt-out and an opt-out flow is active
Everything left over -- typically free-text questions, plus media messages -- is forwarded to your webhook URL. Because this integration is meant to answer the customer, receiving a message also interrupts any flow the contact was in and marks the conversation unresolved in the Wax inbox.
Request
POST <your_webhook_url>
Content-Type: application/json
X-Signature: <HMAC-SHA256 signature>
Signature Verification
Every webhook request includes an X-Signature header containing an HMAC-SHA256 hex digest of the raw JSON body, signed with your organization's bearer token.
To verify the signature:
import hmac
import hashlib
def verify_signature(body: bytes, signature: str, bearer_token: str) -> bool:
expected = hmac.new(
bearer_token.encode(),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)const crypto = require('crypto');
function verifySignature(body, signature, bearerToken) {
const expected = crypto
.createHmac('sha256', bearerToken)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}expected = OpenSSL::HMAC.hexdigest('SHA256', bearer_token, raw_body)
valid = ActiveSupport::SecurityUtils.secure_compare(expected, signature)Payload Structure
{
"message_id": "wamid_ABCdef123456",
"organization_id": 42,
"contact_id": 789,
"message": {
"id": "whatsapp:48213",
"external_message_id": "wamid_ABCdef123456",
"channel": "whatsapp",
"content": "How is my order?",
"timestamp": "2026-07-08T14:03:21.532Z",
"media_url": "https://...",
"media_type": "image/jpeg"
},
"contact": {
"name": "John Doe",
"phone": "+33612345678",
"email": "[email protected]",
"tags": ["vip", "returning_customer"],
"custom_attributes": {
"loyalty_tier": "gold",
"preferred_language": "fr"
}
},
"history": [
{
"id": "whatsapp:48201",
"external_message_id": "wamid_XYZabc987654",
"role": "user",
"content": "Hi there",
"timestamp": "2026-07-08T13:58:02.101Z"
},
{
"id": "whatsapp:48204",
"external_message_id": "wamid_QRSdef456789",
"role": "assistant",
"content": "Hello! How can I help you today?",
"timestamp": "2026-07-08T13:58:40.870Z"
}
],
"shopify": {
"recent_orders": [
{
"order_number": "#1234",
"status": "shipped",
"items": ["White Sneakers", "Black T-Shirt"],
"date": "2026-03-20T10:30:00Z"
}
],
"active_checkouts": [
{
"items": ["Blue Jacket"],
"created_at": "2026-03-27T09:00:00Z"
}
]
}
}Field Reference
| Field | Type | Description |
|---|---|---|
message_id | string | WhatsApp message ID (wamid) |
organization_id | integer | Your Wax organization ID |
contact_id | integer | Wax contact ID -- use this to send replies |
message.id | string | Stable unique message ID, prefixed with the channel (whatsapp:123, rcs:45, sms:67) -- use it to deduplicate |
message.external_message_id | string? | Provider-side message ID (e.g. the WhatsApp wamid) |
message.channel | string | whatsapp, rcs, or sms |
message.content | string | Text content of the message |
message.timestamp | string | ISO8601 with millisecond precision |
message.media_url | string? | URL of attached media. Images only -- video, audio and document messages carry no media fields on this webhook (the helpdesk webhook sends all four) |
message.media_type | string? | Present with media_url. Currently always reported as image/jpeg, so do not rely on it to detect the real file type -- read the extension of media_url instead |
contact.name | string? | Contact display name |
contact.phone | string | Phone number in E.164 format |
contact.email | string? | Contact email address |
contact.tags | string[] | Tag names assigned to the contact |
contact.custom_attributes | object? | Key-value pairs of custom attributes |
history | array | Last 20 messages (excluding current), sorted chronologically ascending |
history[].id | string | Stable unique message ID, same format as message.id |
history[].external_message_id | string? | Provider-side message ID |
history[].role | string | user (the contact) or assistant (sent by Wax or your integration) |
history[].content | string | Text content of the message |
history[].timestamp | string | ISO8601 with millisecond precision |
shopify | object? | null unless Shopify is connected and "Include Shopify data" is enabled on the webhook (Settings → Organization → External AI) |
shopify.recent_orders | array | Last 5 orders with status: "ordered", "shipped", "delivered", or "cancelled" |
shopify.active_checkouts | array | Up to 3 incomplete checkouts from the last month |
Storing Messages & Deduplication
If you persist conversations on your side (e.g. as help desk tickets), use id as the primary key for each message:
message.idandhistory[].idare stable across deliveries -- the same message always carries the same ID.- Deliveries are at-least-once: failed requests are retried, and
historyintentionally overlaps between deliveries. Upsert onidrather than appending. - Messages sent by human agents from the Wax inbox appear in
historywithrole: "assistant"on the next inbound message, so you can backfill them deduped. - Use
timestamp(millisecond precision) to order messages;historyis already sorted ascending.
Expected Response
Return a 2xx status code to acknowledge receipt. The response body is ignored.
If your endpoint returns a non-2xx status or times out (5 seconds), Wax retries up to 5 times with exponential backoff. After all retries are exhausted, the conversation is marked as unresolved in the inbox for a human agent to handle.
Rapid-Fire Messages
Your webhook is called as soon as a message is processed, with no artificial delay. Calls for the same conversation are collapsed within a 20-second window, so a customer sending several messages in quick succession produces one call rather than several. That call describes the most recent message in message, with the earlier ones present in history -- so read history, not just message, if you need the customer's full thought.
Custom Headers
You can configure custom headers on your webhook (e.g., for additional authentication). These are sent with every webhook request alongside the standard Content-Type and X-Signature headers.
2. Reply Endpoint
API Reference: POST /v1/conversation_replies
Send a plain text reply to a contact. The message is delivered via the contact's default channel (WhatsApp or RCS).
Request
POST https://api.getwax.io/v1/conversation_replies
Authorization: Bearer <your_organization_bearer_token>
Content-Type: application/json
{
"contact_id": 789,
"message_body": "Your order #1234 has been shipped and should arrive by Friday!"
}Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
contact_id | integer | yes | The Wax contact ID (from the webhook payload) |
message_body | string | yes | Plain text message, max 4096 characters |
Channel Routing
The message is automatically sent via the contact's default channel:
- WhatsApp -- if the contact last messaged via WhatsApp (or has no channel set)
- RCS -- if the contact last messaged via RCS
You do not need to specify the channel -- Wax handles routing automatically.
Response
Success (200):
{
"success": true,
"message_id": "whatsapp:48214"
}| Field | Type | Description |
|---|---|---|
success | boolean | Always true on a 200 |
message_id | string? | ID of the message Wax created for your reply, in the same channel:id form as message.id on the webhook. null when the contact is opted out of the channel, in which case nothing was sent. Store it if you also use the helpdesk webhook, which mirrors your own replies back to you -- matching on this ID lets you drop the echo instead of duplicating the message in your thread |
Errors:
| Status | Description |
|---|---|
| 401 | Invalid or missing bearer token |
| 404 | Contact not found |
| 422 | Validation error (missing message_body, message too long, or channel not configured) |
Error response format:
{
"errors": ["Contact not found"]
}Example
const response = await fetch('https://api.getwax.io/v1/conversation_replies', {
method: 'POST',
headers: {
'Authorization': 'Bearer <your_bearer_token>',
'Content-Type': 'application/json'
},
body: JSON.stringify({
contact_id: 789,
message_body: 'Your order #1234 has been shipped!'
})
});import requests
response = requests.post(
'https://api.getwax.io/v1/conversation_replies',
headers={
'Authorization': 'Bearer <your_bearer_token>',
'Content-Type': 'application/json'
},
json={
'contact_id': 789,
'message_body': 'Your order #1234 has been shipped!'
}
)require 'faraday'
conn = Faraday.new(url: 'https://api.getwax.io')
response = conn.post('/v1/conversation_replies') do |req|
req.headers['Authorization'] = 'Bearer <your_bearer_token>'
req.headers['Content-Type'] = 'application/json'
req.body = {
contact_id: 789,
message_body: 'Your order #1234 has been shipped!'
}.to_json
endFull Integration Flow
Customer sends WhatsApp message
|
v
Wax receives message
|
v
Flows, keywords and waiting steps get first refusal
(if one of them handles it, your webhook is NOT called)
|
v
POST to your webhook URL
(with message + contact + history + shopify context)
|
v
Your AI processes the message
|
v
POST /v1/conversation_replies
(with contact_id + message_body)
|
v
Wax sends reply via WhatsApp/RCS
Timeline remains unresolved in inbox