Back to Blog
Technical

Email API Design Patterns for Developers

Best practices for integrating email APIs into your application, including error handling and retry strategies.

Alex Kim

Developer Advocate

December 20, 2025
7 min read

Integrating an email API seems straightforward - until you hit production. Here are proven patterns for building robust email functionality into your applications.

The Basics Done Right

Always Send Asynchronously

Never block your user's request waiting for an email to send:

// ❌ Bad: Blocking the request
app.post('/register', async (req, res) => {
  const user = await createUser(req.body);
  await sendWelcomeEmail(user); // User waits for this
  res.json({ success: true });

// ✅ Good: Queue the email app.post('/register', async (req, res) => { const user = await createUser(req.body); await emailQueue.add('welcome', { userId: user.id }); res.json({ success: true }); // Respond immediately }); ```

Idempotency Keys

Prevent duplicate emails when retries happen:

const response = await postalynk.send({
  idempotencyKey: `order-confirmation-${orderId}`,
  to: customer.email,
  subject: 'Order Confirmed',
  // ...
});

If you retry with the same key, Postalynk returns the original response instead of sending a duplicate.

Validate Before Sending

Catch problems early:

function validateEmailRequest(params) {
  if (!params.to || !isValidEmail(params.to)) {
    throw new Error('Invalid recipient');
  }
  if (!params.subject || params.subject.length > 998) {
    throw new Error('Invalid subject');
  }
  if (!params.html && !params.text && !params.templateId) {
    throw new Error('No content provided');
  }
}

Error Handling

Categorize Errors

Not all errors are equal:

try {
  await postalynk.send(emailParams);
} catch (error) {
  if (error.type === 'validation_error') {
    // Bad request - don't retry
    logger.error('Invalid email params', { error, params: emailParams });
    return { success: false, reason: 'invalid_params' };

if (error.type === 'rate_limit') { // Retry after delay await emailQueue.add('send', emailParams, { delay: error.retryAfter * 1000 }); return { success: true, queued: true }; }

if (error.type === 'server_error') { // Transient error - retry with backoff throw error; // Let queue handle retry } } ```

Exponential Backoff

For transient failures, increase delay between retries:

async function sendWithRetry(params, attempt = 0) { try { return await postalynk.send(params); } catch (error) { if (attempt >= retryDelays.length || !isRetryable(error)) { throw error; } await sleep(retryDelays[attempt]); return sendWithRetry(params, attempt + 1); } } ```

Circuit Breaker Pattern

Stop hammering a failing service:

class EmailCircuitBreaker {
  constructor() {
    this.failures = 0;
    this.lastFailure = null;
    this.state = 'closed'; // closed, open, half-open

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

try { const result = await postalynk.send(params); 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 >= 5) { this.state = 'open'; } } } ```

Template Patterns

Centralize Template Logic

Don't scatter template IDs across your codebase:

// email-templates.js
export const templates = {
  WELCOME: 'welcome-v2',
  PASSWORD_RESET: 'password-reset-v1',
  ORDER_CONFIRMATION: 'order-confirmation-v3',
  // ...

export function sendWelcomeEmail(user) { return postalynk.send({ to: user.email, templateId: templates.WELCOME, variables: { name: user.firstName, loginUrl: ${config.appUrl}/login, }, }); } ```

Version Your Templates

When updating templates, keep the old version:

welcome-v1  (deprecated, still used by some flows)
welcome-v2  (current production)
welcome-v3  (testing/staging)

Provide Fallbacks

Always include plain text:

await postalynk.send({
  to: recipient,
  subject: 'Your Order',
  html: renderHtmlTemplate(data),
  text: renderPlainTextTemplate(data), // Fallback for text-only clients
});

Webhook Handling

Verify Signatures

Never trust webhook payloads without verification:

app.post('/webhooks/email', (req, res) => {
  const signature = req.headers['x-postalynk-signature'];
  const isValid = verifySignature(
    req.rawBody,
    signature,
    process.env.WEBHOOK_SECRET

if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); }

// Process webhook... res.json({ received: true }); }); ```

Respond Quickly

Process webhooks asynchronously to avoid timeouts:

app.post('/webhooks/email', async (req, res) => {

// Queue for processing await webhookQueue.add('process', req.body);

// Respond immediately res.json({ received: true }); }); ```

Handle Duplicates

Webhooks can be delivered multiple times. Use idempotent processing:

async function processDeliveryWebhook(event) {

// Check if already processed if (await redis.get(lockKey)) { return; // Already handled }

// Process the event await updateMessageStatus(event.messageId, event.status);

// Mark as processed (expire after 24h) await redis.setex(lockKey, 86400, '1'); } ```

Testing Strategies

Mock in Unit Tests

Don't hit real APIs in unit tests:

jest.mock('./email-client', () => ({
  send: jest.fn().mockResolvedValue({ messageId: 'test-123' }),

test('sends welcome email on registration', async () => { await registerUser({ email: 'test@example.com' });

expect(emailClient.send).toHaveBeenCalledWith( expect.objectContaining({ to: 'test@example.com', templateId: 'welcome-v2', }) ); }); ```

Use Test Mode Keys

Postalynk test keys (mf_test_) log emails without sending:

const postalynk = new Postalynk({
  apiKey: process.env.NODE_ENV === 'production'
    ? process.env.POSTALYNK_LIVE_KEY
    : process.env.POSTALYNK_TEST_KEY,
});

Integration Test with Webhooks

Use webhook.site or similar for integration tests:

test('delivery webhook fires on send', async () => {

await postalynk.send({ to: 'test@example.com', subject: 'Test', text: 'Test message', webhookUrl, // Per-message webhook override });

// Poll for webhook delivery const event = await waitForWebhook(webhookUrl); expect(event.type).toBe('delivered'); }); ```

Monitoring and Observability

Track Key Metrics

const metrics = {
  emailsSent: new Counter('emails_sent_total'),
  emailsFailed: new Counter('emails_failed_total'),
  sendLatency: new Histogram('email_send_duration_seconds'),

async function sendEmail(params) { const timer = metrics.sendLatency.startTimer();

try { const result = await postalynk.send(params); metrics.emailsSent.inc({ type: params.type }); return result; } catch (error) { metrics.emailsFailed.inc({ type: params.type, error: error.code }); throw error; } finally { timer(); } } ```

Log Structured Data

logger.info('Email sent', {
  messageId: result.messageId,
  recipient: params.to,
  template: params.templateId,
  duration: endTime - startTime,
});

Conclusion

Robust email integration requires attention to error handling, retries, and testing. These patterns will help you build email functionality that works reliably in production.

Postalynk provides the tools to implement these patterns easily - from idempotency keys to webhook signatures to test mode. Start with the basics and add complexity as your needs grow.

Share this article:

Related Articles

Ready to improve your email deliverability?

Start sending emails with Postalynk today. Free plan available.