Messaging
Send, edit, delete and react to messages, and upload the media they carry
Sending is the standard sendMessage mutation - the same one the dashboard calls. There is no separate machine-only send path, and no reduced message-type subset: a key can send anything an operator can.
Scope: MESSAGES_SEND for everything on this page. Posting an internal note additionally needs NOTES_WRITE.
sendMessage
sendMessage(chatId: String!, input: CreateCombinedMessageInput!): Message!Sends a message into an existing chat thread.
Arguments
| Argument | Type | Required | Description |
|---|---|---|---|
chatId | String! | Yes | The thread to send into. |
input | CreateCombinedMessageInput! | Yes | See below. |
actingUserId | String | No | Keys only. Author the message as this operator. Omitted, the author is the organisation that owns the key. See sending as an operator. |
async | Boolean | No | Keys only. Return immediately and report the outcome by webhook. See async send. |
CreateCombinedMessageInput - the fields that matter for an integration:
| Field | Type | Description |
|---|---|---|
text | String | Message body. |
media | [String!] | Media to attach. Each entry is either a media ID already in the org, or a public https:// URL Wexio fetches and ingests. See media. |
caption | String | Caption for a media message. |
showCaptionAboveMedia | Boolean | Render the caption above the media instead of below. |
hasMediaSpoiler | Boolean | Hide the media behind a spoiler, where the channel supports it. |
mediaGroup | [String!] | Media IDs to send as one grouped album. |
isMediaGroup | Boolean | Defaults to false. Set true with mediaGroup. |
mediaGroupId | String | Groups several sends into one album. |
buttons | [MessageButtonInput!] | Interactive buttons, where the channel supports them. |
replyToMessage | String | ID of a message to quote. Must be in the same chat. |
replyToButtonId | String | The button ID this message answers. |
internal | Boolean | Post as an internal note - team-only, never sent to the customer. Requires NOTES_WRITE. |
mentions | [ID!] | User IDs to mention on an internal note. |
keyboardType | String | Channel-specific keyboard hint. |
createdAt | DateTime | Override the creation timestamp - for backfilling imported history. |
deliveryStatus | MessageDeliveryStatus | Override the initial status. Defaults from the direction. |
Returns Message!
| Field | Type | Description |
|---|---|---|
_id | String! | The message ID. |
type | MessageType! | TEXT, MEDIA, BUTTONS, … |
direction | MessageDirection! | OUTBOUND for anything you send. |
text | String | The body as stored. |
media | [Media!] | Attached media. |
deliveryStatus | MessageDeliveryStatus | Provider-side delivery state - PENDING, SENT, DELIVERED, READ or FAILED. Check this on every send. |
deliveryStatusAt | DateTime | When the status last changed. |
errorCode | String | The provider's own error code when delivery failed, e.g. 131047 when a free-form WhatsApp message is sent outside the 24-hour window. |
errorMessage | String | Provider error text when delivery failed. |
createdAt | DateTime | Creation time. |
Message has no status field - delivery state is deliveryStatus alone. A provider-side failure returns a successful mutation with deliveryStatus: FAILED, so a 200 does not mean the customer received it.
Rules
- On WhatsApp, free-form text only works inside the 24-hour customer-service window. Outside it, send an approved template with
sendWhatsAppTemplateMessage. Instagram has its own window. mediaentries are either media IDs belonging to the acting org, or publichttps://URLs. A media ID from another org is 403; a URL that fails the SSRF check is 400 (see media by URL).replyToMessagemust point to a message in the same chat.
Example - text
mutation Send {
sendMessage(chatId: "6804f4c2a6f9f35f6e66f1a1", input: {
text: "Your pizza is on the way 🍕"
}) {
_id
deliveryStatus
createdAt
}
}{
"data": {
"sendMessage": {
"_id": "6804f5011c34d0012ab8a077",
"deliveryStatus": "SENT",
"createdAt": "2026-10-01T18:00:00.000Z"
}
}
}Example - an album with a caption
mutation SendAlbum {
sendMessage(chatId: "6804f4c2a6f9f35f6e66f1a1", input: {
isMediaGroup: true
mediaGroup: ["6805b1a0…", "6805b1a1…", "6805b1a2…"]
caption: "Tonight's specials"
showCaptionAboveMedia: true
}) { _id type deliveryStatus }
}Example - an internal note
mutation Note {
sendMessage(chatId: "6804f4c2a6f9f35f6e66f1a1", input: {
internal: true
text: "Customer called twice about this order - escalating."
}) { _id }
}Requires NOTES_WRITE. The note never reaches the customer and is invisible to keys without that scope.
Sending as an Operator
By default a key's message is authored by the organisation that owns the key. Pass actingUserId to author it as a named operator instead.
For a tech-provider key acting on a managed client, that default author is the provider, not the client - which is what a white-label integration usually wants.
| Value | A user ID of an active member of that organisation. Get it from orgTeamMembers - the userId field. |
| Not a member of the target org | Refused with user unavailable, and nothing is written. |
| Omitted | The organisation that owns the key. Surfaces on webhooks as author.kind: "Organisation". |
It works on sendMessage and on conversation notes.
Membership is per client org. A tech provider acting on one child must pass a member of that child. A member of a sibling child, or of the provider org itself, is refused - operators do not carry across the orgs you manage, so cache the member list per org rather than once per provider.
Use it when your own product already knows which agent is replying - the conversation then reads correctly in the Wexio inbox instead of showing everything as AI. Mention emails are not sent for notes a key originates; in-app notifications still are.
Async Send
A normal sendMessage waits for the channel provider. With async: true the call returns as soon as the message is stored, and the outcome arrives later as a webhook.
Keys only - a member passing async gets a 400. Internal notes and mentions ignore it and are answered synchronously.
The Flow
- Call
sendMessagewithasync: true. - Wexio validates and stores the message, bills it once, and returns immediately with
deliveryStatus: PENDINGand no provider id. message.outbound.createdfires withPENDING.- The provider send runs in the background, in creation order per chat.
message.outbound.statusfires once with the outcome -SENT(orDELIVERED/READon channels without receipts) with the provider id, orFAILEDwith an error.
Billing is the same either way: one operation per message, charged when it is stored, even if the send later fails. Async changes when you learn the outcome, not what it costs.
When to Use It
Use async: true for bulk sends and anywhere you do not want to hold a request open on a slow channel. Keep the synchronous form when your own code needs the provider id in the same call.
Async means you must have a webhook subscribed to message.outbound.status. Without it you never learn whether the message went out - the mutation's PENDING is not an outcome.
Over Capacity
If the async backlog is full, the call returns RATE_LIMITED with retryAfterMs and nothing is stored - so a retry cannot duplicate the message. See Rate limits.
updateMessage
updateMessage(messageId: String!, input: UpdateMessageInput!): Message!Edits a message you already sent.
Arguments
| Argument | Type | Required | Description |
|---|---|---|---|
messageId | String! | Yes | The message to edit. |
input | UpdateMessageInput! | Yes | See below. |
UpdateMessageInput
| Field | Type | Description |
|---|---|---|
text | String | New body. |
caption | String | New media caption. |
reaction | MessageReactionInput | Set a reaction inline. |
resolved | Boolean | Mark an internal note resolved. |
mentions | [ID!] | Replace the mention list on a note. |
updatedAt | DateTime | Override the edit timestamp. |
Returns Message! - the updated message.
Whether the edit propagates to the customer depends on the channel: some support editing a delivered message, others only change Wexio's copy.
deleteMessage
deleteMessage(messageId: String!): Boolean!Deletes a message. Returns true on success.
Emits message.inbound.deleted or message.outbound.deleted to your webhook.
Reactions
addMessageReaction(messageId: String!, emoji: String!): Message
removeMessageReaction(messageId: String!, emoji: String!): MessageAdd or remove an emoji reaction. Both return the updated Message, or null if there was nothing to change.
mutation React {
addMessageReaction(messageId: "6804f4f01c34d0012ab8a055", emoji: "👍") { _id }
}Reaction support is channel-dependent.
Media
Two ways to get media into a message: upload it, or hand Wexio a URL to fetch. Media IDs are scoped to the org they were uploaded into.
Media by URL
Put a public https:// URL straight into media and Wexio fetches, stores and sends it - no upload round-trip:
mutation SendByUrl {
sendMessage(chatId: "6804f4c2a6f9f35f6e66f1a1", input: {
media: ["https://cdn.example.com/menu.png?v=2"]
caption: "Tonight's menu"
}) { _id deliveryStatus }
}The fetch is SSRF-guarded. These are rejected with 400, and nothing is fetched:
- private and loopback addresses -
http://127.0.0.1/…,http://10.0.0.5/… - link-local metadata endpoints -
http://169.254.169.254/… - internal hostnames that resolve to private space
- non-HTTP schemes -
file:///etc/passwd
Use a publicly reachable https:// URL. If your asset lives behind a VPN or on a private host, upload it instead.
A URL that is fetched becomes a normal media record in the org, so the message you get back carries a real media ID.
Uploading
createMedia(createMediaInput: CreateMediaInput!, file: Upload!): Media!
uploadMultipleMedia(files: [Upload!]!, createMediaInputs: [CreateMediaInput!]!): [Media!]!
deleteMedia(id: String!): Boolean!| Operation | Use |
|---|---|
createMedia | One file. file is a GraphQL Upload - send a multipart/form-data request following the GraphQL multipart spec. |
uploadMultipleMedia | Several files at once. files and createMediaInputs are positional - index n of one pairs with index n of the other. |
deleteMedia | Remove a media record. |
Take _id from the returned Media and pass it in sendMessage's media or mediaGroup.
deleteMedia needs MESSAGES_SEND, not a read scope - it mutates the library. Listing media (allMedia) needs MESSAGES_READ.
sendWhatsAppTemplateMessage
sendWhatsAppTemplateMessage(input: SendWhatsAppTemplateInput!): SendWhatsAppTemplateResult!Sends an approved WhatsApp template. This is the path for reaching a customer outside the 24-hour window, and the only way to open a WhatsApp conversation from a phone number.
Read the available templates with whatsAppTemplates - that needs CHANNELS_MANAGE, while sending needs MESSAGES_SEND.
A Template Send Lands in Two Steps
The message is stored before it is rendered, so it exists in reads straight away:
| Stage | deliveryStatus | text |
|---|---|---|
| Stored | PENDING | A placeholder, [template:<name>] |
| Sent | SENT | The real rendered body |
| Rejected | FAILED | The placeholder stays; errorCode and errorMessage say why |
Do not show [template:<name>] to anyone. If you mirror messages, a template send appears first with that placeholder text, and the real body replaces it moments later. Wait for the message.outbound.status transition out of pending before treating the text as final, or your UI will briefly show a bracketed token to an operator.
A key cannot create or update templates. Authoring and submitting them to Meta for approval is dashboard-only. See Limitations.
See Channel constraints for which channels can start a conversation at all.