GraphQL API

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

ArgumentTypeRequiredDescription
chatIdString!YesThe thread to send into.
inputCreateCombinedMessageInput!YesSee below.
actingUserIdStringNoKeys only. Author the message as this operator. Omitted, the author is the organisation that owns the key. See sending as an operator.
asyncBooleanNoKeys only. Return immediately and report the outcome by webhook. See async send.

CreateCombinedMessageInput - the fields that matter for an integration:

FieldTypeDescription
textStringMessage 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.
captionStringCaption for a media message.
showCaptionAboveMediaBooleanRender the caption above the media instead of below.
hasMediaSpoilerBooleanHide the media behind a spoiler, where the channel supports it.
mediaGroup[String!]Media IDs to send as one grouped album.
isMediaGroupBooleanDefaults to false. Set true with mediaGroup.
mediaGroupIdStringGroups several sends into one album.
buttons[MessageButtonInput!]Interactive buttons, where the channel supports them.
replyToMessageStringID of a message to quote. Must be in the same chat.
replyToButtonIdStringThe button ID this message answers.
internalBooleanPost as an internal note - team-only, never sent to the customer. Requires NOTES_WRITE.
mentions[ID!]User IDs to mention on an internal note.
keyboardTypeStringChannel-specific keyboard hint.
createdAtDateTimeOverride the creation timestamp - for backfilling imported history.
deliveryStatusMessageDeliveryStatusOverride the initial status. Defaults from the direction.

Returns Message!

FieldTypeDescription
_idString!The message ID.
typeMessageType!TEXT, MEDIA, BUTTONS, …
directionMessageDirection!OUTBOUND for anything you send.
textStringThe body as stored.
media[Media!]Attached media.
deliveryStatusMessageDeliveryStatusProvider-side delivery state - PENDING, SENT, DELIVERED, READ or FAILED. Check this on every send.
deliveryStatusAtDateTimeWhen the status last changed.
errorCodeStringThe provider's own error code when delivery failed, e.g. 131047 when a free-form WhatsApp message is sent outside the 24-hour window.
errorMessageStringProvider error text when delivery failed.
createdAtDateTimeCreation 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.
  • media entries are either media IDs belonging to the acting org, or public https:// URLs. A media ID from another org is 403; a URL that fails the SSRF check is 400 (see media by URL).
  • replyToMessage must 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.

ValueA user ID of an active member of that organisation. Get it from orgTeamMembers - the userId field.
Not a member of the target orgRefused with user unavailable, and nothing is written.
OmittedThe 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

  1. Call sendMessage with async: true.
  2. Wexio validates and stores the message, bills it once, and returns immediately with deliveryStatus: PENDING and no provider id.
  3. message.outbound.created fires with PENDING.
  4. The provider send runs in the background, in creation order per chat.
  5. message.outbound.status fires once with the outcome - SENT (or DELIVERED/READ on channels without receipts) with the provider id, or FAILED with 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

ArgumentTypeRequiredDescription
messageIdString!YesThe message to edit.
inputUpdateMessageInput!YesSee below.

UpdateMessageInput

FieldTypeDescription
textStringNew body.
captionStringNew media caption.
reactionMessageReactionInputSet a reaction inline.
resolvedBooleanMark an internal note resolved.
mentions[ID!]Replace the mention list on a note.
updatedAtDateTimeOverride 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!): Message

Add 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!
OperationUse
createMediaOne file. file is a GraphQL Upload - send a multipart/form-data request following the GraphQL multipart spec.
uploadMultipleMediaSeveral files at once. files and createMediaInputs are positional - index n of one pairs with index n of the other.
deleteMediaRemove 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:

StagedeliveryStatustext
StoredPENDINGA placeholder, [template:<name>]
SentSENTThe real rendered body
RejectedFAILEDThe 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.

On this page