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: trueso 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 gets409immediately (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: trueDebugging cached responses
If you suspect an idempotent retry is returning a stale cached response you don’t want:
- Check the
X-Idempotent-Replayheader — iftrue, this is a cache hit. - 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.
- 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.
Media: link vs upload-then-reuse
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\" } }"
doneUse 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.
| Country | Quirk | What Meta expects on send | What Meta delivers on inbound |
|---|---|---|---|
| 🇦🇷 Argentina (54) | “9” mobile flag | 54XXXXXXXXXX (without 9) | Mixed — Web sends with “9”, Mobile without |
| 🇲🇽 Mexico (52) | “1” mobile flag | 52XXXXXXXXXX (without 1) | Without “1” (Meta unified in 2021) |
| 🇧🇷 Brazil (55) | “9” after DDD | 55XX9XXXXXXXX (with 9, post-2012) | With “9” (mandatory since 2012) |
What we do automatically:
- On send: if Meta returns
131030 Recipient phone number not in allowed listbecause of format, we retry with the alternate form before giving up. - On storage:
conversation.contact_phoneandmessage.from_phone/to_phoneare normalized to the canonical form (sin “9” for AR, etc.) so outbound + inbound of the same contact group into the same conversation. - 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) OR541140123456(without 9) toPOST /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...orcontact_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
| Mistake | Effect | Fix |
|---|---|---|
Using +54... in to | 400 Bad Request | Strip the + |
| Sending free text outside 24h window | 403 Forbidden | Use a template |
Forgetting Idempotency-Key on retries | Duplicate messages sent to user | Always set it |
Using the human-readable phone in phone_number_id | 404 Not Found | Use the Meta numeric ID |
Polling GET /v1/messages/{id} every 5s for status | Rate limit + cost | Subscribe to webhooks instead |
| Sending JSON with wrong Content-Type | 415 Unsupported Media Type | Always Content-Type: application/json |
What’s next
- Webhooks — receive inbound messages and status updates
- Templates concept — how to get a template approved
- Error handling — every error code with fix