Skip to main content
API calls can fail for many reasons — invalid parameters, unverified domains, rate limits, or transient server issues. How your integration handles these failures determines whether emails are silently lost or reliably delivered. This page covers Lettr’s error response format, common error codes, which errors are safe to retry, and patterns for building resilient send logic.

Error Response Format

All API errors return a JSON object with a consistent structure. The error_code field identifies the category of failure, message provides a human-readable description, and errors (present on validation failures) lists problems for specific fields:
{
  "error_code": "validation_error",
  "message": "The given data was invalid.",
  "errors": {
    "to": ["The to field is required."],
    "subject": ["The subject must not exceed 998 characters."]
  }
}

Common Error Codes

Error CodeHTTP StatusDescription
validation_error422Invalid request parameters
invalid_domain400Sender domain not verified
unconfigured_domain400Domain not ready for sending
template_not_found404Template slug not found
send_error500Email sending error
transmission_failed502Email delivery to upstream provider failed

Validation Errors

Validation errors (422) mean the request was well-formed but contained invalid data — a missing required field, an unverified sender domain, or a subject line exceeding the character limit. The errors object in the response maps each invalid field to an array of error messages:
try {
  await lettr.emails.send(emailData);
} catch (error) {
  if (error.status === 422) {
    console.log('Validation errors:', error.errors);

    // Handle specific field errors
    if (error.errors.to) {
      console.log('Invalid recipients:', error.errors.to);
    }
    if (error.errors.from) {
      console.log('Invalid sender:', error.errors.from);
    }
  }
}

Domain Errors

Domain-related errors occur when the from address uses a domain that hasn’t been verified or whose DNS records aren’t properly configured. These are the most common errors during initial integration setup:
try {
  await lettr.emails.send({
    from: 'you@unverified-domain.com',
    to: ['recipient@example.com'],
    subject: 'Hello',
    html: '<p>Hello!</p>'
  });
} catch (error) {
  if (error.error_code === 'invalid_domain') {
    console.log('Domain not verified. Please verify your domain first.');
  }
  if (error.error_code === 'unconfigured_domain') {
    console.log('Domain DNS records not configured correctly.');
  }
}

Retry Logic

A well-designed retry function should distinguish between retryable errors (server failures, rate limits) and non-retryable errors (validation, authentication). The following pattern uses exponential backoff and respects the retry_after value from rate limit responses:
async function sendEmailWithRetry(emailData, options = {}) {
  const {
    maxRetries = 3,
    baseDelay = 1000,
    maxDelay = 30000
  } = options;

  let lastError;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await lettr.emails.send(emailData);
    } catch (error) {
      lastError = error;

      // Don't retry client errors (except rate limits)
      if (error.status >= 400 && error.status < 500 && error.status !== 429) {
        throw error;
      }

      // Calculate delay with exponential backoff
      const delay = Math.min(
        baseDelay * Math.pow(2, attempt - 1),
        maxDelay
      );

      // Use retry-after header if provided
      const retryAfter = error.retryAfter
        ? error.retryAfter * 1000
        : delay;

      console.log(`Attempt ${attempt} failed. Retrying in ${retryAfter}ms...`);
      await sleep(retryAfter);
    }
  }

  throw lastError;
}

Retryable vs Non-Retryable Errors

Not all errors should be retried. Retrying a validation error or an unauthorized request will produce the same result every time. The table below summarizes which errors are safe to retry and what action to take for each:
Error TypeRetryableAction
429 Rate limitYesWait and retry with backoff
500 Server errorYesRetry with backoff
502/503/504 Gateway errorsYesRetry with backoff
401 UnauthorizedNoCheck API key
403 ForbiddenNoCheck API key permissions
422 ValidationNoFix request data
404 Not foundNoCheck template/resource exists

Error Handling Best Practices

Wrapping the retry function with error-specific handling gives you a clean interface for your application code. Log errors with enough context to debug later, and return structured results so calling code can decide how to proceed:
async function sendEmail(emailData) {
  try {
    const response = await sendEmailWithRetry(emailData);
    return { success: true, data: response.data };
  } catch (error) {
    // Log error for debugging
    console.error('Email send failed:', {
      error_code: error.error_code,
      message: error.message,
      to: emailData.to,
      timestamp: new Date().toISOString()
    });

    // Handle specific errors
    switch (error.error_code) {
      case 'validation_error':
        return { success: false, error: 'Invalid email data', details: error.errors };

      case 'invalid_domain':
        return { success: false, error: 'Sender domain not verified' };

      case 'rate_limit_exceeded':
        return { success: false, error: 'Rate limit exceeded', retryAfter: error.retryAfter };

      default:
        return { success: false, error: 'Failed to send email' };
    }
  }
}

Soft Bounce Retries

After Lettr accepts an email for delivery, the receiving mail server may reject it with a temporary error (soft bounce). Lettr automatically retries soft bounces on your behalf — you don’t need to implement retry logic for these. Hard bounces are permanent failures and are never retried:
Bounce TypeAuto-RetryMax Attempts
Soft bounce (mailbox full)Yes3
Soft bounce (server error)Yes3
Hard bounce (invalid address)No-

Circuit Breaker Pattern

In high-volume sending scenarios, repeated failures to the API can indicate a systemic issue (service degradation, network problem). A circuit breaker stops sending attempts after a threshold of failures, waits for a cooldown period, then allows a single test request through. If the test succeeds, normal traffic resumes. This prevents your application from wasting resources on requests that are likely to fail:
class EmailCircuitBreaker {
  constructor(threshold = 5, resetTimeout = 60000) {
    this.failures = 0;
    this.threshold = threshold;
    this.resetTimeout = resetTimeout;
    this.state = 'closed';
    this.lastFailure = null;
  }

  async send(emailData) {
    if (this.state === 'open') {
      if (Date.now() - this.lastFailure > this.resetTimeout) {
        this.state = 'half-open';
      } else {
        throw new Error('Circuit breaker is open');
      }
    }

    try {
      const result = await lettr.emails.send(emailData);
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  onSuccess() {
    this.failures = 0;
    this.state = 'closed';
  }

  onFailure() {
    this.failures++;
    this.lastFailure = Date.now();
    if (this.failures >= this.threshold) {
      this.state = 'open';
    }
  }
}

Monitoring Errors

Beyond handling errors in your send logic, set up webhook-based monitoring to catch delivery failures that occur after the API accepts the email. This lets you alert your team about bounces, failures, and other delivery problems in real time:
// Webhook handler for delivery failures
app.post('/webhooks/lettr', (req, res) => {
  const event = req.body;

  if (event.type === 'generation.generation_failure' || event.type === 'message.bounce') {
    // Alert your team
    alertTeam({
      type: event.type,
      email_id: event.data.email_id,
      recipient: event.data.to,
      error: event.data.error_message
    });
  }

  res.sendStatus(200);
});