Error Handling

This guide shows you how to build resilient messaging integrations with error classification, retries, fallbacks, and safe re-sends.

Overview

When sending messages, errors can occur at multiple levels:

  • API errors - Invalid requests, authentication failures
  • Network errors - Connection timeouts, DNS failures
  • Provider errors - Carrier issues, rate limiting
  • Business errors - Insufficient balance, template not approved

This guide covers patterns for handling each type of error.

Error Types

API Errors (4xx)

Client-side errors indicate a problem with the request. Fix the request instead of retrying:

StatusExample CodeMeaningAction
400VALIDATION_001Request validation failedFix the request body; check error.details
401AUTH_002Invalid or missing API keyVerify the API key; don't retry
404RESOURCE_001Resource not foundVerify IDs
409CONFLICT_001Concurrent idempotent request in progressWait for the original request to complete
429BUSINESS_002Rate limit exceededImplement backoff and respect Retry-After

These are the statuses the retry logic in this guide branches on. For every code with causes and remediation steps, see the Error Catalog; for limit values, see Rate Limits.

Server Errors (5xx)

Temporary server-side errors - safe to retry:

StatusMeaningAction
500Internal server errorRetry with backoff
502Bad gatewayRetry with backoff
503Service unavailableRetry with backoff
504Gateway timeoutRetry with backoff

Business Logic Errors

Application-level errors requiring business decisions:

Error CodeMeaningAction
BUSINESS_003Insufficient account balanceAlert billing, queue for later
BUSINESS_005Template not approvedWait or use SMS fallback
BUSINESS_007Channel not available for this contactSwitch channel or use fallback
VALIDATION_002Invalid phone number formatValidate input, notify user
ERR_CONSENT_BLOCKEDRecipient opted out: the message is finalized as FILTERED (asynchronous, surfaces on message.filtered)Suppress the contact and skip further sends until renewed consent

POST /v3/messages accepts sends with 202 and applies business rules asynchronously, so opt-out, balance, and template-approval outcomes arrive as message statuses and webhooks rather than HTTP errors. The Error Catalog lists every business logic code with remediation steps.

Basic Error Handling

Try-Catch Pattern

import SentDm from '@sentdm/sentdm';

const client = new SentDm();

async function sendMessage(phoneNumber: string, templateId: string, channels?: string[]) {
  try {
    const response = await client.messages.send({
      to: [phoneNumber],
      template: { id: templateId },
      // Omit channel for automatic selection; pass e.g. ['sms'] to pin the channel
      ...(channels ? { channel: channels } : {})
    });
    return { success: true, messageId: response.data.recipients[0].message_id };
  } catch (error) {
    if (error instanceof SentDm.APIError) {
      return handleApiError(error);
    }
    // Network or other errors
    return { success: false, error: 'Network error', retryable: true };
  }
}

function handleApiError(error: SentDm.APIError) {
  switch (error.status) {
    case 429:
      return { success: false, error: 'Rate limited', retryable: true, delay: 60000 };
    case 401:
      return { success: false, error: 'Invalid API key', retryable: false };
    case 400:
      return { success: false, error: error.message, retryable: false };
    default:
      return { success: false, error: error.message, retryable: error.status >= 500 };
  }
}
import sent_dm
from sent_dm import SentDm

client = SentDm()

def send_message(phone_number: str, template_id: str):
    try:
        response = client.messages.send(
            to=[phone_number],
            template={"id": template_id}
        )
        return {"success": True, "message_id": response.data.recipients[0].message_id}
    except sent_dm.RateLimitError as e:
        return {"success": False, "error": "Rate limited", "retryable": True, "delay": 60}
    except sent_dm.AuthenticationError as e:
        return {"success": False, "error": "Invalid API key", "retryable": False}
    except sent_dm.BadRequestError as e:
        return {"success": False, "error": str(e), "retryable": False}
    except sent_dm.APIStatusError as e:
        return {"success": False, "error": str(e), "retryable": e.status_code >= 500}
    except sent_dm.APIConnectionError as e:
        return {"success": False, "error": "Network error", "retryable": True}
func sendMessage(phoneNumber, templateId string) (*SendResult, error) {
    client := sentdm.NewClient()

    response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{
        To: []string{phoneNumber},
        Template: sentdm.MessageSendParamsTemplate{
            ID: sentdm.String(templateId),
        },
    })

    if err != nil {
        if apiErr, ok := err.(*sentdm.APIError); ok {
            return nil, handleAPIError(apiErr)
        }
        // Network or other errors
        return &SendResult{Retryable: true, Error: err}, nil
    }

    return &SendResult{Success: true, MessageID: response.Data.Recipients[0].MessageID}, nil
}

func handleAPIError(err *sentdm.APIError) error {
    switch err.StatusCode {
    case 429:
        return fmt.Errorf("rate limited, retry after %d seconds", err.RetryAfter)
    case 401:
        return fmt.Errorf("invalid API key")
    case 400:
        return fmt.Errorf("bad request: %s", err.Message)
    default:
        if err.StatusCode >= 500 {
            return fmt.Errorf("server error: %s (retryable)", err.Message)
        }
        return fmt.Errorf("API error: %s", err.Message)
    }
}
public SendResult sendMessage(String phoneNumber, String templateId) {
    try {
        MessageSendParams params = MessageSendParams.builder()
            .addTo(phoneNumber)
            .template(MessageSendParams.Template.builder()
                .id(templateId)
                .build())
            .build();

        var response = client.messages().send(params);
        return new SendResult(true, response.data().recipients().get(0).messageId(), null);
    } catch (RateLimitException e) {
        return new SendResult(false, null, "Rate limited, retry after " + e.getRetryAfter());
    } catch (AuthenticationException e) {
        return new SendResult(false, null, "Invalid API key");
    } catch (BadRequestException e) {
        return new SendResult(false, null, e.getMessage());
    } catch (APIException e) {
        boolean retryable = e.getStatusCode() >= 500;
        return new SendResult(false, null, e.getMessage() + (retryable ? " (retryable)" : ""));
    }
}
public async Task<SendResult> SendMessageAsync(string phoneNumber, string templateId)
{
    try
    {
        var parameters = new MessageSendParams
        {
            To = new List<string> { phoneNumber },
            Template = new MessageSendParamsTemplate { Id = templateId }
        };

        var response = await client.Messages.Send(parameters);
        return new SendResult(true, response.Data.Recipients[0].MessageId, null);
    }
    catch (RateLimitException ex)
    {
        return new SendResult(false, null, $"Rate limited, retry after {ex.RetryAfter}");
    }
    catch (AuthenticationException ex)
    {
        return new SendResult(false, null, "Invalid API key");
    }
    catch (BadRequestException ex)
    {
        return new SendResult(false, null, ex.Message);
    }
    catch (APIException ex) when (ex.StatusCode >= 500)
    {
        return new SendResult(false, null, $"{ex.Message} (retryable)");
    }
    catch (APIException ex)
    {
        return new SendResult(false, null, ex.Message);
    }
}
function sendMessage($phoneNumber, $templateId) {
    try {
        $result = $this->client->messages->send(
            to: [$phoneNumber],
            template: ['id' => $templateId]
        );
        return ['success' => true, 'message_id' => $result->data->recipients[0]->message_id];
    } catch (RateLimitException $e) {
        return ['success' => false, 'error' => 'Rate limited', 'retryable' => true];
    } catch (AuthenticationException $e) {
        return ['success' => false, 'error' => 'Invalid API key', 'retryable' => false];
    } catch (BadRequestException $e) {
        return ['success' => false, 'error' => $e->getMessage(), 'retryable' => false];
    } catch (APIException $e) {
        $retryable = $e->getCode() >= 500;
        return ['success' => false, 'error' => $e->getMessage(), 'retryable' => $retryable];
    }
}
def send_message(phone_number, template_id)
  result = sent_dm.messages.send_(
    to: [phone_number],
    template: { id: template_id }
  )
  { success: true, message_id: result.data.recipients[0].message_id }
rescue Sentdm::RateLimitError => e
  { success: false, error: 'Rate limited', retryable: true }
rescue Sentdm::AuthenticationError => e
  { success: false, error: 'Invalid API key', retryable: false }
rescue Sentdm::BadRequestError => e
  { success: false, error: e.message, retryable: false }
rescue Sentdm::APIError => e
  retryable = e.code >= 500
  { success: false, error: e.message, retryable: retryable }
end

The remaining examples in this guide build on the TypeScript sendMessage helper from Try-Catch Pattern.

Retry Strategies

Exponential Backoff

Increase wait time between retries to avoid overwhelming the API:

async function sendWithRetry(
  phoneNumber: string,
  templateId: string,
  maxRetries: number = 3
): Promise<SendResult> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const result = await sendMessage(phoneNumber, templateId);

    if (result.success || !result.retryable) {
      return result;
    }

    if (attempt < maxRetries) {
      // Exponential backoff: 1s, 2s, 4s
      const delay = Math.pow(2, attempt) * 1000;
      console.log(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
      await sleep(delay);
    }
  }

  return { success: false, error: 'Max retries exceeded' };
}

Jitter

Add randomness to prevent thundering herd:

function sleepWithJitter(baseDelay: number): Promise<void> {
  const jitter = Math.random() * 1000; // 0-1000ms random
  return sleep(baseDelay + jitter);
}

// Usage
const delay = Math.pow(2, attempt) * 1000;
await sleepWithJitter(delay);

Circuit Breaker Pattern

Retries handle brief errors; a circuit breaker handles sustained outages by failing fast instead of piling retries onto an API that is already struggling. The pattern itself is generic. Use a maintained circuit-breaker library for your platform rather than implementing the state machine yourself. Two rules matter when you wrap Sent calls:

  • Trip the breaker only on retryable failures: network errors and 5xx responses. A 4xx response means your request needs fixing, and a 429 needs backoff (see Exponential Backoff), not an open circuit.
  • Wrap the sendMessage helper (or your equivalent) so queued sends fail fast while the circuit is open, then re-enter your retry flow when the probe request succeeds.

Idempotency

Prevent duplicate messages when retrying:

async function sendMessageIdempotent(
  phoneNumber: string,
  templateId: string,
  idempotencyKey: string
) {
  try {
    return await client.messages.send({
      to: [phoneNumber],
      template: { id: templateId }
    }, {
      headers: { 'Idempotency-Key': idempotencyKey }
    });
  } catch (error) {
    if (error.code === 'CONFLICT_001') {
      // Duplicate request - message already sent with this key
      console.log('Message already sent with this idempotency key');
      return { success: true, duplicate: true };
    }
    throw error;
  }
}

// Generate idempotency key from business context
const idempotencyKey = `order_confirmation_${orderId}_${userId}`;
await sendMessageIdempotent(phoneNumber, templateId, idempotencyKey);

Fallback Strategies

Channel Fallback

If you want Sent to handle fallback for you, omit the channel field: automatic selection is the only mode with cross-channel fallback, and it routes each message over another channel when the preferred one can't deliver. Refer to Channel Selection Strategies for how selection works.

An explicit channel value pins the send and disables fallback. Because POST /v3/messages returns 202 before delivery, a pinned send that can't deliver fails asynchronously: the failure arrives as a message.failed webhook event, not as an error response. To fall back manually, re-send on the other channel from your webhook handler:

// At send time: pin to WhatsApp and store the context keyed by message ID
const result = await sendMessage(phoneNumber, templateId, ['whatsapp']);
if (result.success) {
  await pendingSends.set(result.messageId, { phoneNumber, templateId });
}

// In your webhook handler: re-send over SMS when the WhatsApp message fails
async function handleFailedEvent(event) {
  if (event.event === 'message.failed' && event.payload.channel === 'whatsapp') {
    const context = await pendingSends.get(event.payload.message_id);
    if (context) {
      console.log('WhatsApp delivery failed, falling back to SMS');
      await sendMessage(context.phoneNumber, context.templateId, ['sms']);
      await pendingSends.delete(event.payload.message_id);
    }
  }
}

Queue for Later

POST /v3/messages accepts sends with 202 even when your balance is too low. Each affected message then finalizes as BLOCKED and fires a message.blocked webhook event instead of returning a synchronous error, and blocked messages are not re-sent automatically after a top-up. Handle the event by queuing a re-send and alerting your billing owner, reusing the pendingSends store from the fallback example:

async function handleBlockedEvent(event) {
  if (event.event === 'message.blocked') {
    // Account-level gate (for example, insufficient balance); message not dispatched
    const context = await pendingSends.get(event.payload.message_id);
    if (context) {
      await retryQueue.add(context);
      await pendingSends.delete(event.payload.message_id);
    }
    await alertBillingTeam('Messages blocked - check account balance');
  }
}

// After topping up, re-send queued messages as new requests
for (const context of await retryQueue.drain()) {
  await sendMessage(context.phoneNumber, context.templateId);
}

Monitoring and Alerting

Error Metrics

Track error rates to detect issues:

// Increment counters
errorCounter.labels({ type: 'rate_limited' }).inc();
errorCounter.labels({ type: 'network' }).inc();

// Alert on high error rates
if (errorRate > 0.1) { // 10% error rate
  await sendAlert('High message send error rate', { errorRate });
}

Structured Logging

Log errors with context for debugging:

logger.error('Message send failed', {
  error: error.message,
  errorCode: error.code,
  phoneNumber: maskPhone(phoneNumber),
  templateId,
  attempt: attemptNumber,
  retryable: isRetryable(error)
});

Best Practices

1. Distinguish Retryable vs Non-Retryable

function isRetryable(error: APIError): boolean {
  // Never retry auth errors
  if (error.status === 401) {
    return false;
  }

  // Never retry payment / balance errors (legacy v2 send endpoints only;
  // v3 sends surface balance problems asynchronously as BLOCKED)
  if (error.status === 402) {
    return false;
  }

  // Don't retry validation errors
  if (error.status === 400 || error.status === 422) {
    return false;
  }

  // Retry server errors and rate limits
  return error.status >= 500 || error.status === 429;
}

2. Set Maximum Retry Limits

Prevent infinite loops:

const MAX_RETRIES = 3;
const MAX_DELAY = 30000; // 30 seconds

const delay = Math.min(Math.pow(2, attempt) * 1000, MAX_DELAY);

3. Fail Fast for User-Facing Errors

Don't retry if user needs to fix something:

if (error.code === 'VALIDATION_002') {
  // Show error to user immediately
  return { success: false, userError: 'Please enter a valid phone number' };
}

4. Use Dead Letter Queues

For messages that ultimately fail:

async function processMessage(message: Message) {
  const result = await sendWithRetry(message);

  if (!result.success) {
    // Move to dead letter queue for manual review
    await deadLetterQueue.add({
      originalMessage: message,
      error: result.error,
      attempts: result.attempts,
      failedAt: new Date()
    });
  }
}

Testing Error Handling

To exercise each error path without sending real messages:

  • Success path: add sandbox: true to the request body. The API authenticates and validates the request, then returns 202 with status: "QUEUED" and generated message IDs. Nothing is sent or charged. See Sandbox Mode.
  • Validation errors: request validation runs even with sandbox: true, so a malformed request (for example, a phone number that isn't E.164) returns a real 400 your handler can be tested against.
  • Provider and rate-limit errors: sandbox mode does not simulate 429, 5xx, or delivery failures. Unit-test your retry and fallback branches by mocking the SDK client to throw those errors, as shown in Testing & Debugging.
  • Webhook-driven paths: trigger your message.failed and message.blocked handlers with the webhook test endpoint, which sends a test event to your registered URL.

For failure injection beyond unit tests (dropped connections, dependency outages), apply standard chaos-engineering practice at your infrastructure layer. It requires no Sent-specific setup.


On this page