Error handling
All errors follow the same JSON shape:
{
"statusCode": 400,
"message": "Validation failed: 'to' must be E164 format",
"error": "Bad Request",
"details": {
"field": "to",
"value": "+5491140123456"
}
}The details object is optional and varies by error type.
HTTP status codes
| Code | Category | Retry? |
|---|---|---|
400 | Bad Request — validation failed | ✗ Fix the request shape |
401 | Unauthorized — invalid/missing API key | ✗ Check your key |
403 | Forbidden — you can’t access this resource | ✗ Check tenant/role |
404 | Not Found | ✗ Resource doesn’t exist |
409 | Conflict — e.g., duplicate idempotency key with different body | ✗ Investigate |
415 | Unsupported Media Type | âś— Set Content-Type: application/json |
422 | Unprocessable Entity — semantic validation | ✗ Fix the data |
429 | Rate Limited | âś“ Wait retry_after_seconds |
500 | Internal Server Error (ours) | âś“ Retry with backoff |
502 / 503 | Upstream unavailable (Meta or DB) | âś“ Retry with backoff |
504 | Gateway Timeout | âś“ Retry, idempotently |
Error codes (specific)
Authentication
| Code | When | Fix |
|---|---|---|
INVALID_API_KEY | Key not found in DB | Check spelling, regenerate if lost |
EXPIRED_API_KEY | Key was revoked | Generate a new one |
MALFORMED_API_KEY | Wrong format (must start with gsk_test_ or gsk_live_) | Check the prefix |
Validation
| Code | When | Fix |
|---|---|---|
INVALID_PHONE_NUMBER | to is not E164 | Strip +, spaces, dashes |
INVALID_PHONE_NUMBER_ID | phone_number_id not in your project | Use the Meta ID, not the display phone |
INVALID_MESSAGE_TYPE | type is not in the allowed list | Check spelling against the 10 supported types |
MISSING_REQUIRED_FIELD | Body is missing a required field | Read the details.field |
INVALID_E164 | Phone is wrong format | E164: country code + number, no separators |
Authorization
| Code | When | Fix |
|---|---|---|
PHONE_NUMBER_NOT_IN_TENANT | Phone exists but belongs to another project | Check you’re using the right API key |
WABA_NOT_IN_TENANT | Same as above for WABAs | — |
TEMPLATE_NOT_IN_WABA | Template exists but in a different WABA | Sync templates, or check waba_id |
ROLE_FORBIDDEN | Your role can’t do this action | Owner/admin for some operations |
Meta-specific (Cloud API errors passed through)
| Code | Meaning | Fix |
|---|---|---|
OUTSIDE_CONVERSATION_WINDOW | Tried to send free-text >24h after last user message | Use a template |
TEMPLATE_NOT_APPROVED | Template is in PENDING/REJECTED/PAUSED state | Wait for approval, or use another template |
TEMPLATE_VARIABLE_COUNT_MISMATCH | Number of parameters doesn’t match {{N}} placeholders | Match exactly |
MEDIA_NOT_FOUND | Media ID expired (Meta keeps media for 30 days) | Re-upload |
RECIPIENT_NOT_ON_WHATSAPP | The contact phone isn’t on WhatsApp | Skip this contact |
RECIPIENT_OPTED_OUT | User explicitly blocked your business | Don’t retry, remove from list |
WABA_SUSPENDED | Meta suspended the WABA | Customer must appeal with Meta |
RATE_LIMIT_EXCEEDED | Too many messages/sec for this phone | Slow down, request throughput upgrade |
QUALITY_FLAG_RED | Your number is at red quality, send rate restricted | Improve content, reduce blocks |
Retry strategy
For retryable errors (5xx, 429, network), use exponential backoff:
async function sendWithRetry(payload, maxAttempts = 5) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
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, // critical for retries
},
body: JSON.stringify(payload),
});
// Success
if (res.ok) return res.json();
const error = await res.json();
// 4xx → permanent failure, abort
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
throw new PermanentError(error.message);
}
// 429 → respect retry_after_seconds
if (res.status === 429) {
const wait = (error.retry_after_seconds ?? 1) * 1000;
await sleep(wait);
continue;
}
// 5xx → exponential backoff
const wait = Math.min(60_000, 1000 * Math.pow(2, attempt)); // 1s, 2s, 4s, 8s, 16s, 32s, capped 60s
await sleep(wait + Math.random() * 500); // jitter
} catch (err) {
if (err instanceof PermanentError) throw err;
if (attempt === maxAttempts) throw err;
}
}
throw new Error('Max retries exceeded');
}
const sleep = ms => new Promise(r => setTimeout(r, ms));Idempotency rescues retries
Always send Idempotency-Key with each request. If you retry after a timeout but the original succeeded, the duplicate retry returns the same response without sending a second message. See Sending messages → Idempotency.
Common gotchas
”I get 401 but my key works in cURL”
Your HTTP client is mangling the header. Check:
- Header name:
X-API-Key(case may matter in some clients) - No trailing newlines/spaces in the key value
- Not using basic auth by accident
”I get 400 for a valid-looking phone”
E164 means no +, no spaces, no dashes:
âś“ "5491140123456"
âś— "+5491140123456"
✗ "+54 9 11 4012 3456"“I get OUTSIDE_CONVERSATION_WINDOW for a brand new contact”
The 24h window only opens when the contact sends to you first. Before that, you can only initiate via templates.
”Template message goes through but never delivers”
Check the conversations status. If the WABA is suspended or quality is red, deliveries fail silently. The dashboard shows the current quality rating.
Reporting bugs
If you hit an error code not listed here, or an error that looks wrong:
- Capture the full response body (we include
detailsfor debugging) - Note the timestamp (we can grep logs)
- Email hello@gosendapi.com with both
We reply within 1 business day.
What’s next
- Rate limits — quotas, headers, backoff specifics
- Sending messages — retry patterns in context