✅ Meta App Review approved — GoSendAPI Cloud is live. Sign up free →

Messages

Sending and receiving WhatsApp messages is what GoSendAPI is for. Everything goes through one endpoint:

POST /v1/messages

The type field determines the shape of the rest of the payload.

Supported types

TypeUse caseRequires template?
textPlain text messageOnly outside 24h window
templatePre-approved templateAlways (for outbound)
imageJPEG/PNG imageSame as text
videoMP4 videoSame as text
audioMP3/OGG audioSame as text
documentPDF/DOCX/XLSXSame as text
stickerWebP stickerSame as text
locationLat/lon + labelSame as text
interactiveButtons / lists / location requestSame as text
contactsvCard-style contact shareSame as text
reactionEmoji reaction to a prior messageNo (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.xxx or enterprise US.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 as sent; updates to delivered, read, or failed async

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=50

Filters:

ParamTypeDescription
phone_number_idstringOnly messages on this number
directioninbound | outboundFilter by direction
statusstringsent/delivered/read/failed
sinceISO dateFrom this date onwards
untilISO dateUp to this date
pageintDefault 1
per_pageintDefault 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_key in headers for retries. Same key = same message, won’t duplicate.
  • Reuse media via id for 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_idcustomer_id in 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

What’s next