Skip to main content

Message types — sending and receiving

This reference details what you send (the request) and what you receive (the payload of the message.received webhook and of the history at GET /conversations/{jid}/messages) for each type. Use it to consume the information however you need.

Common fields on every received message

Every message.received carries in the envelope: type, instance_id (the number that received it), sender ({ jid, phone, lid, name }), and in the payload: message_id, wa_message_id, from, body, and quoted_id + quoted_participant (the quoted message's author) when it's a reply. Group messages: see Groups. Phone numbers are stored as the customer's phone JID (a unified conversation across all your numbers).

The to field

Accepts a phone number in E.164 (+5511999999999), a contact JID ([email protected]), a group JID ([email protected]) and the contact's @lid — the privacy identifier this API returns in sender.lid and in group participants. A @lid is translated to the phone number when the connected number already knows that LID; when it doesn't, the message goes out to the @lid itself. See Contacts with @lid. On the official rail the destination is always the phone number.


Text​

Sending — POST /messages/text

{ "to": "+5511999999999", "body": "Hi 👋" }

Received (type: "text")

{ "type": "text", "body": "Hi 👋", "from": "[email protected]" }

Image · Video · Document · Audio · Sticker​

Sending — POST /messages/{image|video|document|audio|sticker}

{ "to": "+5511999999999",
"media": { "url": "https://.../photo.jpg", "caption": "Check this out", "filename": "photo.jpg", "mimetype": "image/jpeg", "ptt": false } }
  • url or base64. ptt: true (audio) = voice note. caption applies to image/video/document.

Received (type: "image" | "video" | "audio" | "document" | "sticker")

{ "type": "image",
"body": "Check this out", // = caption
"media": {
"id": "uuid",
"url": "https://api.bzapper.com.br/media/uuid?exp=...&sig=...",
"mime_type": "image/jpeg", "filename": "photo.jpg", "size": 84211
} }
Conversation media is PRIVATE

Received media is not public: it goes to a private bucket (files.bzapper.com.br, separate from the public assets at assets.bzapper.com.br). The media.url is a SigV4 pre-signed URL with a TTL (MEDIA_URL_TTL, ~24h, configurable — see config.go) — it expires, so don't store it.

The stable reference is GET /media/{id} (with exp+sig): the API responds with 302 redirecting to a fresh pre-signed URL, and the client downloads directly from Spaces/CDN. Whenever you need the file, redo that GET (don't keep the expired media.url). audio with ptt is a voice note.

Location​

Sending — POST /messages/location

{ "to": "+5511999999999", "latitude": -23.561, "longitude": -46.656, "name": "Av. Paulista", "address": "São Paulo" }

Received (type: "location")

{ "type": "location", "body": "Av. Paulista", "latitude": -23.561, "longitude": -46.656 }

Contact (vCard)​

Sending — POST /messages/contact

{ "to": "+5511999999999", "contact_name": "Support", "contact_phone": "+5511977776666" }
  • contact_phone is the phone of the contact on the card — the person you are sharing, never the recipient. We build the vCard from it, including the waid that makes WhatsApp recognise the number as a contact and offer to message them.
  • Already have the card? Send contact_vcard instead — then contact_name/contact_phone are not used to build anything.
  • contact_name without contact_phone returns 400 contact_phone_required.

Received (type: "contact") — body = the displayed name.

Reaction (emoji)​

Sending — POST /messages/reaction

{ "to": "+5511999999999", "quoted_message_id": "<wa_message_id>", "emoji": "❤️" }

Received (type: "reaction") — body = the emoji; quoted_id = the reacted message.

Poll​

Sending — POST /messages/poll

{ "to": "+5511999999999", "name": "Which time slot?", "options": ["Morning", "Afternoon", "Evening"], "selectable_count": 1 }

Received — the poll (type: "poll")

{ "type": "poll", "body": "Which time slot?",
"poll": { "name": "Which time slot?", "options": ["Morning", "Afternoon", "Evening"] } }

Received — the VOTE (type: "poll_vote")

{ "type": "poll_vote",
"body": "Afternoon",
"poll_vote": {
"poll_message_id": "<wa_message_id of the poll>",
"selected": ["Afternoon"] // chosen options, already resolved
} }

The vote arrives encrypted by WhatsApp (hashes only). bZapper decrypts and resolves the hashes against the original poll's options, delivering the names in poll_vote.selected. Correlate it with the poll using the poll_message_id. A multiple vote (selectable_count > 1) brings several items in selected.

Does not exist on the official rail

Meta's Cloud API has no poll — it is the only parity gap between the rails, and it is permanent. On an official project, POST /messages/poll returns 422 official_poll_unsupported and points at the alternative, which is a list of options. See Meta's official API.

Buttons and Lists (menu)​

Sending — POST /messages/{buttons|list}

{ "to": "+5511999999999", "body": "Confirm the order?", "buttons": [{ "id": "yes", "title": "Yes" }, { "id": "no", "title": "No" }] }
Behavior

WhatsApp restricts interactive buttons/lists to official API accounts. Since bZapper sends over the multi-device protocol, what goes out is always an equivalent numbered text menu (a stable fallback that always delivers). The customer's reply arrives as type: "text" with the chosen text/number — treat it as plain text.

On the official rail they are native and they render: bZapper uses the Cloud API interactive whenever the request fits Meta's limits (up to 3 buttons, titles up to 20 characters; up to 10 list rows, titles up to 24). Above that it falls back to the same text menu — the message arrives, it just does not arrive clickable. The reply still comes as type: "text", now with the id of the chosen option in payload.reply_id.

OTP (verification code)​

Sending — POST /messages/otp

{ "to": "+5511999999999", "code": "738291", "expiry_minutes": 5 }

The OTP goes out as two messages on WhatsApp: a context text + a bubble with just the code (easy to copy with a long-press on any device). It's 1 logical OTP and counts as 1 send for billing.

  • If you omit body, the API generates the text in the account's language with random variations (repeating identical text to many recipients makes it easier for WhatsApp to fingerprint). expiry_minutes (optional) is only mentioned in the text.
  • The code is never persisted in cleartext nor shown in the inbox: we keep only a masked version (e.g. ••••91) for audit/UX, and echoguard prevents the code from appearing in the transcript.
OTP security

Treat the code as a secret. bZapper does not return it in any conversation listing or status webhook — only you (who generated it) know it.

Approved template — official rail only​

Send — POST /messages/template

{ "to": "+5511999999999", "template": "pedido_confirmado", "language": "pt_BR",
"variables": ["Ana", "10432"], "header_variables": ["10432"] }

It is the only way to start a conversation outside the Cloud API's 24-hour window. You send the template name; the id Meta gave it never appears.

  • language may be omitted when the name exists in a single language. If it exists in more than one, the answer is 409 template_ambiguous — sending in the wrong language is worse than asking.
  • The number of variables must match the approved template. We check before calling Meta: a mismatch is the number one cause of error 132000.
  • For an AUTHENTICATION template, send code instead of variables.

On an unofficial project this endpoint returns 409 template_official_only: there is no prior approval there, and so nothing to send by name — send the text directly to /messages/text.

Creating, listing and following approval: Meta's official API.


Quick table (received types)​

typeKey fields in the payload
textbody
image/video/audio/document/stickerbody (caption), media.{url,mime_type,filename,size}
locationlatitude, longitude, body (name)
contactbody (name)
reactionbody (emoji), quoted_id
pollpoll.{name,options}
poll_votepoll_vote.{poll_message_id,selected[]}, body

otp and template are send-only. The OTP code never comes back in the inbox, and what arrives from a sent template is the person's reply — plain text, or type: "text" with reply_id when they tap a button.

See also Webhooks (signature/delivery) and Customer support (a per-customer unified conversation).