GraphQL API

Channels

Connect and disconnect WhatsApp, Telegram, Viber and Instagram, and read WhatsApp templates

Scope: CHANNELS_MANAGE for everything on this page, except sending a template, which needs MESSAGES_SEND.

This is how a partner onboards a client's channels without a human ever opening the Wexio dashboard.

How Many Channels an Org Can Have

An organisation can connect several channels of the same provider: many WhatsApp numbers, many Instagram accounts, many Telegram bots, many Viber bots. Only the web widget is one per organisation.

RuleDetail
Reconnecting in the same orgA WhatsApp number or Instagram account already connected here re-authenticates in place. It does not create a second channel.
Connected in another orgRefused: the number or account is already live elsewhere. One identity belongs to one organisation at a time.
Telegram botsPlan-limited for retail organisations - 1 on Free and Basic, 2 on Standard, 5 on Pro, unlimited on Enterprise. Tech-provider client orgs are uncapped, since they are billing-exempt.

Only channels that are not disconnected count toward the Telegram limit: connected, paused, pending, failed and idle all occupy a slot. A failed connect therefore holds one until it is disconnected - worth knowing if a client reports hitting a limit they appear to be under. The operator-facing version is in Channels.

Re-running a connect for an identity you already have is therefore safe - it refreshes the credentials rather than duplicating the channel. Moving a number between orgs is not: disconnect it on the old organisation first.

channels

channels(provider: IntegrationProvider): [Integration!]!

Lists the org's connected channels. Pass provider to filter to one.

query Connected {
  channels { _id provider }
}

Connecting

Two shapes, depending on what the channel's platform requires.

Bot token channels

connectTelegramChannel(botToken: String!): Integration!
connectViberChannel(authToken: String!): Integration!
ChannelCredentialWhere it comes from
TelegrambotTokenBotFather
ViberauthTokenThe Viber bot admin panel
mutation ConnectTelegram {
  connectTelegramChannel(botToken: "7123456789:AAE…") { _id provider }
}
partnerChannelConnectUrl(
  childOrganisationId: String!
  channel: HostedConnectChannel!
  returnUrl: String!
): String!

For WhatsApp and Instagram you do not have to handle Meta credentials yourself. This mutation returns a URL you send the customer to; Wexio runs the Meta consent flow, binds the result to the child org, and redirects back.

ArgumentTypeRequiredDescription
childOrganisationIdString!YesThe managed client org the channel binds to.
channelHostedConnectChannel!YesWHATSAPP or INSTAGRAM.
returnUrlString!YesWhere to send the customer afterwards. Status comes back on that redirect.

Returns String! - the URL to open. It carries a signed, single-use state that is valid for 10 minutes.

returnUrl is validated when you mint the link, not when the customer comes back:

RuleDetail
SchemeAbsolute https://. Plain http:// only for localhost outside production.
No credentials, no fragmentA URL carrying either is refused.
LengthAt most 2048 characters.

The URL you pass is signed into the state, so the callback can only ever return to a URL your authenticated key minted. A customer cannot steer the redirect by tampering with a parameter.

mutation HostedConnect {
  partnerChannelConnectUrl(
    childOrganisationId: "6716b2f0a1c34d0012ab89ef"
    channel: WHATSAPP
    returnUrl: "https://app.crmco.com/clients/5541/channels"
  )
}

The flow: your page → Wexio's Meta consent screen → binds to the child → redirect to returnUrl with status.

This is the path to use. Your customer consents in Meta's own UI under Wexio's app, so you never hold their access token, you need no Meta app of your own, and your domain needs no setup with Meta.

Call it with your partner key and the CHANNELS_MANAGE scope. It names the child in childOrganisationId, so do not send X-Wexio-Org with it.

Meta channels - direct credentials

connectWhatsAppChannel(input: ConnectWhatsAppChannelInput!): Integration!
connectInstagramChannel(accessToken: String!): Integration!

For when you already hold Meta credentials - your own Meta app, or a migration.

ConnectWhatsAppChannelInput

FieldTypeRequiredDescription
accessTokenString!YesA Meta access token for the WABA.
wabaIdString!YesWhatsApp Business Account ID.
phoneNumberIdString!YesThe phone number ID to send from.
tokenExpiresInIntNoToken lifetime in seconds, when you know it.

These take pre-obtained credentials - you source the token, WABA ID and phone number ID through your own Meta app and hand them over. Prefer hosted connect unless you specifically need this.

mutation ConnectWhatsApp {
  connectWhatsAppChannel(input: {
    accessToken: "EAAG…"
    wabaId: "102938475612345"
    phoneNumberId: "109876543210987"
    tokenExpiresIn: 5184000
  }) { _id provider }
}

Pause, Resume, Update

pauseChannel(integrationId: String!): Integration!
resumeChannel(integrationId: String!): Integration!
updateChannel(integrationId: String!, input: UpdateChannelInput!): Integration!

CHANNELS_MANAGE. Members need OWNER or ADMIN.

OperationEffect
pauseChannelThe channel stops serving. Nothing is deleted.
resumeChannelIt serves again. Expensive - see rate limits.
updateChannelChanges name and aiAutoReply, nothing else.

UpdateChannelInput

FieldTypeDescription
nameStringDisplay name.
aiAutoReplyobjectmode (ORG_DEFAULT, SPECIFIC, RULES, DISABLED), assistantId, and rules for RULES mode.

An empty update is a 400. mode: SPECIFIC requires an assistantId belonging to this org, or it is a not-found.

mode: RULES takes an ordered rules list of { condition, assistantId }; the first match wins and the top-level assistantId is the fallback. The concept is in choosing an assistant by rules; the condition format is below.

mutation Route {
  updateChannel(integrationId: "67f95b…", input: {
    aiAutoReply: {
      mode: RULES
      assistantId: "<fallback assistant>"
      rules: [
        { condition: { in: [{ var: "field.plan" }, ["Pro", "Enterprise"]] },
          assistantId: "<paid-plan assistant>" }
      ]
    }
  }) { _id }
}

The web widget takes the same rules as aiAutoReplyRules on createWebIntegration / updateWebIntegration.

Every assistantId, fallback included, must belong to the organisation - otherwise 404 AI assistant not found.

Writing a Rule Condition

A condition is JSON in a small subset of json-logic. The shape rule is strict and applies at every level: each object must have exactly one key, and that key must be one of the operators below. There is no escape hatch - a second key, a zero-key object, or an unknown key is rejected outright, which is also what keeps $-prefixed Mongo operators and __proto__ out of the stored value.

mode: RULES needs a paid plan. On Free it fails with PLAN_FEATURE_NOT_ALLOWED; see plan feature not allowed.

Operators

OperatorFormNotes
and{"and": [c1, c2, …]}All must hold.
or{"or": [c1, c2, …]}Any must hold.
!{"!": c}Negation.
== / !={"==": [a, b]}Equality.
< <= > >={"<": [a, b]}Ordering.
in{"in": [needle, haystack]}haystack is an array literal, or a string for a substring test.
var{"var": "path"}Reads a fact. See below.

Facts

var reads only these. Nothing else about the visitor is exposed to a rule.

PathValue
isAuthenticatedtrue or false.
authMethod"anonymous", "jwt", "google" or "passkey".
verifiedArray of verification tags, for example "EMAIL_GOOGLE".
field.<key>A trusted contact field: a read-only field, a value signed into the widget JWT's fields claim, or one an operator verified.
verifiedField.<key>The same, but only when verified through JWT or an operator.

field.<key> is trusted input only. A value a visitor typed into a form is not a trusted field and will not be visible to a rule - which is the point: a visitor must not be able to route themselves to a different assistant by claiming a plan.

Examples

{"==": [{"var": "isAuthenticated"}, true]}
{"==": [{"var": "authMethod"}, "anonymous"]}
{"in": [{"var": "authMethod"}, ["jwt", "google"]]}
{"in": [{"var": "field.plan"}, ["Pro", "Enterprise"]]}
{"in": ["EMAIL_GOOGLE", {"var": "verified"}]}
{"and": [{"==": [{"var": "isAuthenticated"}, true]},
         {"==": [{"var": "field.plan"}, "Pro"]}]}

Limits

Rules per channel20, else 400
One condition, serialized4000 characters
Nesting depth32
assistantId on a ruleRequired

Every var path is also checked on save. It must be authMethod, isAuthenticated, verified, or a field.<key> / verifiedField.<key> whose <key> is an existing People field in your organisation - in either the {"var": "x"} or the {"var": ["x", default]} form. A path that is not, including a mistyped key, is a 400:

Unknown fact "field.pln" in an agent-selection rule. Use authMethod, isAuthenticated, verified, field.<key> or verifiedField.<key> with an existing People field key.

This exists because a misspelled fact fails closed at runtime: without the save-time check, the rule would simply never match and the typo would stay invisible.

What Fails, and How

An invalid condition is rejected on save, with a 400. A valid condition that cannot be evaluated - it reads a field this visitor does not have, say - is not an error: it simply does not match, and selection falls through to the next rule and then to the fallback.

A rule that silently never fires is therefore not reported anywhere. Order your rules from most specific to least, and make the fallback an assistant you are content for anyone to reach.

Checking Which Rule Won

Read the outcome per chat, on Chat:

Field
copilotAssistantPinnedByRULE a rule matched, OPERATOR an operator pinned the assistant by hand, DEFAULT nothing matched and the fallback or the organisation default answered.
copilotAssistantRuleIndexWhich rule matched, as a 0-based index into the channel's rules. Set only when copilotAssistantPinnedBy is RULE; null for DEFAULT, and cleared when an operator pins.
copilotAssistantIdWhich assistant answered.

All three come back from any operation returning a Chat - searchChats, collectionChats - under MESSAGES_READ. They are on Chat, not Conversation.

copilotAssistantRuleIndex is an index, not an id, so it is only meaningful against the rules array as it is now. Reorder the rules and the indexes recorded on older chats refer to different rules.

An assistantId that is unknown, malformed, or belongs to another organisation is the same 404 AI assistant not found, fallback included. The error does not distinguish the three, so it cannot be used to probe for assistants in other organisations.

Deleting an assistant does not break a channel. Rules naming it are dropped, a RULES fallback naming it is cleared, and a channel in SPECIFIC mode naming it reverts to ORG_DEFAULT.

Pausing is the reversible way to take a client's channel offline - during a billing dispute, say - without disconnecting it and losing the binding. A resume that conflicts with a WhatsApp number live elsewhere answers Channel cannot be resumed for a key; a member sees the precise reason.

A channel that is non-messaging, foreign, malformed or missing is one Channel not found - no existence oracle. Every channel operation by a key writes an audit line.

disconnectChannel

disconnectChannel(integrationId: String!, deletePeople: Boolean = false): IntegrationCleanupResult!

Disconnects a channel and cleans up behind it.

Arguments

ArgumentTypeDefaultDescription
integrationIdString!-The integration to disconnect.
deletePeopleBooleanfalseAlso delete the contacts that reached the org only through this channel.

deletePeople: true is destructive and not reversible. It removes contact records, which cascades to their threads and messages - and emits chat.deleted and contact.deleted per row. Leave it false unless you intend exactly that.

For a key, deletePeople: true additionally requires the CONTACTS_ERASE scope - 403 without it. Members are unchanged.

WhatsApp Templates

Outside WhatsApp's 24-hour window you can only reach a customer with an approved template. These read and refresh the template list.

whatsAppTemplates(filter: WhatsAppTemplateFilterInput): WhatsAppTemplateConnection!  # CHANNELS_MANAGE
whatsAppTemplate(id: String!): WhatsAppTemplate                                      # CHANNELS_MANAGE
syncWhatsAppTemplates(integrationId: String!): WhatsAppTemplateSyncResult!           # CHANNELS_MANAGE
OperationDescription
whatsAppTemplatesLists templates with their approval status.
whatsAppTemplateReads one. Returns null if there is no such template.
syncWhatsAppTemplatesRe-pulls templates from Meta, so newly approved ones become usable.

Send one with sendWhatsAppTemplateMessage - that needs MESSAGES_SEND, not CHANNELS_MANAGE.

Creating and Deleting Templates

createWhatsAppTemplate(…): CreateWhatsAppTemplateResult!
updateWhatsAppTemplate(…): UpdateWhatsAppTemplateResult!
deleteWhatsAppTemplate(…): DeleteWhatsAppTemplateResult!

CHANNELS_MANAGE. Members need OWNER, ADMIN or EDITOR.

OperationWhat it does
createWhatsAppTemplateSubmits a new template to Meta for approval. Expensive, and capped for keys.
updateWhatsAppTemplateLocal metadata only - variable mappings, default media, product, expiry. Meta content cannot be edited. Not capped.
deleteWhatsAppTemplateExpensive. Deletes the template on Meta.

Two things about delete that surprise people. It removes every language variant of that template name, not just one. And Meta then blocks reusing the name for several weeks - so a delete-and-recreate cycle to "fix" a template does not work.

Use updateWhatsAppTemplate for anything local, and create a differently-named template when the Meta-side content must change.

Key creates are capped at 20 per rolling 24 hours per org by default. A Meta rejection still spends a token. See the daily cap.

Media on a template - defaultMediaId, header media, carousel media - must belong to the same org, or it is a not-found. Creating is allowed on channels that are paused, idle, pending or failed, and refused on disconnected channels. A bad integration id raises a not-found error rather than returning a failure object.

Telegram Bot Settings

A key can manage the Telegram bot's own profile - what users see in Telegram before they ever message you.

channelTelegramBotSettings(integrationId: String!): TelegramBotSettings    # read
syncChannelTelegramBotSettings(integrationId: String!)                     # re-pull from Telegram
setChannelTelegramBotName(input: …)
setChannelTelegramBotDescription(input: …)
setChannelTelegramBotShortDescription(input: …)
setChannelTelegramBotCommands(input: …)
deleteChannelTelegramBotCommands(integrationId: String!)
setChannelTelegramMenuButton(input: …)

CHANNELS_MANAGE. Members need OWNER or ADMIN. All the mutations return TelegramBotSettings!.

SettingWhat the user sees
NameThe bot's display name.
DescriptionThe long text on the bot's empty-chat screen.
Short descriptionThe one-liner in the bot's profile.
CommandsThe / command menu.
Menu buttonThe button beside the message box.

syncChannelTelegramBotSettings re-reads the current state from Telegram and is expensive. A channel that is not Telegram, foreign or missing is one Channel not found.

This is what lets a tech provider brand each client's bot without anyone opening Telegram - set the name, description and commands at provisioning time, alongside connecting the channel.

Web Widget

The website chat widget is a channel too, and a key with CHANNELS_MANAGE can manage it end to end - useful for a partner provisioning a widget per client.

webIntegration: WebIntegration                                            # the org's widget
webIntegrationById(id: ID!): WebIntegration
createWebIntegration(input: CreateWebIntegrationInput!): WebIntegrationWithSecret!
updateWebIntegration(id: ID!, input: UpdateWebIntegrationInput!): WebIntegration!
rotateWebIntegrationSecret(id: ID!): WebIntegrationWithSecret!
deleteWebIntegration(id: ID!): Boolean!

CreateWebIntegrationInput

FieldTypeDefaultDescription
nameString!-Widget name.
allowedOrigins[String!]![]Origins allowed to embed the widget. An empty list embeds nowhere - set it.
aiAutoReplyModeAiAutoReplyMode!ORG_DEFAULTWhether the AI answers automatically in this widget.
aiAutoReplyAssistantIdID-Which assistant to use, when not the org default.

Returns WebIntegrationWithSecret! - { integration: WebIntegration!, sharedSecret: String! }.

sharedSecret is returned only by createWebIntegration and rotateWebIntegrationSecret. It never appears on a read. Store it at creation; if you lose it, rotate - which invalidates the old one.

The secret is what signs authenticated visitor sessions. Embedding and configuring the widget itself is covered in the Web Widget docs.

Which Channels Can Start a Conversation

ChannelReply to an existing threadStart a new conversation
WhatsAppFree-form inside the 24-hour windowYes - approved template required outside it
TelegramYesNo - the user must start the bot
ViberYesNo - the user must subscribe
InstagramYes, within its own windowNo
WebYesNo - visitor-initiated only

Full detail in Channel constraints.

On this page