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

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

CodeCategoryRetry?
400Bad Request — validation failed✗ Fix the request shape
401Unauthorized — invalid/missing API key✗ Check your key
403Forbidden — you can’t access this resource✗ Check tenant/role
404Not Found✗ Resource doesn’t exist
409Conflict — e.g., duplicate idempotency key with different body✗ Investigate
415Unsupported Media Typeâś— Set Content-Type: application/json
422Unprocessable Entity — semantic validation✗ Fix the data
429Rate Limitedâś“ Wait retry_after_seconds
500Internal Server Error (ours)âś“ Retry with backoff
502 / 503Upstream unavailable (Meta or DB)âś“ Retry with backoff
504Gateway Timeoutâś“ Retry, idempotently

Error codes (specific)

Authentication

CodeWhenFix
INVALID_API_KEYKey not found in DBCheck spelling, regenerate if lost
EXPIRED_API_KEYKey was revokedGenerate a new one
MALFORMED_API_KEYWrong format (must start with gsk_test_ or gsk_live_)Check the prefix

Validation

CodeWhenFix
INVALID_PHONE_NUMBERto is not E164Strip +, spaces, dashes
INVALID_PHONE_NUMBER_IDphone_number_id not in your projectUse the Meta ID, not the display phone
INVALID_MESSAGE_TYPEtype is not in the allowed listCheck spelling against the 10 supported types
MISSING_REQUIRED_FIELDBody is missing a required fieldRead the details.field
INVALID_E164Phone is wrong formatE164: country code + number, no separators

Authorization

CodeWhenFix
PHONE_NUMBER_NOT_IN_TENANTPhone exists but belongs to another projectCheck you’re using the right API key
WABA_NOT_IN_TENANTSame as above for WABAs—
TEMPLATE_NOT_IN_WABATemplate exists but in a different WABASync templates, or check waba_id
ROLE_FORBIDDENYour role can’t do this actionOwner/admin for some operations

Meta-specific (Cloud API errors passed through)

CodeMeaningFix
OUTSIDE_CONVERSATION_WINDOWTried to send free-text >24h after last user messageUse a template
TEMPLATE_NOT_APPROVEDTemplate is in PENDING/REJECTED/PAUSED stateWait for approval, or use another template
TEMPLATE_VARIABLE_COUNT_MISMATCHNumber of parameters doesn’t match {{N}} placeholdersMatch exactly
MEDIA_NOT_FOUNDMedia ID expired (Meta keeps media for 30 days)Re-upload
RECIPIENT_NOT_ON_WHATSAPPThe contact phone isn’t on WhatsAppSkip this contact
RECIPIENT_OPTED_OUTUser explicitly blocked your businessDon’t retry, remove from list
WABA_SUSPENDEDMeta suspended the WABACustomer must appeal with Meta
RATE_LIMIT_EXCEEDEDToo many messages/sec for this phoneSlow down, request throughput upgrade
QUALITY_FLAG_REDYour number is at red quality, send rate restrictedImprove 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 details for debugging)
  • Note the timestamp (we can grep logs)
  • Email hello@gosendapi.com with both

We reply within 1 business day.

What’s next