# Negative engagement triggers sequence exit

- **Pattern:** `ab-000613` (`campaign-orchestration-sequencing.reply-engagement.negative-engagement-exit`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000613
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000613)

## Why it matters

GDPR Art. 21 grants the right to object to processing; GDPR Art. 17 grants the right to erasure; CAN-SPAM Act Sec. 5 requires honoring opt-out requests within 10 business days; CCPA Sec. 1798.120 provides the right to opt out of sale. Each of these rights is violated when an unsubscribe updates only the `contacts` table while active sequence enrollments continue executing. The enrollment record is a separate processing instruction — the system knows it should send the next email and will do so unless the enrollment is explicitly terminated. Updating a contact field is not enough: pending queue jobs already scheduled will execute regardless of any contact-level flag unless the jobs themselves are cancelled.

## Severity rationale

High because continuing sequence sends after an unsubscribe, hard bounce, or spam complaint violates GDPR Art-21, CAN-SPAM Act Sec-5, and CCPA Sec-1798.120 — legal obligations with direct regulatory exposure.

## Remediation

Handle all three negative signal types in a single `handleNegativeSignal` function wired to your ESP's event webhooks. The function must update the contact, exit all active enrollments, and cancel pending jobs — in that order.

```ts
async function handleNegativeSignal(
  contactId: string,
  signal: 'unsubscribe' | 'hard_bounce' | 'spam_complaint'
) {
  await db.contact.update({
    where: { id: contactId },
    data: { subscribed: false, suppressedAt: new Date(), suppressionReason: signal }
  })

  await db.contactSequence.updateMany({
    where: { contactId, status: { in: ['active', 'paused'] } },
    data: { status: 'opted_out', exitedAt: new Date() }
  })

  await cancelAllPendingJobsForContact(contactId)
}
```

Verify that your ESP is configured to deliver unsubscribe, hard bounce, and spam complaint webhooks to your handler. All three must trigger `handleNegativeSignal` — a missing spam complaint handler alone is a compliance gap.

## Detection

- **ID:** `negative-engagement-exit`
- **Severity:** `high`
- **What to look for:** Check whether the system handles negative engagement signals — specifically unsubscribes, spam complaints, and hard bounces — by immediately removing the contact from all active sequences. These events should trigger immediate exit, not just prevent future enrollment. Look for: webhook handlers for unsubscribe events from the ESP, hard bounce event handlers, spam complaint event handlers, and whether these handlers call a sequence exit function for the affected contact. Verify that the ESP webhook receives these events and that the handler does more than just mark the contact as unsubscribed in a contacts table.
- **Pass criteria:** Unsubscribe, hard bounce, and spam complaint events immediately exit the contact from all active sequences. Pending send jobs are cancelled. The contact is not re-enrolled in any future sequences. Count all negative signal types handled — at least 3 must trigger sequence exit (unsubscribe, hard bounce, spam complaint).
- **Fail criteria:** Unsubscribe is recorded in the contacts table but active sequence enrollments remain running. Hard bounces do not exit the contact from sequences. Spam complaints are not handled. Updating a contact field without cancelling pending jobs does not count as pass.
- **Skip (N/A) when:** The project sends only transactional emails where unsubscribes do not apply, or the ESP suppression list automatically prevents sends to unsubscribed addresses.
- **Detail on fail:** `"Unsubscribe webhook updates contact.subscribed = false but does not exit active sequence enrollments — pending steps still execute"` or `"No hard bounce or spam complaint webhook handlers found"`
- **Remediation:** Handle all negative signals with immediate sequence exit:

  ```ts
  async function handleNegativeSignal(
    contactId: string,
    signal: 'unsubscribe' | 'hard_bounce' | 'spam_complaint'
  ) {
    // Update contact record
    await db.contact.update({
      where: { id: contactId },
      data: {
        subscribed: false,
        suppressedAt: new Date(),
        suppressionReason: signal
      }
    })

    // Exit all active sequence enrollments
    await db.contactSequence.updateMany({
      where: { contactId, status: { in: ['active', 'paused'] } },
      data: { status: 'opted_out', exitedAt: new Date() }
    })

    // Cancel pending jobs
    await cancelAllPendingJobsForContact(contactId)
  }
  ```

---

## External references

- gdpr Art. 21
- gdpr Art. 17
- external CAN-SPAM-Act-2003-Sec5 — https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business
- ccpa §1798.120
- eprivacy Art. 13

Taxons: privacy-consent, error-resilience

HTML version: https://auditbuffet.com/patterns/ab-000613
