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.
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.
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.
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.
ID: campaign-orchestration-sequencing.reply-engagement.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:
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)
}