External Helpdesk Integration

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:

eventWhenWhat to do
conversation.openedThe first call, for the message that opened the mirrorCreate the ticket
message.createdEvery message after thatAppend 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 AIExternal Helpdesk
PurposeYour service answers the customerYour service observes the conversation
Fires forOnly messages nothing else handledEvery message, once the conversation needs attention
DirectionInbound onlyInbound and outbound
Quick repliesNo -- the flow handles themYes
Automated / flow messagesNoYes
Calls per conversationOne per unanswered messageOne per message
Side effectsInterrupts flows, marks unresolved, sends a typing indicatorNone -- flows keep running untouched
Conversation historyOn every callOn every call
Shopify contextOptional, on every callNever
Reply expectedYes, via the APINo

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

FieldTypeDescription
eventstringconversation.opened on the first call (create the ticket), message.created afterwards (append)
organization_idintegerYour Wax organization ID
contact_idintegerWax contact ID -- use this to send replies
timeline.idintegerID of the conversation, stable for the life of the contact
timeline.statusstringunresolved, escalated, new, done or ads. Use timeline.id to group messages into a thread
message.idstringStable unique message ID, prefixed with the channel (whatsapp:123, rcs:45) -- use it to deduplicate
message.external_message_idstring?Provider-side message ID (e.g. the WhatsApp wamid). Can be null
message.channelstringwhatsapp or rcs
message.directionstringinbound (from the contact) or outbound (sent by Wax)
message.kindstringThe 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.originstringWho 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.contentstringText of the message. For media with a caption, the caption; for media without one, a short label such as 📷 Photo
message.timestampstringISO8601 with millisecond precision
message.media_urlstring?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.namestring?Contact display name, falling back to the phone number
contact.phonestringPhone number in E.164 format
contact.emailstring?Contact email address
contact.tagsstring[]Tag names assigned to the contact
contact.custom_attributesobject?Key-value pairs of custom attributes. Omitted when the contact has none
historyarraySent 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, ignore

If 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