Messages
Sending and receiving WhatsApp messages is what GoSendAPI is for. Everything goes through one endpoint:
POST /v1/messagesThe type field determines the shape of the rest of the payload.
Supported types
| Type | Use case | Requires template? |
|---|---|---|
text | Plain text message | Only outside 24h window |
template | Pre-approved template | Always (for outbound) |
image | JPEG/PNG image | Same as text |
video | MP4 video | Same as text |
audio | MP3/OGG audio | Same as text |
document | PDF/DOCX/XLSX | Same as text |
sticker | WebP sticker | Same as text |
location | Lat/lon + label | Same as text |
interactive | Buttons / lists / location request | Same as text |
contacts | vCard-style contact share | Same as text |
reaction | Emoji reaction to a prior message | No (always allowed) |
Plus the side-channel operation Mark as read for inbound messages.
Identifying the recipient: phone vs BSUID
Every outbound send needs one of these two fields (or both):
to(string): E164 phone number without+, 8-15 digits. The classic identifier.recipient(string): Meta Business-Scoped User ID —US.xxxor enterpriseUS.ENT.xxx. Available since April 2026 as part of the BSUID rollout. Use it for users identified only by username who kept their phone private.
If both are passed, to (phone) takes precedence per Meta docs. Otherwise, at least one is required.
BSUID identifiers on inbound / status webhooks
Every message-object in the outgoing webhook carries the sender / recipient BSUID when Meta provides it:
- Inbound (
whatsapp.message.received):from_user_id,from_parent_user_id. - Status (
whatsapp.message.sent/delivered/read/failed):recipient_user_id,recipient_parent_user_id.
All BSUID fields are optional — omitted when Meta doesn’t send them. See Webhooks — BSUID identity in payloads for the full shape and fallback rules.
Examples by type
text
curl https://cloud.gosendapi.com/v1/messages \
-H "X-API-Key: gsk_live_..." \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "555123456789",
"to": "5491140123456",
"type": "text",
"text": {
"body": "Hola Juan, llegamos al lugar 👋"
}
}'template
{
"phone_number_id": "555123456789",
"to": "5491140123456",
"type": "template",
"template": {
"name": "appointment_reminder",
"language": { "code": "es_AR" },
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Juan" },
{ "type": "text", "text": "20/05" }
]
}
]
}
}image / video / audio / document
Two ways to attach media:
Option A — hosted URL (we download and upload to Meta):
{
"phone_number_id": "555123456789",
"to": "5491140123456",
"type": "image",
"image": {
"link": "https://your-cdn.com/path/to/image.jpg",
"caption": "Order #1234 receipt"
}
}Option B — Meta media ID (if you uploaded to Meta separately, e.g. for reuse):
{
"image": {
"id": "934567890123456",
"caption": "Same image, reused 100 times"
}
}For images sent once, use link. For images sent to many recipients (e.g. marketing campaigns), upload once via POST /v1/media, get an id, and reuse — saves bandwidth and is faster.
interactive — buttons
Up to 3 quick-reply buttons.
{
"phone_number_id": "555123456789",
"to": "5491140123456",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "¿Confirmás tu turno del 20/05?" },
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "confirm_yes", "title": "Sí, confirmo" } },
{ "type": "reply", "reply": { "id": "reschedule", "title": "Reprogramar" } }
]
}
}
}When the user taps a button, you receive an inbound webhook with the id field — match it to your own action.
interactive — list
Up to 10 rows grouped in sections. Better than buttons when you have 4+ options.
{
"phone_number_id": "555123456789",
"to": "5491140123456",
"type": "interactive",
"interactive": {
"type": "list",
"header": { "type": "text", "text": "Especialidades disponibles" },
"body": { "text": "Seleccioná una para ver horarios" },
"footer": { "text": "Centro Médico Pico" },
"action": {
"button": "Ver especialidades",
"sections": [
{
"title": "Clínica general",
"rows": [
{ "id": "spec_clinica", "title": "Clínica médica", "description": "Lun-Vie 8-18hs" },
{ "id": "spec_pediatria", "title": "Pediatría", "description": "Lun-Sab 9-13hs" }
]
},
{
"title": "Diagnóstico",
"rows": [
{ "id": "spec_eco", "title": "Ecografía", "description": "Con turno previo" }
]
}
]
}
}
}sticker
Static WebP. 512×512 px recommended.
{
"phone_number_id": "555123456789",
"to": "5491140123456",
"type": "sticker",
"sticker": { "link": "https://your-cdn.com/sticker.webp" }
}location
Static map pin. Useful for “where to find us” / drop-off coordinates.
{
"phone_number_id": "555123456789",
"to": "5491140123456",
"type": "location",
"location": {
"latitude": -36.6403,
"longitude": -64.2867,
"name": "Centro Médico Pico",
"address": "Av. Belgrano 123, General Pico, La Pampa"
}
}contacts
vCard-style contact share. Useful for “save our number” prompts.
{
"phone_number_id": "555123456789",
"to": "5491140123456",
"type": "contacts",
"contacts": [
{
"name": { "formatted_name": "Centro Médico Pico", "first_name": "Centro Médico Pico" },
"phones": [
{ "phone": "+5491140123456", "type": "WORK", "wa_id": "5491140123456" }
],
"emails": [{ "email": "turnos@centropico.com.ar", "type": "WORK" }]
}
]
}interactive — location request
Ask the user to share their current location. Renders a “Send location” button in the chat.
{
"phone_number_id": "555123456789",
"to": "5491140123456",
"type": "interactive",
"interactive": {
"type": "location_request_message",
"body": { "text": "Compartí tu ubicación para enviarte el técnico más cercano." },
"action": { "name": "send_location" }
}
}When the user taps and shares, you receive an inbound webhook with type: "location" containing latitude and longitude.
reaction
Send an emoji reaction to a previous message. Useful for acknowledging without sending text. Pass an empty emoji: "" to remove an existing reaction.
{
"phone_number_id": "555123456789",
"to": "5491140123456",
"type": "reaction",
"reaction": {
"message_id": "wamid.HBgLNTQ5MTQwMTIzNDU2...",
"emoji": "👍"
}
}message_id is the wa_message_id of the message you want to react to (typically from a prior inbound webhook).
Mark as read
Distinct from sending — this is a side-channel operation that puts the double-blue-check on an inbound message in the customer’s WhatsApp.
POST /v1/messages/mark-read{
"phone_number_id": "555123456789",
"wa_message_id": "wamid.HBgLNTQ5MTQwMTIzNDU2..."
}Response:
{ "success": true }Idempotent — Meta accepts repeat calls. In sandbox (gsk_test_*), no real call is made and { "success": true, "sandbox": true } is returned.
Response
Live (gsk_live_* key)
A successful send returns:
{
"id": "1234567890",
"phone_number_id": "555123456789",
"direction": "outbound",
"message_type": "text",
"wa_message_id": "wamid.HBgLNTQ5...",
"status": "sent",
"to_phone": "5491140123456",
"created_at": "2026-05-18T12:34:56.789Z"
}id: GoSendAPI’s internal ID (use for follow-up queries)wa_message_id: Meta’s tracking ID (you’ll see it again in status webhook events)status: starts assent; updates todelivered,read, orfailedasync
Sandbox (gsk_test_* key)
When the API key is sandbox, the response is synthetic — no Meta call, no persistence, no webhooks:
{
"id": "sandbox-a1b2c3d4e5f6",
"phone_number_id": "555123456789",
"direction": "outbound",
"message_type": "text",
"wa_message_id": "wamid.SANDBOX.a1b2c3d4e5f6g7h8i9j0k1l2",
"status": "sent_sandbox",
"to_phone": "5491140123456",
"created_at": "2026-05-18T12:34:56.789Z",
"_sandbox": true,
"_note": "This response is synthetic. The message was NOT sent to a real WhatsApp recipient. Use a gsk_live_* key to send for real."
}The _sandbox: true flag lets your code branch (e.g. skip downstream side effects in sandbox mode).
Status updates
(Live messages only — sandbox messages don’t trigger status updates.)
WhatsApp delivers async status updates. Either poll:
curl https://cloud.gosendapi.com/v1/messages/1234567890 \
-H "X-API-Key: gsk_live_..."Or subscribe to the whatsapp.message.delivered / whatsapp.message.read / whatsapp.message.failed webhook events (recommended).
Status lifecycle:
sent ──> delivered ──> read
│
└──> failed (with error code)Listing messages
GET /v1/messages?phone_number_id=555123456789&direction=outbound&since=2026-05-01&page=1&per_page=50Filters:
| Param | Type | Description |
|---|---|---|
phone_number_id | string | Only messages on this number |
direction | inbound | outbound | Filter by direction |
status | string | sent/delivered/read/failed |
since | ISO date | From this date onwards |
until | ISO date | Up to this date |
page | int | Default 1 |
per_page | int | Default 20, max 100 |
Receiving messages (inbound)
Inbound messages arrive via webhook to your configured endpoint. Event: whatsapp.message.received.
Sample payload:
{
"event": "whatsapp.message.received",
"delivery_id": "8b3f...",
"occurred_at": "2026-05-18T12:35:00Z",
"tenant_id": "42",
"phone_number_id": "555123456789",
"message": {
"id": "wamid.HBgLNTQ5...",
"timestamp": "1747570500",
"type": "text",
"from": "5491140123456",
"text": { "body": "Confirmo el turno" },
"gosendapi": {
"direction": "inbound",
"status": "received",
"origin": "cloud_api",
"has_media": false,
"content": "Confirmo el turno"
}
},
"conversation": { "id": "...", "status": "active" },
"is_new_conversation": false
}See Webhooks guide for verification and retry policy.
Best practices
- Always send
idempotency_keyin headers for retries. Same key = same message, won’t duplicate. - Reuse media via
idfor marketing sends — way faster than re-uploading - Pre-validate phone numbers (E164 format, no spaces, no
+) before calling the API to reduce 400s - Cache
phone_number_id→customer_idin your DB to avoid joining on every event handler - Don’t poll — use webhooks for status updates. Polling 1000s of messages is wasteful and gets rate-limited