Webhooks and APIOutbound

Shared Types

Payload interfaces and enums used across outbound webhook events

Every outbound event's data is built from these payload types. Each event page links back here for the full definitions.

ChatPayload

interface ChatPayload {
  id: string;                 // Wexio chat id
  integrationId?: string;     // Owning messaging integration (Telegram bot, WhatsApp number, etc.)
  channel?: WebhookChannel;   // "WHATSAPP" | "TELEGRAM" | "VIBER" | "INSTAGRAM" | "WEB"
  externalChatId?: string;    // Provider-side chat id (WhatsApp chat, Telegram chat)
  whatsappId?: string;        // Derived from the contact; present when channel=WHATSAPP
  telegramId?: string;        // Telegram user id as string
  viberId?: string;           // Viber user id
  instagramId?: string;       // Instagram user id
  firstName?: string;
  lastName?: string;
  photoUrl?: string;
  username?: string;
  title?: string;             // Group/channel title
  createdAt?: string;         // ISO-8601 UTC
  updatedAt?: string;         // ISO-8601 UTC
  conversationId?: string;    // The Conversation this thread belongs to
  contactId?: string;         // Merge-winner Contact id
  threadKey?: string;         // Semantic thread key; absent on 1:1 threads
  status?: string;            // Thread status, so chat.updated shows the change
  assignedTo?: { kind: string; id: string };  // Assignee, id only
}
  • threadKey names a topic thread within a conversation. A web visitor's "billing" question is a different thread from their main one, under the same conversation and contact. It is absent on ordinary one-to-one threads, so its presence tells you a conversation has topic threads.
  • assignedTo is id-only: { kind: "OPERATOR", id }, never the operator's record or email. kind is always OPERATOR. Resolve the name on your side.
  • conversationId is the conversation this thread sits under. A chat is one channel thread inside a conversation, not the top-level unit.
  • contactId is the merge-winner contact id. It survives a merge, which peopleId does not, so key your own records on it.
  • channel is resolved from the thread. A website-widget chat reports WEB. Otherwise it comes from the contact's channel-specific id (whatsappId, telegramId, viberId, instagramId, in that order). It is absent only when the chat carries nothing identifying.

Slim variant - chat.deleted and message.*.deleted events carry only:

Pick<ChatPayload, "id" | "integrationId" | "channel">

ContactPayload

interface ContactPayload {
  peopleId: string;           // Wexio People record id (always present)
  phoneNumber?: string;
  email?: string;
  firstName?: string;
  lastName?: string;
  createdAt?: string;         // ISO-8601 UTC
  updatedAt?: string;         // ISO-8601 UTC
  contactId?: string;         // Merge-winner Contact id this People row belongs to
  merged?: {                  // Present only on a merge winner / retired loser
    mergedInto?: string;      // On a retired loser row: the winner it points at
    loosersIds?: string[];    // On a winner: the People ids folded into it
  };
}

peopleId is not stable across a merge - a merge retires People rows. Key your records on contactId (the merge-winner id) if you want contact.merged to reconcile cleanly.

Slim variant - contact.deleted events carry only { peopleId: string }.

Merge variants - contact.merged and contact.split do not use this shape. They carry id lists instead: { from: string[], into: string } and { restored: string[], from: string } respectively.

ConversationPayload

The inbox row a contact occupies, grouping their channel threads. Delivered under data.conversation on conversation.created and conversation.updated.

interface ConversationPayload {
  id: string;                    // Wexio conversation id
  contactId?: string;            // Merge-winner Contact id
  createdAt?: string;            // ISO-8601 UTC
  updatedAt?: string;            // ISO-8601 UTC
}

The payload is lean: it identifies the conversation and nothing more. To see its threads, read the conversation back.

MessagePayload

interface MessagePayload {
  id: string;                       // Wexio message id
  chatId: string;                   // Wexio chat id
  integrationId?: string;
  direction: "INBOUND" | "OUTBOUND";
  type: MessageType;                // see enum below
  text?: string;                    // Plain text, or media caption
  media?: MessageMediaItem[];       // For media/media-group types
  receivedAt?: string;              // Present on inbound
  sentAt?: string;                  // Present on outbound
  deliveryStatus?: "pending" | "sent" | "delivered" | "read" | "failed";  // Outbound lifecycle
  deliveredAt?: string;             // Set once delivered or read
  readAt?: string;                  // Set once read
  failedAt?: string;                // Set when deliveryStatus is failed
  errorCode?: string;               // Provider error code, when it can be parsed
  errorMessage?: string;
  raw?: {                           // Channel-specific provider IDs
    providerMessageId?: string;     // e.g. Telegram message_id, WhatsApp wamid
    [k: string]: unknown;
  };
  conversationId?: string;          // The Conversation this message belongs to
  contactId?: string;               // Merge-winner Contact id
  peopleId?: string;                // Per-channel People identity
  channel?: WebhookChannel;         // Channel this message rode
  threadKey?: string;               // Topic thread; absent on 1:1 threads
  fromModel?: string;               // "User"|"Bot"|"People"|"AIAssistant"|"Organisation"
  author?: { kind: string; id?: string; name?: string };  // name for User and Organisation
  apiKeyId?: string;                // Public id of the API key that sent it
}

interface MessageMediaItem {
  mediaId?: string;                 // Stable Wexio media id
  scanStatus?: ScanStatus;          // see enum below
  url?: string;                     // Signed link; absent when INFECTED
  type?: MediaType;                 // see enum below
  mimeType?: string;
  filename?: string;
  sizeBytes?: number;
  caption?: string;
}
  • apiKeyId is the public id of the API key that sent an outbound message, including when the key acted as an operator. It is absent on inbound messages and on anything sent by a member or the AI.
  • deliveryStatus follows an outbound message from pending to read, or to failed. deliveredAt, readAt and failedAt are set from the status.
  • url is a link valid for 1 hour for media Wexio stores, and it is regenerated on every delivery attempt. An INFECTED item has no url. Use mediaId to deduplicate attachments.

Slim variant - message.*.deleted events carry only:

Pick<MessagePayload, "id" | "chatId" | "direction" | "type">

Who Sent It

author and fromModel carry the same sender kind:

KindWho
UserA human operator.
AIAssistantThe AI assistant.
BotA flow or bot - not the AI assistant.
PeopleThe customer.
OrganisationAn API key with no acting operator. id is the organisation that owns the key, and name is its name.

Organisation is new. If your handler has a strict allow-list of author.kind values, add it or those messages are dropped or mislabelled.

It appears whenever a message is sent with an API key and no actingUserId. For a tech-provider key acting on a managed client, the author is the provider, not the client.

Bot does not mean the AI. The AI assistant is AIAssistant, and Bot is a flow or bot sender. Swapping them mislabels every automated reply.

On an outbound message this is the only thing that tells a human reply from an AI one, because both arrive as message.outbound.created.

author pairs the kind with the sender's id, and carries a name for User and Organisation. The sender's record is never included, and an operator's email never appears.

Routing Without a Lookup

contactId, peopleId, channel and threadKey appear on the message as well as the chat, so you can route an event from data.message alone.

FieldUse
contactIdThe person, stable across merges. Key your records on this.
peopleIdThe per-channel identity behind this particular message.
channelWhich channel carried it.
threadKeyWhich topic thread, when the conversation has several.

All four are optional. They are filled only when the emitting code had the chat and the person at hand, and the slim message.*.deleted payload has none of them. Never assume they are present.

CommentPayload

An Instagram post comment. Delivered under data.comment on the comment.* events.

interface CommentPayload {
  id: string;                  // Wexio comment id
  postId: string;              // The SocialPost it belongs to
  commenterId: string;         // The Commenter record
  rootCommentId?: string;      // Set on replies; absent on top-level comments
  externalCommentId?: string;  // Instagram's own comment id
  text?: string;
  hidden?: boolean;            // Hidden on Instagram
  deleted?: boolean;           // Soft-deleted in Wexio
  createdAt?: string;          // ISO-8601 UTC
  updatedAt?: string;          // ISO-8601 UTC
}

The payload is flat: postId and commenterId are ids, not expanded objects. Resolve them yourself if you need the post or commenter.

PostPayload

An Instagram post. Delivered under data.post on post.created and post.deleted.

interface PostPayload {
  id: string;                  // Wexio post id
  integrationId: string;       // The Instagram integration
  channel: string;             // "INSTAGRAM"
  externalPostId: string;      // Instagram's own media id
  caption?: string;
  username?: string;           // Publishing account's channel username
  isDeleted?: boolean;
  publishedAt?: string;        // When it went live on the channel
  createdAt?: string;          // When Wexio first recorded it
  updatedAt?: string;          // ISO-8601 UTC
}

On the sync path of post.deleted, only id is meaningful. integrationId comes back as an empty string and every other field is absent, so treat everything but id as optional on that event.

Enums

type WebhookChannel = "WHATSAPP" | "TELEGRAM" | "VIBER" | "INSTAGRAM" | "WEB";

type MessageDirection = "INBOUND" | "OUTBOUND";

enum MessageType {
  TEXT = "TEXT",
  BUTTONS = "BUTTONS",
  MEDIA = "MEDIA",
  MEDIA_BUTTONS = "MEDIA_BUTTONS",
  MEDIA_GROUP = "MEDIA_GROUP",
  LOCATION = "LOCATION",
  POOL = "POOL",                  // (sic - poll)
  CONTACT = "CONTACT",
  PIN = "PIN",
  GAME = "GAME",
  DICE = "DICE",                  // Telegram animated emoji
  CHAT_UPDATE = "CHAT_UPDATE",
  STORY = "STORY",
  UNKNOWN = "UNKNOWN",
  UNSUPPORTED = "UNSUPPORTED",    // Sent by the contact but not deliverable in a supported form
  // WhatsApp-specific
  LIST = "LIST",
  TEMPLATE = "TEMPLATE",
  CALLBACK = "CALLBACK",
  CTA_URL = "CTA_URL",
  LOCATION_REQUEST = "LOCATION_REQUEST",
  // Commerce
  ORDER = "ORDER",
  PRODUCT = "PRODUCT",
  // System messages (flow started/completed, assignment changed, etc.)
  SYSTEM = "SYSTEM",
  // Internal to the inbox
  INTERNAL_NOTE = "INTERNAL_NOTE",
  INTERNAL_ACTIONS = "INTERNAL_ACTIONS",
}

type MessageDeliveryStatus = "pending" | "sent" | "delivered" | "read" | "failed";

enum ScanStatus {
  PENDING = "PENDING",            // Not scanned yet
  CLEAN = "CLEAN",
  INFECTED = "INFECTED",          // No url is sent
  SKIPPED = "SKIPPED",
  ERROR = "ERROR",                // The scan failed
}

enum MediaType {
  PHOTO = "photo",
  VIDEO = "video",
  VIDEO_NOTE = "video_note",
  DOCUMENT = "document",
  AUDIO = "audio",
  VOICE = "voice",
  ANIMATION = "animation",
  STICKER = "sticker",
}

Was this page helpful?

On this page