Documentation Index
Fetch the complete documentation index at: https://docs.lettr.com/llms.txt
Use this file to discover all available pages before exploring further.
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.
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 Code | HTTP Status | Description |
|---|
validation_error | 422 | Invalid request parameters |
invalid_domain | 400 | Sender domain not verified |
unconfigured_domain | 400 | Domain not ready for sending |
template_not_found | 404 | Template slug not found |
send_error | 500 | Email sending error |
transmission_failed | 502 | Email 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 Type | Retryable | Action |
|---|
429 Rate limit | Yes | Wait and retry with backoff |
500 Server error | Yes | Retry with backoff |
502/503/504 Gateway errors | Yes | Retry with backoff |
401 Unauthorized | No | Check API key |
403 Forbidden | No | Check API key permissions |
422 Validation | No | Fix request data |
404 Not found | No | Check 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 Type | Auto-Retry | Max Attempts |
|---|
| Soft bounce (mailbox full) | Yes | 3 |
| Soft bounce (server error) | Yes | 3 |
| 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);
});