✅ Meta App Review approved — GoSendAPI Cloud is live. Sign up free →
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.

Deep dive: what’s cached and what’s not

  • 2xx responses are cached for 24h. Retries with the same key return the exact same body + status + a header X-Idempotent-Replay: true so you can distinguish original vs replay.
  • 4xx and 5xx responses are NOT cached. The idempotency record is deleted on failure, so a subsequent retry re-executes cleanly. This lets you fix the payload (e.g. correct a bad phone) and retry with the same key.
  • ⚠️ In-flight collision returns 409 request_in_progress. If two requests with the same key arrive within milliseconds, the second gets 409 immediately (without processing). Retry with a small backoff (500ms-1s). The first request continues normally.
# First request (still processing)
POST /v1/messages   Idempotency-Key: abc 202 Accepted (or takes a few seconds)
 
# Second request arrives while first is still running
POST /v1/messages   Idempotency-Key: abc 409 request_in_progress
 
# Wait 1s, retry:
POST /v1/messages   Idempotency-Key: abc 200 OK + X-Idempotent-Replay: true

Debugging cached responses

If you suspect an idempotent retry is returning a stale cached response you don’t want:

  1. Check the X-Idempotent-Replay header — if true, this is a cache hit.
  2. Wait 24h for the key to expire, or use a new key derived from your business-level ID (order-1234, appointment-9847, etc.) so replays scope to real business events.
  3. If you need to force a fresh send NOW: change the idempotency key. Don’t reuse a key for a semantically different message.

Best practice for keys: use a stable ID from your side (order, appointment, campaign+contact). Keys are compared as-is, so pick a scheme where the same key = the same intended message.

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.

Country-specific quirks (auto-handled)

Meta WhatsApp uses inconsistent phone formats by country: outbound expects one form, inbound delivers another. Our backend normalizes automatically so you don’t have to think about it — but it’s worth knowing what’s happening under the hood.

CountryQuirkWhat Meta expects on sendWhat Meta delivers on inbound
🇦🇷 Argentina (54)“9” mobile flag54XXXXXXXXXX (without 9)Mixed — Web sends with “9”, Mobile without
🇲🇽 Mexico (52)“1” mobile flag52XXXXXXXXXX (without 1)Without “1” (Meta unified in 2021)
🇧🇷 Brazil (55)“9” after DDD55XX9XXXXXXXX (with 9, post-2012)With “9” (mandatory since 2012)

What we do automatically:

  1. On send: if Meta returns 131030 Recipient phone number not in allowed list because of format, we retry with the alternate form before giving up.
  2. On storage: conversation.contact_phone and message.from_phone / to_phone are normalized to the canonical form (sin “9” for AR, etc.) so outbound + inbound of the same contact group into the same conversation.
  3. On query: when looking up conversations by contact_phone, we match all variants — legacy data saved in either form still finds its row.

Practical implications:

  • You can pass 5491140123456 (with 9) OR 541140123456 (without 9) to POST /v1/messages. Both work.
  • Inbound from a contact who texts you with WhatsApp Web (sends with “9”) and another who uses Mobile (sends without “9”) will land in the same conversation if it’s the same logical number.
  • Listing conversations by contact_phone=549... or contact_phone=54... returns the same row.

If you’re integrating from scratch, prefer the canonical form (without “9” for AR, without “1” for MX, with “9” for BR) when persisting numbers in your own DB. Match what we normalize to.

Built another integration on top of Meta Cloud API directly? You probably had to write this normalization yourself. With our API it’s handled.

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