# List undeliverable recipients (permanent failures + exclusions)

`GET /v1/undeliverable`

- Authentication: required (Bearer token)
- Required scope: `undeliverable.view`

Returns recipients with permanent-failure events (Permanent
bounces, Complaints, or Rejects) in your retention window — plus
operator-cleared addresses as tombstones (`status: excluded`)
so polling callers can keep their own suppression list in sync.

### Classification rules (stable across v1)

A recipient appears on the list with `status: "listed"` when
SendOps has ingested any of the following events for that
address in the org's retention window AND no operator exclusion
with `excluded_at` greater than the address's `last_seen_at` is
in effect:

| Event     | Condition                              | Reason             |
| --------- | -------------------------------------- | ------------------ |
| Bounce    | `bounceType = Permanent` (any subtype) | `permanent_bounce` |
| Complaint | any                                    | `complaint`        |
| Reject    | any                                    | `rejected`         |

Transient bounces, DeliveryDelay, Send/Delivery/Open/Click
events never qualify. Refining this classification is a v2
break — callers may write their suppression logic against the
rules above and rely on them.

### Polling with `?since=`

Callers maintaining their own suppression list should poll with
`?since=<RFC 3339>`, anchored on the largest `last_changed_at`
from their most-recent processed batch. The endpoint returns
every row whose `last_changed_at >= since`, including:

- newly-listed addresses (recent permanent failures)
- re-listed addresses (operator-cleared but bounced again)
- excluded tombstones (operator decided to allow this address)

Apply rows by `status`: add `listed` rows to your local
suppression list, remove `excluded` rows from it. The since
boundary is inclusive on the second so a one-row overlap is
possible — operations are idempotent so this is safe.

### Snapshot vs sync default

With no `since`, the response defaults to `status: "listed"`
only — a clean current-state snapshot. Pass `?since=...` to
receive both listed and excluded rows. The discrimination is
keyed off whether `since` is set; explicit `?status=` overrides
the default.

### Retention caveat

The listed portion is bounded by your plan's retention window.
Exclusion tombstones live in durable storage and surface
regardless of how long ago the address last bounced — a caller
skipping a poll window will still see the tombstone when they
next call with `?since=...`.

Cursors are opaque base64 — stale or malformed cursors fall back
to page 1 silently instead of erroring. The cursor paginates one
snapshot; `?since=` is the real polling mechanism.

## Query parameters

- `limit` (integer, optional) — Page size (1–200). Default 50.
- `cursor` (string, optional) — Opaque cursor returned from the previous page.
- `since` (string<date-time>, optional) — RFC 3339 timestamp. Returns only rows whose
`last_changed_at >= since`. Use the largest `last_changed_at`
from your most-recent processed batch as the next value.
The boundary is inclusive on the second.
- `status` (string enum, optional) — Filter by row status. Default depends on `?since=`: when
`since` is set, both `listed` and `excluded` are returned
(so callers see operator-clearing tombstones); when `since`
is absent, only `listed` rows are returned (snapshot mode).
Pass `all` to override the snapshot default.
- `reason` (string enum, optional) — Filter listed rows by classification reason. Has no effect on
excluded tombstones.

Accepted values (all six are valid query inputs regardless of
whether the corresponding rule is currently enabled for the
org — filtering on a disabled rule simply returns no rows):

- `permanent_bounce` — locked, always on
- `complaint` — locked, always on
- `rejected` — locked, always on
- `repeated_transient` — configurable (SND-713)
- `undetermined` — configurable (SND-713)
- `soft_bounce_accumulation` — configurable (SND-713)

Callers filtering on a configurable reason should confirm via
GET /v1/undeliverable/rules that the rule is enabled before
treating an empty page as "no matches" rather than
"rule off".
- `email` (string, optional) — Case-insensitive substring match on the recipient address.
- `min_events` (integer, optional) — Only return listed rows whose `event_count >= min_events`.
Useful for callers who want "address that bounced ≥ N times"
policies. Default `1` (everything qualifying). Excluded
tombstones are dropped when `min_events > 1`.

## Example request

```bash
curl 'https://api.sendops.dev/v1/undeliverable' \
  -H "Authorization: Bearer $SENDOPS_API_KEY"
```

## Responses

### 200 — Paginated list of undeliverable rows

Content type: `application/json`

```json
{
  "data": [
    {
      "email": "alice@example.com",
      "status": "listed",
      "reason": "permanent_bounce",
      "subtype": "General",
      "first_seen_at": "2026-04-02T08:14:00Z",
      "last_seen_at": "2026-05-11T19:42:00Z",
      "last_changed_at": "2026-05-11T19:42:00Z",
      "event_count": 1,
      "last_diagnostic": "smtp; 550 5.1.1 user unknown",
      "last_sender_identity": "notifications@acme.example"
    },
    {
      "email": "bob@example.com",
      "status": "listed",
      "reason": "complaint",
      "subtype": "abuse",
      "first_seen_at": "2026-05-09T11:02:00Z",
      "last_seen_at": "2026-05-09T11:02:00Z",
      "last_changed_at": "2026-05-09T11:02:00Z",
      "event_count": 1,
      "last_sender_identity": "marketing@acme.example"
    },
    {
      "email": "carol@example.com",
      "status": "listed",
      "reason": "repeated_transient",
      "subtype": "General",
      "first_seen_at": "2026-04-28T07:18:00Z",
      "last_seen_at": "2026-05-15T22:01:00Z",
      "last_changed_at": "2026-05-15T22:01:00Z",
      "event_count": 6,
      "last_diagnostic": "smtp; 421 4.7.0 try again later",
      "last_sender_identity": "notifications@acme.example"
    },
    {
      "email": "dave@example.com",
      "status": "listed",
      "reason": "undetermined",
      "first_seen_at": "2026-05-01T12:30:00Z",
      "last_seen_at": "2026-05-14T16:55:00Z",
      "last_changed_at": "2026-05-14T16:55:00Z",
      "event_count": 4,
      "last_sender_identity": "transactional@acme.example"
    },
    {
      "email": "erin@example.com",
      "status": "excluded",
      "last_changed_at": "2026-05-17T09:10:00Z",
      "excluded_at": "2026-05-17T09:10:00Z",
      "excluded_by": "ops@acme.example",
      "exclusion_reason": "Confirmed valid mailbox, customer asked for re-enable"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### 401 — Missing, malformed, or unknown API key

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 403 — Key lacks the required scope or plan limit violated

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 422 — Query parameter or path value failed validation

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 429 — Per-org rate limit exceeded

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 500 — Unexpected server-side failure. The `code` is `internal_error`. The
`request_id` field can be quoted to SendOps support to investigate.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```
