Skip to main content
Beyond bounces, spam complaints and unsubscribes are critical signals that affect your sender reputation. Handling these events properly is essential for maintaining deliverability and legal compliance.

Spam Complaints

A spam complaint occurs when a recipient clicks “Report Spam” or “Mark as Junk” in their email client. This is one of the most damaging signals for your sender reputation.

Understanding Complaints

When a recipient complains, their email provider sends a feedback report to Lettr. The address is automatically suppressed and you receive a message.spam_complaint webhook event.
[{
  "msys": {
    "message_event": {
      "type": "spam_complaint",
      "timestamp": "1705320000",
      "transmission_id": "12345678",
      "message_id": "abcd-1234-efgh",
      "rcpt_to": "recipient@example.com",
      "fbtype": "abuse",
      "friendly_from": "you@example.com",
      "subject": "Your Weekly Newsletter",
      "sending_ip": "192.168.1.1",
      "rcpt_meta": {
        "userId": "user_789"
      }
    }
  }
}]
FieldTypeDescription
message_idstringUnique identifier for the email
rcpt_tostringRecipient who complained
timestampstringUnix timestamp of the complaint
fbtypestringType of feedback (typically abuse)
rcpt_metaobjectCustom metadata from the original send

Complaint Rate Guidelines

Complaint RateStatusAction
< 0.1%HealthyMaintain current practices
0.1-0.3%WarningReview targeting and content
> 0.3%CriticalPause sending and investigate
A complaint rate above 0.1% is considered high by most email providers. Rates above 0.3% can result in your emails being blocked entirely.

Handling Complaints

app.post('/webhooks/lettr', express.json(), async (req, res) => {
  res.sendStatus(200);

  for (const event of req.body) {
    const eventType = Object.keys(event.msys)[0];
    const data = event.msys[eventType];

    if (data.type === 'spam_complaint') {
      // Immediately suppress in your system
      await db.subscribers.update({
        where: { email: data.rcpt_to },
        data: {
          status: 'complained',
          canEmail: false,
          complainedAt: new Date(parseInt(data.timestamp) * 1000)
        }
      });

      // Log for analysis
      await logComplaint({
        email: data.rcpt_to,
        messageId: data.message_id,
        userId: data.rcpt_meta?.userId,
        timestamp: new Date(parseInt(data.timestamp) * 1000)
      });

      // Alert team - complaints are serious
      await alertTeam({
        type: 'spam_complaint',
        email: data.rcpt_to,
        messageId: data.message_id,
        severity: 'high'
      });
    }
  }
});

Reducing Complaints

Never email people who haven’t explicitly opted in to receive your emails. Purchased lists have extremely high complaint rates.
Tell subscribers what kind of emails they’ll receive and how often. Unexpected emails lead to complaints.
A visible unsubscribe link gives recipients an alternative to marking you as spam. Don’t hide it.
If someone signs up for weekly updates, don’t email them daily. Respect the frequency they agreed to.
Irrelevant emails frustrate recipients. Segment your list and send targeted content.

Unsubscribes

Unsubscribes occur when recipients opt out of receiving your emails. Lettr supports two unsubscribe methods, each with its own event type.

List-Unsubscribe

Modern email clients show an “Unsubscribe” button in their interface. When clicked, this triggers a List-Unsubscribe request directly to Lettr, which generates an unsubscribe.list_unsubscribe event.
[{
  "msys": {
    "unsubscribe_event": {
      "type": "list_unsubscribe",
      "timestamp": "1705320000",
      "transmission_id": "12345678",
      "message_id": "abcd-1234-efgh",
      "rcpt_to": "recipient@example.com",
      "friendly_from": "you@example.com",
      "subject": "Your Weekly Newsletter",
      "rcpt_meta": {
        "userId": "user_789"
      }
    }
  }
}]
When a recipient clicks an unsubscribe link in your email body, this triggers an unsubscribe.link_unsubscribe event.
[{
  "msys": {
    "unsubscribe_event": {
      "type": "link_unsubscribe",
      "timestamp": "1705320000",
      "transmission_id": "12345678",
      "message_id": "abcd-1234-efgh",
      "rcpt_to": "recipient@example.com",
      "friendly_from": "you@example.com",
      "subject": "Your Weekly Newsletter",
      "rcpt_meta": {
        "userId": "user_789"
      }
    }
  }
}]
FieldTypeDescription
message_idstringUnique identifier for the email
rcpt_tostringRecipient who unsubscribed
timestampstringUnix timestamp of the unsubscribe
typestringlist_unsubscribe or link_unsubscribe
rcpt_metaobjectCustom metadata from the original send

Handling Unsubscribes

app.post('/webhooks/lettr', express.json(), async (req, res) => {
  res.sendStatus(200);

  for (const event of req.body) {
    const eventType = Object.keys(event.msys)[0];
    const data = event.msys[eventType];

    if (data.type === 'list_unsubscribe' || data.type === 'link_unsubscribe') {
      // Update subscription status
      await db.subscribers.update({
        where: { email: data.rcpt_to },
        data: {
          status: 'unsubscribed',
          canEmail: false,
          unsubscribedAt: new Date(parseInt(data.timestamp) * 1000),
          unsubscribeMethod: data.type
        }
      });

      // Log for analytics
      await analytics.track('unsubscribe', {
        email: data.rcpt_to,
        method: data.type,
        userId: data.rcpt_meta?.userId
      });
    }
  }
});
Unsubscribe requests must be honored. Both CAN-SPAM (US) and GDPR (EU) require you to stop emailing someone who has unsubscribed. Violations can result in significant fines.
CAN-SPAM Requirements:
  • Process unsubscribe requests within 10 business days
  • Don’t charge a fee or require personal information to unsubscribe
  • Don’t transfer or sell the email address after unsubscribe
GDPR Requirements:
  • Process unsubscribe requests without delay
  • Provide clear and accessible unsubscribe mechanisms
  • Maintain records of consent and withdrawal

Managing Your Suppression Data

While Lettr automatically suppresses addresses that bounce, complain, or unsubscribe, you should also maintain your own records for several reasons:

Why Maintain Your Own Records

  1. Prevent re-adding suppressed addresses: When someone unsubscribes or complains, you shouldn’t re-add them to your list
  2. Cross-platform consistency: If you use multiple email providers, share suppression data between them
  3. Compliance auditing: Maintain proof that you honored unsubscribe requests
  4. List hygiene: Track patterns to improve your email program

Building a Suppression System

// Example suppression record schema
const suppressionSchema = {
  email: String,           // The suppressed email address
  type: String,            // 'bounce', 'complaint', or 'unsubscribe'
  reason: String,          // Detailed reason (e.g., bounce_class or method)
  source: String,          // Where suppression originated
  messageId: String,       // Related message ID if applicable
  createdAt: Date,         // When suppression was added
  metadata: Object         // Additional context
};

// Check before sending
async function canSendTo(email) {
  const suppression = await db.suppressions.findOne({ email });
  return !suppression;
}

// Add suppression from webhook
async function addSuppression(data) {
  let type, reason;

  switch (data.type) {
    case 'bounce':
      const bounceClass = parseInt(data.bounce_class);
      if (![10, 30, 100].includes(bounceClass)) return; // Only suppress hard bounces
      type = 'bounce';
      reason = `bounce_class_${data.bounce_class}`;
      break;
    case 'spam_complaint':
      type = 'complaint';
      reason = data.fbtype || 'abuse';
      break;
    case 'list_unsubscribe':
    case 'link_unsubscribe':
      type = 'unsubscribe';
      reason = data.type;
      break;
    default:
      return;
  }

  await db.suppressions.upsert({
    where: { email: data.rcpt_to },
    create: {
      email: data.rcpt_to,
      type,
      reason,
      source: 'lettr_webhook',
      messageId: data.message_id,
      createdAt: new Date(),
      metadata: data.rcpt_meta
    },
    update: {
      type,
      reason,
      updatedAt: new Date()
    }
  });
}

Pre-Send Validation

Before sending emails, validate addresses against your suppression list:
async function sendEmail(to, subject, html) {
  // Check suppression status
  const canSend = await canSendTo(to);

  if (!canSend) {
    console.log(`Skipping suppressed address: ${to}`);
    return { status: 'suppressed' };
  }

  // Proceed with sending
  const response = await fetch('https://app.lettr.com/api/emails', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      from: 'you@example.com',
      to: [to],
      subject,
      html
    })
  });

  return response.json();
}

Webhook Event Summary

Subscribe to these events to track all suppression-related activity:
Event TypeTriggerSuppression
message.bounce (hard)Permanent delivery failureAutomatic
message.spam_complaintSpam complaintAutomatic
unsubscribe.list_unsubscribeList-Unsubscribe headerAutomatic
unsubscribe.link_unsubscribeUnsubscribe link clickAutomatic
Configure your webhook to receive these events:
// When creating a webhook, include these event types
const webhookEvents = [
  'message.bounce',
  'message.spam_complaint',
  'unsubscribe.list_unsubscribe',
  'unsubscribe.link_unsubscribe'
];