Mirror conversations into your help desk — every message, both directions, once a conversation needs attention.
External Helpdesk Integration
Mirror WhatsApp and RCS conversations into your own help desk. Once a conversation needs attention, Wax POSTs every message on it to your URL, one request per message, in both directions: free-text, quick replies, media, and the automated messages your flows send.
This is a one-way feed. Wax expects no answer beyond an acknowledgement -- your agents reply from your help desk, and you send those replies back through POST /v1/conversation_replies when you want them delivered to the customer.
Configure this integration in Settings → Organization → External helpdesk.
When Wax starts mirroring
Nothing is forwarded while your automations are handling a conversation normally. The mirror opens when the conversation starts needing a human:
- a customer sends a free-text message that no flow, keyword or waiting step answered, which marks the conversation unresolved, or
- the Wax AI agent escalates the conversation to a human
From that moment every message on the conversation is forwarded until it is resolved. That means a customer who then taps a quick reply, sends a photo, or receives an automated message from one of your flows all produce a webhook call -- which is the difference from the External AI webhook, where only the unanswered message is sent.
When an agent resolves the conversation in the Wax inbox, forwarding stops.
Two event types
Everything that happened before the conversation needed attention was never forwarded, so every call carries the transcript to give your agent context. The two event types only tell you what to do with it:
event | When | What to do |
|---|---|---|
conversation.opened | The first call, for the message that opened the mirror | Create the ticket |
message.created | Every message after that | Append to it |
Both carry the same fields, including history -- up to the last 20 messages of the conversation, oldest first, excluding message itself. history entries use exactly the same shape as message, so one parser handles both.
Every payload is self-contained on purpose. Deliveries are at-least-once and a message is dropped once its retries are exhausted, so repeating the transcript lets the next call repair the gap instead of leaving you permanently short a message. It also means a retry arriving out of order cannot corrupt your thread. The cost is that history overlaps heavily between calls -- upsert on message.id rather than appending blindly.
External AI vs External Helpdesk
| External AI | External Helpdesk | |
|---|---|---|
| Purpose | Your service answers the customer | Your service observes the conversation |
| Fires for | Only messages nothing else handled | Every message, once the conversation needs attention |
| Direction | Inbound only | Inbound and outbound |
| Quick replies | No -- the flow handles them | Yes |
| Automated / flow messages | No | Yes |
| Calls per conversation | One per unanswered message | One per message |
| Side effects | Interrupts flows, marks unresolved, sends a typing indicator | None -- flows keep running untouched |
| Conversation history | On every call | On every call |
| Shopify context | Optional, on every call | Never |
| Reply expected | Yes, via the API | No |
The two are independent, each has its own settings page, and they can run at the same time.
Request
POST <your_webhook_url>
Content-Type: application/json
X-Signature: <HMAC-SHA256 signature>
Signature Verification
Identical to the External AI webhook: X-Signature is an HMAC-SHA256 hex digest of the raw JSON body, signed with your organization's bearer token. Verify it before trusting the payload.
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)Any custom headers you configure are sent alongside Content-Type and X-Signature. A custom header can never overwrite those two.
Payload Structure
Every call looks like this, with event distinguishing the first one:
{
"event": "conversation.opened",
"organization_id": 42,
"contact_id": 789,
"timeline": {
"id": 987,
"status": "unresolved"
},
"message": {
"id": "whatsapp:48213",
"external_message_id": "wamid_ABCdef123456",
"channel": "whatsapp",
"direction": "inbound",
"kind": "text",
"origin": "customer",
"content": "that is not the order I asked about",
"timestamp": "2026-07-08T14:03:21.532Z"
},
"contact": {
"name": "John Doe",
"phone": "+33612345678",
"email": "[email protected]",
"tags": ["vip", "returning_customer"],
"custom_attributes": {
"loyalty_tier": "gold"
}
},
"history": [
{
"id": "whatsapp:48201",
"external_message_id": "wamid_XYZabc987654",
"channel": "whatsapp",
"direction": "outbound",
"kind": "template",
"origin": "flow",
"content": "Hi John! What can we help you with?",
"timestamp": "2026-07-08T13:58:02.101Z"
},
{
"id": "whatsapp:48204",
"external_message_id": "wamid_QRSdef456789",
"channel": "whatsapp",
"direction": "inbound",
"kind": "quick_reply",
"origin": "customer",
"content": "Track my order",
"timestamp": "2026-07-08T13:58:40.870Z"
}
]
}Every later call is identical in shape, with "event": "message.created" and history grown by one entry.
A media message carries one extra field:
{
"message": {
"id": "whatsapp:48220",
"channel": "whatsapp",
"direction": "inbound",
"kind": "document",
"origin": "customer",
"content": "📄 Document",
"timestamp": "2026-07-08T14:06:02.004Z",
"media_url": "https://wax-media.s3.eu-west-3.amazonaws.com/8f2c....pdf"
}
}Field Reference
| Field | Type | Description |
|---|---|---|
event | string | conversation.opened on the first call (create the ticket), message.created afterwards (append) |
organization_id | integer | Your Wax organization ID |
contact_id | integer | Wax contact ID -- use this to send replies |
timeline.id | integer | ID of the conversation, stable for the life of the contact |
timeline.status | string | unresolved, escalated, new, done or ads. Use timeline.id to group messages into a thread |
message.id | string | Stable unique message ID, prefixed with the channel (whatsapp:123, rcs:45) -- use it to deduplicate |
message.external_message_id | string? | Provider-side message ID (e.g. the WhatsApp wamid). Can be null |
message.channel | string | whatsapp or rcs |
message.direction | string | inbound (from the contact) or outbound (sent by Wax) |
message.kind | string | The message type, which differs per channel. WhatsApp: text, quick_reply, list_option, image, video, audio, document, template, interactive, caption. RCS: text, image, video, audio, document, file, card, carousel. Treat it as an open set |
message.origin | string | Who produced it: customer, human (a teammate in the Wax inbox), ai_agent (the Wax AI agent), flow (an automation) or api (sent through /v1/conversation_replies) |
message.content | string | Text of the message. For media with a caption, the caption; for media without one, a short label such as 📷 Photo |
message.timestamp | string | ISO8601 with millisecond precision |
message.media_url | string? | Present for image, video, audio and document messages. A permanent link Wax re-hosted, fetchable directly with no authentication. Use kind to know what to expect |
contact.name | string? | Contact display name, falling back to the phone number |
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. Omitted when the contact has none |
history | array | Sent on every call. Up to the last 20 messages of the conversation, oldest first, excluding message itself. Each entry has exactly the same fields as message. Overlaps heavily between calls by design -- deduplicate on id |
What is not mirrored
- Reactions, stickers, shared locations and shared contacts. These never produce a call and never appear in
history, so a customer sharing their address as a WhatsApp location will not appear in your help desk. - SMS. Only WhatsApp and RCS conversations are mirrored.
- Anything older than the last 20 messages of a long-running conversation.
Expected Response
Return any 2xx status to acknowledge receipt. The response body is ignored.
A non-2xx response or a timeout (3s to connect, 5s to read) is retried up to 5 times with exponential backoff. After that the message is dropped -- the conversation is unaffected, and later messages are still forwarded.
Your URL must be publicly resolvable. Requests to private, loopback, link-local or cloud-metadata addresses are refused before any connection is made, on the initial request and on every redirect.
Deduplication
Deliveries are at-least-once, so treat message.id as the primary key and upsert rather than append. It is stable across retries and identical for the same message every time.
Your own replies come back to you. A message you send through POST /v1/conversation_replies is mirrored back with direction: "outbound" and origin: "api". That endpoint returns a message_id in exactly the same channel:id format, so store it when you send and drop the matching echo:
// 1. Send the agent's reply
const res = await fetch('https://api.getwax.io/v1/conversation_replies', {
method: 'POST',
headers: { Authorization: 'Bearer <token>', 'Content-Type': 'application/json' },
body: JSON.stringify({ contact_id: 789, message_body: 'On its way!' }),
});
const { message_id } = await res.json(); // "whatsapp:48214"
await sentByUs.add(message_id);
// 2. Later, in your webhook handler
if (await sentByUs.has(payload.message.id)) return ack(); // our own echo, ignoreIf you would rather not track IDs, filtering on origin: "api" drops every message sent through the API -- at the cost of also dropping replies sent by other API integrations on the same organization.
Full Integration Flow
Customer goes through your flows
(nothing forwarded — your automations are handling it)
|
v
Customer asks something no flow answered
|
v
Wax marks the conversation unresolved
|
v
POST event=conversation.opened
(the message + history: the flow messages and quick replies above)
-> create the ticket
|
v
Every subsequent message is forwarded as event=message.created, one call each,
every one repeating the transcript so a dropped delivery self-heals:
customer taps a quick reply -> direction inbound, origin customer
customer sends a photo -> direction inbound, origin customer, media_url
your flow sends an update -> direction outbound, origin flow
a teammate replies in Wax -> direction outbound, origin human
you reply via the API -> direction outbound, origin api (your own echo)
|
v
Agent resolves the conversation -> forwarding stops