🚀 GoSendAPI is in beta — Meta App Review in progress. Get on the waitlist →
GuidesSending messages

Sending messages

The Messages concept covered the 10 message types. This guide goes deeper into practical patterns you’ll need in production.

Idempotency

Add the Idempotency-Key header to dedupe retries on your side. Same key = same message, won’t duplicate.

curl https://cloud.gosendapi.com/v1/messages \
  -H "X-API-Key: gsk_live_..." \
  -H "Idempotency-Key: order-1234-confirmation" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

If you retry the same request (network glitch, deploy crash, etc.), we return the same response as the first call — no duplicate WhatsApp message sent. Keys expire after 24h.

Idempotency is on the API call, not on Meta. If your idempotent retry succeeded the first time, you don’t send a 2nd message to Meta. Saves money and prevents spam.

For images, videos, documents, audio:

Option A — pass a hosted URL (one-shot send)

{
  "type": "image",
  "image": {
    "link": "https://your-cdn.com/path/to/image.jpg",
    "caption": "Order receipt"
  }
}

We download from your URL once and upload to Meta. Good for: transactional one-off sends.

Option B — upload once, reuse N times

# Step 1: upload once
curl -X POST https://cloud.gosendapi.com/v1/media \
  -H "X-API-Key: gsk_live_..." \
  -F "phone_number_id=555123456789" \
  -F "file=@./promo-banner.jpg" \
  -F "type=image/jpeg"
 
# Response:
# { "id": "934567890123456" }
 
# Step 2: send to many recipients
for phone in $RECIPIENTS; do
  curl https://cloud.gosendapi.com/v1/messages \
    -H "X-API-Key: gsk_live_..." \
    -d "{ \"phone_number_id\": \"555123456789\", \"to\": \"$phone\",
         \"type\": \"image\", \"image\": { \"id\": \"934567890123456\" } }"
done

Use Option B when:

  • Marketing campaign (same image to 1000s)
  • Reusable assets (your logo header, signature)
  • Bandwidth on your side is constrained

Template message anatomy

Templates have variable substitution. Match parameters in order to the placeholders in the template body.

{
  "phone_number_id": "555123456789",
  "to": "5491140123456",
  "type": "template",
  "template": {
    "name": "appointment_reminder",
    "language": { "code": "es_AR" },
    "components": [
      {
        "type": "header",
        "parameters": [
          { "type": "image", "image": { "link": "https://your-cdn.com/clinic-logo.jpg" } }
        ]
      },
      {
        "type": "body",
        "parameters": [
          { "type": "text", "text": "Juan" },
          { "type": "text", "text": "20/05" },
          { "type": "text", "text": "14:00" }
        ]
      },
      {
        "type": "button",
        "sub_type": "quick_reply",
        "index": "0",
        "parameters": [
          { "type": "payload", "payload": "confirm_yes_apt_123" }
        ]
      }
    ]
  }
}

Component types: header, body, button. Parameter types: text, currency, date_time, image, video, document, payload.

⚠️

Order matters. {{1}} in the template maps to the first parameter in your array. Mixing them up sends “Hola 20/05, te recordamos tu turno el Juan a las 14:00”.

Interactive messages (buttons / lists)

Buttons:

{
  "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" } }
      ]
    }
  }
}

Lists (up to 10 items, sectioned):

{
  "type": "interactive",
  "interactive": {
    "type": "list",
    "body": { "text": "ElegĂ­ un servicio:" },
    "action": {
      "button": "Ver servicios",
      "sections": [
        {
          "title": "Consultas",
          "rows": [
            { "id": "general", "title": "Consulta general", "description": "30 min · $5000" },
            { "id": "specialist", "title": "Especialista", "description": "45 min · $8000" }
          ]
        },
        {
          "title": "Estudios",
          "rows": [
            { "id": "lab", "title": "Laboratorio", "description": "Análisis de sangre" }
          ]
        }
      ]
    }
  }
}

When the user taps a button or list item, you receive whatsapp.message.received with the selected id — match it to your own action handler.

Sending media + caption + buttons together

This needs a template (not free-form). Approved templates can combine: header image + body text + buttons.

Phone number format

E164, no +, no spaces, no parentheses:

âś“ "5491140123456"
âś— "+54 9 11 4012-3456"
âś— "+5491140123456"
âś— "549-11-4012-3456"

Pre-normalize on your side. A library like libphonenumber-js is your friend.

Error handling pattern

async function sendMessage(payload) {
  const res = await fetch('https://cloud.gosendapi.com/v1/messages', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.GOSENDAPI_KEY,
      'Content-Type': 'application/json',
      'Idempotency-Key': payload.idempotencyKey,
    },
    body: JSON.stringify(payload),
  });
 
  if (!res.ok) {
    const error = await res.json();
 
    // 4xx → permanent failure, don't retry
    if (res.status >= 400 && res.status < 500) {
      throw new PermanentError(error.message);
    }
 
    // 5xx → temporary, retry with backoff
    if (res.status >= 500) {
      throw new RetryableError(error.message);
    }
  }
 
  return res.json();
}

See Error handling for the full catalog of error codes.

Throttling

Each phone has a throughput tier (see Phone numbers). If you exceed it, we return 429 Rate Limited:

{
  "statusCode": 429,
  "message": "Rate limit exceeded for phone_number_id=555123456789. Tier: standard (80 msg/s).",
  "error": "Too Many Requests",
  "retry_after_seconds": 1
}

Honor retry_after_seconds. See Rate limits.

Common pitfalls

MistakeEffectFix
Using +54... in to400 Bad RequestStrip the +
Sending free text outside 24h window403 ForbiddenUse a template
Forgetting Idempotency-Key on retriesDuplicate messages sent to userAlways set it
Using the human-readable phone in phone_number_id404 Not FoundUse the Meta numeric ID
Polling GET /v1/messages/{id} every 5s for statusRate limit + costSubscribe to webhooks instead
Sending JSON with wrong Content-Type415 Unsupported Media TypeAlways Content-Type: application/json

What’s next