Error Handling

Learn how to handle errors gracefully when using the Transactional API and SDKs.

Error Handling

Proper error handling ensures your application can gracefully recover from failures and provide meaningful feedback to users. This guide covers error handling for the Transactional API and SDKs.

Error Response Format

All API errors follow a consistent format:

{
  "success": false,
  "error": {
    "code": "INVALID_API_KEY",
    "message": "The API key provided is invalid.",
    "statusCode": 401,
    "details": {}
  }
}

Common Error Codes

Authentication Errors (4xx)

CodeHTTP StatusDescription
UNAUTHORIZED401Missing or invalid API key
INVALID_API_KEY401API key format is invalid
API_KEY_REVOKED401API key has been revoked
FORBIDDEN403API key lacks required permissions

Validation Errors (400)

CodeHTTP StatusDescription
VALIDATION_ERROR400Request body validation failed
INVALID_EMAIL400Email address format is invalid
INVALID_PHONE400Phone number format is invalid
MISSING_REQUIRED_FIELD400Required field is missing

Resource Errors (404)

CodeHTTP StatusDescription
NOT_FOUND404Resource not found
TEMPLATE_NOT_FOUND404Email template not found
MESSAGE_NOT_FOUND404Message ID not found
FORM_NOT_FOUND404Form not found

Sending Errors

CodeHTTP StatusDescription
INACTIVE_RECIPIENT406Recipient is inactive (bounced)
SUPPRESSED406Recipient is suppressed
SENDER_NOT_VERIFIED400Sender email not verified
DOMAIN_NOT_VERIFIED400Sending domain not verified

Rate Limiting

CodeHTTP StatusDescription
RATE_LIMITED429Too many requests

Server Errors

CodeHTTP StatusDescription
INTERNAL_ERROR500Server error
SERVICE_UNAVAILABLE503Service temporarily unavailable

SDK Error Handling

TypeScript SDK

The SDK throws typed errors you can catch and handle:

import { Transactional, ApiError, ValidationError } from 'transactional-sdk';
 
const client = new Transactional({
  apiKey: process.env.TRANSACTIONAL_API_KEY,
});
 
async function sendEmail(to: string, subject: string, html: string) {
  try {
    const result = await client.emails.send({
      from: 'hello@yourapp.com',
      to,
      subject,
      html,
    });
    return { success: true, messageId: result.messageId };
  } catch (error) {
    if (error instanceof ApiError) {
      switch (error.code) {
        case 'INACTIVE_RECIPIENT':
          // Handle bounced email
          await markEmailAsInactive(to);
          return { success: false, error: 'Email address is inactive' };
 
        case 'SUPPRESSED':
          // Handle suppressed email
          return { success: false, error: 'Recipient has unsubscribed' };
 
        case 'SENDER_NOT_VERIFIED':
          // Handle unverified sender
          console.error('Sender not verified:', error.message);
          return { success: false, error: 'Configuration error' };
 
        case 'RATE_LIMITED':
          // Handle rate limiting
          const retryAfter = error.headers?.['retry-after'] || 60;
          await sleep(retryAfter * 1000);
          return sendEmail(to, subject, html); // Retry
 
        default:
          console.error('API Error:', error.code, error.message);
          return { success: false, error: 'Failed to send email' };
      }
    }
 
    if (error instanceof ValidationError) {
      console.error('Validation failed:', error.errors);
      return { success: false, error: 'Invalid request data' };
    }
 
    // Network or other errors
    console.error('Unexpected error:', error);
    return { success: false, error: 'An unexpected error occurred' };
  }
}

Python SDK

from transactional import Transactional, ApiError, ValidationError
import os
 
client = Transactional(api_key=os.environ["TRANSACTIONAL_API_KEY"])
 
def send_email(to: str, subject: str, html: str):
    try:
        result = client.emails.send(
            from_email="hello@yourapp.com",
            to=to,
            subject=subject,
            html=html,
        )
        return {"success": True, "message_id": result.message_id}
    except ApiError as e:
        if e.code == "INACTIVE_RECIPIENT":
            mark_email_as_inactive(to)
            return {"success": False, "error": "Email address is inactive"}
        elif e.code == "SUPPRESSED":
            return {"success": False, "error": "Recipient has unsubscribed"}
        elif e.code == "RATE_LIMITED":
            time.sleep(60)
            return send_email(to, subject, html)  # Retry
        else:
            print(f"API Error: {e.code} - {e.message}")
            return {"success": False, "error": "Failed to send email"}
    except ValidationError as e:
        print(f"Validation failed: {e.errors}")
        return {"success": False, "error": "Invalid request data"}
    except Exception as e:
        print(f"Unexpected error: {e}")
        return {"success": False, "error": "An unexpected error occurred"}

Retry Strategies

Exponential Backoff

async function sendWithRetry(
  message: EmailMessage,
  maxRetries: number = 3
): Promise<SendResult> {
  let lastError: Error | null = null;
 
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await client.emails.send(message);
    } catch (error) {
      lastError = error;
 
      if (error instanceof ApiError) {
        // Don't retry these errors
        if (['INACTIVE_RECIPIENT', 'SUPPRESSED', 'VALIDATION_ERROR'].includes(error.code)) {
          throw error;
        }
 
        // Retry rate limits with backoff
        if (error.code === 'RATE_LIMITED') {
          const retryAfter = parseInt(error.headers?.['retry-after'] || '60');
          await sleep(retryAfter * 1000);
          continue;
        }
 
        // Retry server errors with exponential backoff
        if (error.statusCode >= 500) {
          const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
          await sleep(delay);
          continue;
        }
      }
 
      throw error;
    }
  }
 
  throw lastError;
}

Circuit Breaker

class CircuitBreaker {
  private failures = 0;
  private lastFailure: Date | null = null;
  private state: 'closed' | 'open' | 'half-open' = 'closed';
 
  constructor(
    private threshold: number = 5,
    private resetTimeout: number = 30000
  ) {}
 
  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      if (Date.now() - this.lastFailure!.getTime() > this.resetTimeout) {
        this.state = 'half-open';
      } else {
        throw new Error('Circuit breaker is open');
      }
    }
 
    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
 
  private onSuccess() {
    this.failures = 0;
    this.state = 'closed';
  }
 
  private onFailure() {
    this.failures++;
    this.lastFailure = new Date();
    if (this.failures >= this.threshold) {
      this.state = 'open';
    }
  }
}
 
// Usage
const circuitBreaker = new CircuitBreaker();
 
async function sendEmail(message: EmailMessage) {
  return circuitBreaker.execute(() => client.emails.send(message));
}

Batch Error Handling

For batch operations, handle individual failures:

const results = await client.emails.sendBatch(messages);
 
const successful: SendResult[] = [];
const failed: { message: EmailMessage; error: SendResult }[] = [];
 
results.forEach((result, index) => {
  if (result.errorCode === 0) {
    successful.push(result);
  } else {
    failed.push({ message: messages[index], error: result });
  }
});
 
console.log(`Sent: ${successful.length}, Failed: ${failed.length}`);
 
// Handle failures by error type
for (const { message, error } of failed) {
  switch (error.errorCode) {
    case 406: // INACTIVE_RECIPIENT
      await handleInactiveRecipient(message.to);
      break;
    case 300: // Invalid email
      await logInvalidEmail(message.to);
      break;
    default:
      // Queue for retry
      await retryQueue.add(message);
  }
}

Webhook Error Handling

Handle errors in webhook processing:

app.post('/webhooks/transactional', async (req, res) => {
  try {
    const event = verifyWebhookSignature(req.body, req.headers);
    await processEvent(event);
    res.sendStatus(200);
  } catch (error) {
    if (error.message === 'Invalid signature') {
      console.error('Webhook signature verification failed');
      res.status(401).send('Invalid signature');
      return;
    }
 
    // Log but don't fail - we want to return 200 to prevent retries
    // if the error is in our processing logic
    console.error('Webhook processing error:', error);
 
    // Queue for retry processing
    await webhookRetryQueue.add(req.body);
 
    res.sendStatus(200);
  }
});

Logging Best Practices

Structured Logging

import winston from 'winston';
 
const logger = winston.createLogger({
  format: winston.format.json(),
  transports: [new winston.transports.Console()],
});
 
async function sendEmail(message: EmailMessage) {
  const startTime = Date.now();
 
  try {
    const result = await client.emails.send(message);
 
    logger.info('Email sent', {
      messageId: result.messageId,
      to: message.to,
      duration: Date.now() - startTime,
    });
 
    return result;
  } catch (error) {
    logger.error('Email send failed', {
      to: message.to,
      error: error instanceof ApiError ? error.code : 'UNKNOWN',
      message: error.message,
      duration: Date.now() - startTime,
    });
 
    throw error;
  }
}

Error Tracking

import * as Sentry from '@sentry/node';
 
async function sendEmail(message: EmailMessage) {
  try {
    return await client.emails.send(message);
  } catch (error) {
    // Only report unexpected errors to Sentry
    if (error instanceof ApiError) {
      if (!['INACTIVE_RECIPIENT', 'SUPPRESSED', 'RATE_LIMITED'].includes(error.code)) {
        Sentry.captureException(error, {
          extra: {
            to: message.to,
            errorCode: error.code,
          },
        });
      }
    } else {
      Sentry.captureException(error);
    }
 
    throw error;
  }
}

User-Friendly Error Messages

Map API errors to user-friendly messages:

const ERROR_MESSAGES: Record<string, string> = {
  INACTIVE_RECIPIENT: 'This email address appears to be inactive. Please use a different email.',
  SUPPRESSED: 'This recipient has opted out of receiving emails.',
  INVALID_EMAIL: 'Please enter a valid email address.',
  RATE_LIMITED: 'Too many requests. Please try again in a moment.',
  SENDER_NOT_VERIFIED: 'There was a configuration issue. Please contact support.',
  INTERNAL_ERROR: 'An unexpected error occurred. Please try again later.',
};
 
function getUserFriendlyError(error: ApiError): string {
  return ERROR_MESSAGES[error.code] || 'An error occurred. Please try again.';
}

Next Steps