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.
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.
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