Errors & rate limits

Zend uses standard HTTP status codes and returns a consistent JSON error shape. This page covers the error response format, the most common errors, rate limits per endpoint, and best practices for handling both.

Error response shape

Every error response is JSON with three fields: an HTTP statusCode, a human-readable message, and an error label.

{
  "statusCode": 400,
  "message": "Insufficient credits. Required: 0.020, Available: 0.015. Please purchase more credits.",
  "error": "Bad Request"
}

Response fields

statusCodenumber
HTTP status code, e.g. 400, 429, 500.
messagestring
Human-readable description of what went wrong.
errorstring
Short error label, e.g. Bad Request, Too Many Requests.

Common errors

Insufficient credits400

{
  "statusCode": 400,
  "message": "Insufficient credits. Required: 0.020, Available: 0.015. Please purchase more credits.",
  "error": "Bad Request"
}

Invalid template400

{
  "statusCode": 400,
  "message": "Template rendering failed: Required variable 'customer_name' is missing",
  "error": "Bad Request"
}

Rate limit exceeded429

{
  "statusCode": 429,
  "message": "Rate limit exceeded. Please wait 60 seconds before sending more messages.",
  "error": "Too Many Requests"
}

Invalid phone number400

{
  "statusCode": 400,
  "message": "Invalid phone number format. Please use international format (e.g., +233593152134)",
  "error": "Bad Request"
}

Handling errors

Inspect the HTTP status and message to branch on the failure type. The example below throws targeted errors and retries transient failures with exponential backoff.

class MessageAPI {
  constructor(apiKey) {
    this.apiKey = apiKey;
    this.baseUrl = 'https://api.tryzend.com';
  }

  async sendMessage(messageData) {
    try {
      const response = await fetch(`${this.baseUrl}/messages`, {
        method: 'POST',
        headers: {
          'x-api-key': this.apiKey,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(messageData)
      });

      if (!response.ok) {
        const error = await response.json();

        // Handle specific error types
        switch (response.status) {
          case 400:
            if (error.message.includes('credits')) {
              throw new Error('Insufficient credits - please top up your account');
            } else if (error.message.includes('template')) {
              throw new Error('Template error - check your template parameters');
            } else {
              throw new Error(`Bad request: ${error.message}`);
            }

          case 429:
            throw new Error('Rate limit exceeded - please wait before sending more messages');

          case 500:
            throw new Error('Server error - please try again later');

          default:
            throw new Error(`API error: ${error.message}`);
        }
      }

      return await response.json();
    } catch (error) {
      console.error('Message send failed:', error.message);
      throw error;
    }
  }

  async retryWithBackoff(messageId, maxRetries = 3) {
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        const response = await fetch(`${this.baseUrl}/messages/${messageId}/retry`, {
          method: 'PUT',
          headers: {
            'x-api-key': this.apiKey
          }
        });

        if (response.ok) {
          return await response.json();
        }
      } catch (error) {
        console.error(`Retry attempt ${attempt} failed:`, error.message);

        if (attempt === maxRetries) {
          throw new Error('Max retry attempts reached');
        }

        // Exponential backoff
        await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
      }
    }
  }
}

Rate limits

Rate limits are enforced per endpoint. Exceeding a limit returns a 429 response.

EndpointLimitWindow
SMS Messages100/minutePer minute
WhatsApp Messages50/minutePer minute
Bulk Messages1000/hourPer hour
Template Creation10/minutePer minute

Best practices

Handle errors explicitly

Branch on the error message so each failure type has a clear recovery path.

try {
  const result = await messageAPI.sendMessage(messageData);
  console.log('Message sent:', result.id);
} catch (error) {
  if (error.message.includes('credits')) {
    // Handle insufficient credits
    await topUpCredits();
  } else if (error.message.includes('rate limit')) {
    // Implement retry with backoff
    await retryWithBackoff(messageData);
  } else {
    // Log and handle other errors
    console.error('Unexpected error:', error);
  }
}

Throttle to stay under limits

Track requests within each window and pause before you hit a limit rather than reacting to 429s.

class RateLimiter {
  constructor(limit, window) {
    this.limit = limit;
    this.window = window;
    this.requests = [];
  }

  async checkLimit() {
    const now = Date.now();
    this.requests = this.requests.filter(time => now - time < this.window);

    if (this.requests.length >= this.limit) {
      const oldestRequest = this.requests[0];
      const waitTime = this.window - (now - oldestRequest);
      await new Promise(resolve => setTimeout(resolve, waitTime));
    }

    this.requests.push(now);
  }
}

const smsLimiter = new RateLimiter(100, 60000); // 100 per minute
const whatsappLimiter = new RateLimiter(50, 60000); // 50 per minute

Note

Choose the most cost-effective channel per message: WhatsApp is cheaper for template messages, while SMS is cheaper for simple text. See Status & priority to optimize delivery for cost.