Skip to main content
Duplicate emails damage user trust — nobody wants to receive the same order confirmation twice. Network failures, retries, and concurrent requests can all cause a single email to be sent more than once unless your integration is designed to prevent it. This page covers how to use Lettr’s request IDs and application-level idempotency patterns to ensure each email is sent exactly once, even when failures and retries occur.

Request IDs

Every successful send returns a unique request_id that identifies the transmission. This ID is your primary handle for tracking the email through delivery, webhooks, and the dashboard:
const response = await lettr.emails.send({
  from: 'you@example.com',
  to: ['recipient@example.com'],
  subject: 'Order Confirmation',
  html: '<p>Your order is confirmed!</p>'
});

console.log(response.data.request_id);
// "7582751837467401763"

Using Request IDs

Store the request_id alongside your business record (order, user, ticket) so you can reference it later. The request ID lets you query the email’s delivery status through the API, look it up in the dashboard, trace it through webhook events, and provide it to Lettr support if you need to investigate a delivery issue.
// Store with your order
await db.orders.update(orderId, {
  confirmation_email_request_id: response.data.request_id
});

// Later, check delivery status
const email = await lettr.emails.get(requestId);
console.log(email.status);

Preventing Duplicate Sends

The simplest way to prevent duplicates is to check a flag in your database before sending. If the email has already been sent for a given business event (order confirmed, password reset requested), skip the API call:
async function sendOrderConfirmation(order) {
  // Check if already sent
  if (order.confirmation_email_sent) {
    console.log('Email already sent for order', order.id);
    return;
  }

  const response = await lettr.emails.send({
    from: 'orders@example.com',
    to: [order.customer_email],
    subject: `Order #${order.id} Confirmed`,
    template_slug: 'order-confirmation',
    substitution_data: { order },
    metadata: {
      order_id: order.id,
      idempotency_key: `order-confirmation-${order.id}`
    }
  });

  // Mark as sent
  await db.orders.update(order.id, {
    confirmation_email_sent: true,
    confirmation_email_request_id: response.data.request_id
  });
}

Distributed Idempotency

For distributed systems where multiple workers might process the same event, use a shared cache or database lock to coordinate. Store a unique idempotency key derived from the business event and check it before sending:
async function sendPasswordReset(userId, resetToken, userEmail) {
  const idempotencyKey = `password-reset-${userId}-${resetToken}`;

  // Use a distributed lock or cache to check if already sent
  const alreadySent = await cache.get(idempotencyKey);
  if (alreadySent) {
    console.log('Password reset email already sent');
    return;
  }

  const response = await lettr.emails.send({
    from: 'security@example.com',
    to: [userEmail],
    subject: 'Password Reset Request',
    template_slug: 'password-reset',
    metadata: {
      idempotency_key: idempotencyKey,
      user_id: userId
    }
  });

  // Mark as sent
  await cache.set(idempotencyKey, response.data.request_id, { ttl: 86400 });
  return response;
}

Safe Retry Pattern

When a send request fails with a server error (5xx) or network timeout, it’s safe to retry — but only if the error was transient. Client errors (4xx) indicate a problem with the request itself and should not be retried. This pattern implements exponential backoff for retryable errors while immediately re-throwing client errors:
async function sendEmailWithRetry(emailData, maxRetries = 3) {
  let lastError;

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

      // Don't retry client errors (4xx)
      if (error.status >= 400 && error.status < 500) {
        throw error;
      }

      // Wait before retrying (exponential backoff)
      if (attempt < maxRetries) {
        await sleep(Math.pow(2, attempt) * 1000);
      }
    }
  }

  throw lastError;
}

Request ID in Webhooks

Every webhook event includes the request_id from the original send. This lets your webhook handler look up the corresponding business record (order, user, ticket) and update its status based on the delivery outcome:
{
  "type": "message.delivery",
  "data": {
    "request_id": "7582751837467401763",
    "message_id": "msg_abc123",
    "to": "recipient@example.com",
    "metadata": {
      "order_id": "order_12345"
    }
  }
}

Best Practices

Persist the request_id alongside the business record that triggered the email (e.g., an order row, a user record, a support ticket). This gives you a direct lookup path when investigating delivery issues, correlating webhook events, or providing information to support.
Construct keys from the email’s purpose and the entity it relates to, such as order-confirmation-{order_id} or password-reset-{user_id}-{token}. Avoid generic keys like email-123 — the key should make it impossible for a different email type to collide with the same value.
For critical emails like order confirmations and password resets, always verify that the email hasn’t already been sent before calling the API. A database flag check is fast and prevents unnecessary API calls, even before metadata-based deduplication comes into play.
A network timeout does not mean the email wasn’t sent — the request may have reached Lettr successfully, but the response was lost. Before retrying, query the API using your idempotency key to check whether the email was already accepted for delivery.

Timeout Handling

A timeout (ETIMEDOUT) or connection reset (ECONNRESET) means your client didn’t receive a response, but Lettr may have already accepted and queued the email. Before retrying, check your local idempotency records (database flag or cache) to see if the send was already recorded. If it wasn’t, you can safely retry — but be aware the email may have been sent despite the timeout:
async function safeSend(emailData, idempotencyKey) {
  try {
    const response = await lettr.emails.send(emailData);
    await cache.set(idempotencyKey, response.data.request_id, { ttl: 86400 });
    return response;
  } catch (error) {
    if (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET') {
      // Check local cache to see if we already recorded a successful send
      const existingRequestId = await cache.get(idempotencyKey);
      if (existingRequestId) {
        return { data: { request_id: existingRequestId }, alreadySent: true };
      }
      // If not in cache, the send may or may not have succeeded.
      // Log the ambiguity and consider retrying with caution.
      console.warn('Timeout occurred, send status unknown for:', idempotencyKey);
    }
    throw error;
  }
}