> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Email suppression event payloads: worked examples

> Every opt-out signal Orbit emits for email — one-click unsubscribe, bounce-driven and complaint-driven suppression, and re-subscription — with full webhook payloads, signature-verifying handlers that update your contacts, the persisted GET /api/v1/email/suppressions response, and the end-to-end sequence from bounce to suppressed send.

# Email suppression event payloads: worked examples

When a recipient unsubscribes, bounces, or files a spam complaint, Orbit writes the address to your tenant's email suppression list and emits a subscription event to your webhook endpoints. This guide gives you the exact payload for each event, handlers that verify the signature and act on your contact records, and the persisted shape you read back from `GET /api/v1/email/suppressions`.

For the conceptual model (what the suppression list fences, channel scoping, the three entry points), read [Message suppression model](/concepts/message-suppression-model) and [Choose your suppression entry point](/guides/suppression-three-entry-points). This page is the payload-and-handler companion.

***

## 1. Event taxonomy recap

Four subscription events cover the email opt-out lifecycle. All four are delivered to your webhook endpoints with the same HMAC signing and retry policy as delivery-status events.

| Event | Fires when | Effect on the suppression list |
| - | - | - |
| `email.notification-subscribed` | A recipient re-subscribes (preference center or one-click re-subscribe) | Clears a recoverable row and restores the contact's email preference |
| `email.notification-unsubscribed` | A recipient clicks one-click unsubscribe (List-Unsubscribe header or footer link) | Address is fenced from future email sends; recoverable if the recipient later re-subscribes |
| `email.notification-unsubscribed-by-bounce` | A hard bounce returns for the address | Terminal suppression row, reason `hard_bounce` |
| `email.notification-unsubscribed-by-complaint` | A mailbox provider reports a spam complaint | Terminal suppression row, reason `complaint` |

Two reasons are terminal per bulk-sender convention and should never be re-enabled: `hard_bounce` and `complaint`. Orbit also tracks `soft_bounce_exhausted` (an address that crossed the consecutive-soft-bounce threshold) and `manual` (rows you added yourself, including one-click unsubscribes you mirror in). You can pass any of these to the `reason` filter below.

<Note>
  Suppression rows and subscription events are **tenant-owned controls**: Orbit enforces the send gate on your behalf, but the decision to re-enable an address, and the downstream update to your own contact records, is yours. This guide is not legal advice.
</Note>

***

## 2. Sample payloads

Every payload carries a top-level `type` (the event name), a monotonically useful `created_at`, and a `data` block with the affected recipient plus the delivery-status body that caused the event, where one exists.

### `email.notification-unsubscribed`

A one-click unsubscribe. The delivery-status body records the unsubscribe mechanism.

```json theme={null}
{
  "id": "evt_01J9K2M4N8P6Q1R3S5T7V9W0X2",
  "type": "email.notification-unsubscribed",
  "created_at": "2026-10-12T09:41:22Z",
  "channel": "email",
  "data": {
    "message_id": "msg_01J9J8H3K6M2N4P7Q9R1S3T5V7",
    "recipient": "jordan.reeves@example.com",
    "sender": "news@yourbrand.devotel.io",
    "delivery_status": {
      "status": "unsubscribed",
      "mechanism": "one-click",
      "occurred_at": "2026-10-12T09:41:20Z"
    },
    "suppression": {
      "written": true,
      "reason": "manual",
      "terminal": false
    }
  }
}
```

### `email.notification-subscribed`

A re-subscription. No delivery-status body applies; the event carries the restored preference.

```json theme={null}
{
  "id": "evt_01J9K5Q7R2S4T6V8W1X3Y5Z7A9",
  "type": "email.notification-subscribed",
  "created_at": "2026-10-12T14:03:51Z",
  "channel": "email",
  "data": {
    "recipient": "jordan.reeves@example.com",
    "source": "preference-center",
    "suppression": {
      "row_removed": true,
      "previous_reason": "manual"
    }
  }
}
```

### `email.notification-unsubscribed-by-bounce`

A hard bounce. The delivery-status body carries the provider's bounce classification.

```json theme={null}
{
  "id": "evt_01J9K8T1V3W5X7Y9Z2A4B6C8D1",
  "type": "email.notification-unsubscribed-by-bounce",
  "created_at": "2026-10-12T16:27:05Z",
  "channel": "email",
  "data": {
    "message_id": "msg_01J9J8H3K6M2N4P7Q9R1S3T5V7",
    "recipient": "mailbox-full-forever@example.net",
    "sender": "news@yourbrand.devotel.io",
    "delivery_status": {
      "status": "bounced",
      "reason": "hard_bounce",
      "provider_code": "550",
      "provider_description": "5.1.1 The email account that you tried to reach does not exist",
      "occurred_at": "2026-10-12T16:27:02Z"
    },
    "suppression": {
      "written": true,
      "reason": "hard_bounce",
      "terminal": true
    }
  }
}
```

### `email.notification-unsubscribed-by-complaint`

A spam complaint. Terminal; do not re-enable these addresses.

```json theme={null}
{
  "id": "evt_01J9KBW4X6Y8Z1A3B5C7D9E2F4",
  "type": "email.notification-unsubscribed-by-complaint",
  "created_at": "2026-10-12T18:52:40Z",
  "channel": "email",
  "data": {
    "message_id": "msg_01J9JCK1M3N5P7Q9R2S4T6V8",
    "recipient": "complained@example.org",
    "sender": "news@yourbrand.devotel.io",
    "delivery_status": {
      "status": "complained",
      "reason": "complaint",
      "feedback_type": "abuse",
      "occurred_at": "2026-10-12T18:52:37Z"
    },
    "suppression": {
      "written": true,
      "reason": "complaint",
      "terminal": true
    }
  }
}
```

***

## 3. Read the suppression list

`GET /api/v1/email/suppressions` returns the persisted rows for your tenant, newest first. It accepts `limit`, `offset`, `search` (substring match on the address), and `reason`.

```bash theme={null}
curl -s "https://api.orbit.devotel.io/api/v1/email/suppressions?limit=50&offset=0&reason=hard_bounce" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

```json theme={null}
{
  "data": [
    {
      "id": "esup_01J9K9V2W4X6Y8Z1A3B5C7D9",
      "email": "mailbox-full-forever@example.net",
      "channel": "email",
      "reason": "hard_bounce",
      "terminal": true,
      "created_at": "2026-10-12T16:27:05Z"
    }
  ],
  "limit": 50,
  "offset": 0,
  "total": 1
}
```

Use `search` to check one address before a send, and pair the read with the event handlers below so your own contact store never drifts from the ledger Orbit gates on.

***

## 4. Verification handlers

All three handlers share one verifier. Orbit signs every delivery with HMAC-SHA256 in the `X-Orbit-Signature` header, formatted `t=<unix_seconds>,v1=<hex hmac>` over the raw request body. Reject anything older than five minutes and anything whose `v1` does not match.

```python theme={null}
import hashlib
import hmac
import os
import time

WEBHOOK_SECRET = os.environ["ORBIT_WEBHOOK_SECRET"].encode()

def verified(raw_body: bytes, header: str) -> bool:
    """Return True when the X-Orbit-Signature header matches the raw body."""
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts, sig = int(parts["t"]), parts["v1"]
    if abs(time.time() - ts) > 300:
        return False  # replay window
    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
```

### Handler 1 — one-click unsubscribe: unsubscribe the contact

```python theme={null}
import json

def handle_unsubscribe(raw_body: bytes, header: str) -> str:
    if not verified(raw_body, header):
        return "401"
    event = json.loads(raw_body)
    if event["type"] != "email.notification-unsubscribed":
        return "204"  # not this handler's event
    recipient = event["data"]["recipient"]
    contacts.set_email_preference(recipient, "unsubscribed",
                                 source="one-click",
                                 event_id=event["id"])
    return "200"
```

### Handler 2 — bounce: downgrade, and unsubscribe on a terminal bounce

Soft bounces recover; a single soft bounce should downgrade the contact's engagement tier, not remove them. A `hard_bounce` (or `soft_bounce_exhausted`) unsubscribes the contact to match the terminal row Orbit wrote.

```python theme={null}
def handle_bounce(raw_body: bytes, header: str) -> str:
    if not verified(raw_body, header):
        return "401"
    event = json.loads(raw_body)
    if event["type"] != "email.notification-unsubscribed-by-bounce":
        return "204"
    reason = event["data"]["delivery_status"]["reason"]
    recipient = event["data"]["recipient"]
    if reason in ("hard_bounce", "soft_bounce_exhausted"):
        contacts.set_email_preference(recipient, "unsubscribed",
                                     source="bounce", reason=reason,
                                     event_id=event["id"])
    else:
        contacts.downgrade_engagement_tier(recipient, event_id=event["id"])
    return "200"
```

### Handler 3 — complaint: unsubscribe and mark do-not-recontact

Complaints are the strongest negative signal a mailbox provider can send. Unsubscribe the contact and flag the record so no other channel re-adds it.

```python theme={null}
def handle_complaint(raw_body: bytes, header: str) -> str:
    if not verified(raw_body, header):
        return "401"
    event = json.loads(raw_body)
    if event["type"] != "email.notification-unsubscribed-by-complaint":
        return "204"
    recipient = event["data"]["recipient"]
    contacts.set_email_preference(recipient, "unsubscribed",
                                 source="complaint",
                                 do_not_recontact=True,
                                 event_id=event["id"])
    return "200"
```

### Replay a payload locally

Stamp a captured payload with your test secret and POST it at your handler, exactly as Orbit would:

```bash theme={null}
# stamp
SIG=$(python - <<'EOF'
import hashlib, hmac, os, time
body = open("complaint.json", "rb").read()
t = int(time.time())
v1 = hmac.new(os.environ["ORBIT_WEBHOOK_SECRET"].encode(), body, hashlib.sha256).hexdigest()
print(f"t={t},v1={v1}")
EOF
)

# replay
curl -s -X POST http://localhost:8080/webhooks/orbit \
  -H "Content-Type: application/json" \
  -H "X-Orbit-Signature: $SIG" \
  --data @complaint.json
```

Expect `200` and the contact record updated; a tampered body or a stale `t=` must return `401`.

***

## 5. Sequence: send → complaint → suppression write → contact update

```mermaid theme={null}
sequenceDiagram
    participant You as Your backend
    participant API as Orbit email API
    participant Provider as Mailbox provider
    participant WH as Your webhook handler
    API->>Provider: message accepted and sent
    Provider-->>API: complaint feedback loop report
    API->>API: write suppression row (reason: complaint, terminal)
    API->>WH: email.notification-unsubscribed-by-complaint
    WH->>WH: verify X-Orbit-Signature, parse event.type
    WH->>You: unsubscribe contact, mark do-not-recontact
    You->>API: GET /api/v1/email/suppressions?search=<address>
    API-->>You: row present, reason complaint
    Note over API: subsequent sends to the address are fenced at the gate
```

The same sequence applies to bounces (`reason: hard_bounce`) and one-click unsubscribes; only the triggering delivery-status body differs.

***

## 6. Opt-out alias table

Recipients express opt-outs differently per channel. In SMS and WhatsApp they type keywords; in email they click or complain. This table maps the intent you already handle on messaging channels to its email equivalent and the event your handler receives.

| Recipient intent | Messaging keywords (any language) | Email equivalent signal | Event you receive |
| - | - | - | - |
| Opt out of this sender | `STOP`, `UNSUBSCRIBE`, `QUIT`, `CANCEL`, `UNSTOP` | One-click unsubscribe | `email.notification-unsubscribed` |
| Opt out of everything, forcefully | `STOPALL`, `STOP ALL` | Spam complaint | `email.notification-unsubscribed-by-complaint` |
| Address is dead (no recipient action) | none; the carrier bounces it | Hard bounce | `email.notification-unsubscribed-by-bounce` |
| Opt back in | `START`, `YES`, `UNBLOCK` | Preference-center re-subscribe | `email.notification-subscribed` |

Unlike keyword opt-outs, email events need no keyword normalization on your side: the event type *is* the normalized intent. Treat `email.notification-unsubscribed-by-complaint` as `STOPALL` semantics in your contact store (unsubscribe everywhere, do not re-add), and `email.notification-unsubscribed` as `STOP` semantics (email only).

***

## 7. FAQ

### Does a suppression row block all of my senders?

Yes, within your tenant. The list gates every email send you make regardless of sender address or domain, because reputation attaches to your sending identity as a whole. It does not affect SMS or WhatsApp unless you also suppress those channels.

### Can I re-enable a suppressed address?

It depends on the reason. Rows with reason `manual` or `soft_bounce_exhausted` are recoverable: delete the row with `DELETE /api/v1/email/suppressions/{id}` and the next send is allowed. Rows with reason `hard_bounce` or `complaint` are terminal by bulk-sender convention; re-enabling them harms your sender reputation and is strongly discouraged. A later `email.notification-subscribed` event clears a recoverable row automatically.

### I handle unsubscribes in my own system. Do I still need these events?

Yes. Your system records the preference; Orbit's list enforces the gate at send time. Wiring the handlers above keeps the two in lockstep, and the complaint and bounce events carry information your own system can never observe directly.

### Why did an address appear in the list with no event of mine?

Rows you create through the console, the CSV import, or the Consent API write the same ledger without emitting a subscription event, as do bounce and complaint rows Orbit derives from provider feedback. Use `GET /api/v1/email/suppressions?search=<address>` to inspect the reason and `created_at` when reconciling.

### Do these events retry if my endpoint is down?

Yes, with the same retry schedule as delivery-status events. Handlers must be idempotent: key your contact update on `data.recipient` (or the event `id` if you dedupe) so a redelivered event produces the same contact state.

***

## Related

* [Email channel](/channels/email) — sending, DNS, and the List-Unsubscribe posture that drives one-click events.
* [Webhook events](/webhooks/events) — the full event catalog and delivery semantics.
* [Email bounces and complaints](/troubleshooting/email-bounces-complaints) — diagnosing bounce spikes and complaint-rate thresholds.
* [Troubleshooting hub](/reference/troubleshooting-hub) — start here when a send failed for a reason you do not recognize.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.