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:
| Status | Example Code | Meaning | Action |
|---|---|---|---|
| 400 | VALIDATION_001 | Request validation failed | Fix the request body; check error.details |
| 401 | AUTH_002 | Invalid or missing API key | Verify the API key; don't retry |
| 404 | RESOURCE_001 | Resource not found | Verify IDs |
| 409 | CONFLICT_001 | Concurrent idempotent request in progress | Wait for the original request to complete |
| 429 | BUSINESS_002 | Rate limit exceeded | Implement 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:
| Status | Meaning | Action |
|---|---|---|
| 500 | Internal server error | Retry with backoff |
| 502 | Bad gateway | Retry with backoff |
| 503 | Service unavailable | Retry with backoff |
| 504 | Gateway timeout | Retry with backoff |
Business Logic Errors
Application-level errors requiring business decisions:
| Error Code | Meaning | Action |
|---|---|---|
BUSINESS_003 | Insufficient account balance | Alert billing, queue for later |
BUSINESS_005 | Template not approved | Wait or use SMS fallback |
BUSINESS_007 | Channel not available for this contact | Switch channel or use fallback |
VALIDATION_002 | Invalid phone number format | Validate input, notify user |
ERR_CONSENT_BLOCKED | Recipient 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 }
endThe 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
5xxresponses. A4xxresponse means your request needs fixing, and a429needs backoff (see Exponential Backoff), not an open circuit. - Wrap the
sendMessagehelper (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: trueto the request body. The API authenticates and validates the request, then returns202withstatus: "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 real400your 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.failedandmessage.blockedhandlers 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.
Batch Operations
How to send messages in bulk with the Sent API: batches of up to 1000 recipients, rate limit management, idempotent retries, and bulk contact imports.
How to handle Sent API errors
Handle Sent API v3 error responses in client code: check the success flag, branch on error codes, capture request IDs, and test failures with sandbox mode.