# Subscribers

> List, count, add or update, read, change, unsubscribe, delete, forget, activity.

Reading needs Subscribers: view, writing Subscribers: manage ([Authentication](https://docs.uptools.io/echo/developer-guide/authentication/)).

## The subscriber object

```json
{
  "id": "d5f0b4b1-b59d-40eb-b2e0-74857893a055",
  "email": "lilla.farkas@example.com",
  "status": "unconfirmed",
  "source": "api",
  "fields": {"city": "Szeged", "last_name": "Farkas", "name": "Lilla", "rendelesek_szama": 3},
  "groups": [{"id": "257e83a2-d55e-4a8c-b9a4-b3e939c151cc", "name": "Webshop vásárlók"}],
  "signup_ip": "203.0.113.25",
  "optin_ip": null,
  "subscribed_at": "2026-10-22T08:52:08.527488Z",
  "opted_in_at": null,
  "unsubscribed_at": null,
  "timezone": null,
  "location_country": null,
  "location_city": null,
  "sent": 0,
  "opens": 0,
  "clicks": 0,
  "open_rate": 0,
  "click_rate": 0,
  "created_at": "2026-10-22T08:52:08.527488Z",
  "updated_at": "2026-10-22T08:52:08.527488Z"
}
```

| Key | Meaning |
|---|---|
| `status` | `active`, `unsubscribed`, `unconfirmed` (double opt-in pending), `bounced` or `junk` (spam complaint). |
| `source` | How the subscriber was added: `manual`, `import`, `api`, `form`, `landing_page`, `integration` or `preference_center`. |
| `fields` | Values by field key ([fields](https://docs.uptools.io/echo/developer-guide/groups-segments-fields/#fields)). Number fields are numbers, date fields `YYYY-MM-DD` strings. Empty fields are left out. |
| `groups` | The groups the subscriber belongs to. |
| `signup_ip`, `optin_ip` | The sign-up and the confirmation IP address, if known. |
| `sent`, `opens`, `clicks`, `open_rate`, `click_rate` | Email statistics; the rates are percentages. |
| `timezone`, `location_country`, `location_city` | Detected from the subscriber's activity, if known. |

## List subscribers

`GET /subscribers`

| Parameter | Meaning |
|---|---|
| `filter[status]` | `active` (default), `unsubscribed`, `unconfirmed`, `bounced`, `junk` or `all`. |
| `filter[group]` | A group ID: only its members. |
| `limit`, `cursor` | Paging, newest subscriber first ([Pagination](https://docs.uptools.io/echo/developer-guide/quickstart/#pagination)). |

```bash
curl -g -H "Authorization: Bearer $ECHO_TOKEN" \
  "$ECHO_API/subscribers?filter[group]=257e83a2-d55e-4a8c-b9a4-b3e939c151cc&limit=1"
```

```json
{"data":[{"id":"98cc16c4-cb25-4ec6-8dbe-9704c4e198c4","email":"reka.toth@example.org","status":"active","source":"import","fields":{"name":"Réka","rendelesek_szama":5},"groups":[{"id":"257e83a2-d55e-4a8c-b9a4-b3e939c151cc","name":"Webshop vásárlók"}],"signup_ip":null,"optin_ip":null,"subscribed_at":"2026-10-22T08:52:08.762873Z","opted_in_at":null,"unsubscribed_at":null,"timezone":null,"location_country":null,"location_city":null,"sent":0,"opens":0,"clicks":0,"open_rate":0,"click_rate":0,"created_at":"2026-10-22T08:52:08.762873Z","updated_at":"2026-10-22T08:52:08.762873Z"}],"meta":{"next_cursor":"ATFli9bJvH7vgqoL19WIaHd7P2ajK_3_gc-avJBnE93Vz7-BggnUFyd3INX0aogupEIO9GInVy69os7dJEXCtecC72rG","prev_cursor":null,"limit":1,"total":2}}
```

The same list for one group or segment is also at `GET /groups/{id}/subscribers` and `GET /segments/{id}/subscribers` (with `filter[status]`, `limit`, `cursor`).

The next page comes with `cursor` set to the `next_cursor`. Its `prev_cursor` leads back, also through `cursor` (a subscriber list's `prev_cursor` starts with `b.`; pass it as it is):

```bash
curl -g -H "Authorization: Bearer $ECHO_TOKEN" \
  "$ECHO_API/subscribers?filter[group]=257e83a2-d55e-4a8c-b9a4-b3e939c151cc&limit=1&cursor=ATFli9bJvH7v..."
```

```json
{"data":[...],"meta":{"next_cursor":null,"prev_cursor":"b.ATFli9bJvH7vuyCx2u4V-jDq5wBEHY5rFldF82H_JvI92h0J4F4OC4I6KKepvM_8mmxMtw6iIl72qgodfvxCaS2-bJj7","limit":1,"total":2}}
```

A very large, slow filter can answer `503 list_timeout`: narrow the filter and try again.

## Count subscribers

`GET /subscribers/count` takes the same `filter[status]` and `filter[group]`.

```bash
curl -g -H "Authorization: Bearer $ECHO_TOKEN" "$ECHO_API/subscribers/count?filter[status]=unsubscribed"
```

```json
{"data":{"total":193}}
```

`total` is `null` when counting took too long.

## Add or update a subscriber

`POST /subscribers` adds a subscriber, or updates the one that already has this email address.

| Key | Required | Meaning |
|---|---|---|
| `email` | yes | The address. It is trimmed and lower-cased. |
| `fields` | no | Values by field key. `null` clears a value; an empty string leaves the stored value as it is. |
| `groups` | no | Group IDs to add the subscriber to. Groups are only added, never removed. |
| `ip_address` | no | The sign-up IP address, stored for a new subscriber. |

```bash
curl -X POST "$ECHO_API/subscribers" \
  -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \
  -d '{"email":"lilla.farkas@example.com","fields":{"name":"Lilla","last_name":"Farkas","city":"Szeged","rendelesek_szama":3},"groups":["257e83a2-d55e-4a8c-b9a4-b3e939c151cc"],"ip_address":"203.0.113.25"}'
```

`201 Created` for a new subscriber:

```json
{"data":{"id":"d5f0b4b1-b59d-40eb-b2e0-74857893a055","email":"lilla.farkas@example.com","status":"unconfirmed","source":"api","fields":{"city":"Szeged","last_name":"Farkas","name":"Lilla","rendelesek_szama":3},"groups":[{"id":"257e83a2-d55e-4a8c-b9a4-b3e939c151cc","name":"Webshop vásárlók"}],"signup_ip":"203.0.113.25","optin_ip":null,"subscribed_at":"2026-10-22T08:52:08.527488Z","opted_in_at":null,"unsubscribed_at":null,"timezone":null,"location_country":null,"location_city":null,"sent":0,"opens":0,"clicks":0,"open_rate":0,"click_rate":0,"created_at":"2026-10-22T08:52:08.527488Z","updated_at":"2026-10-22T08:52:08.527488Z"}}
```

Sending the same address again updates it and answers `200 OK`. Only the given fields change:

```bash
curl -X POST "$ECHO_API/subscribers" \
  -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \
  -d '{"email":"lilla.farkas@example.com","fields":{"vasarloi_szint":"Ezüst"}}'
```

```json
{"data":{"id":"d5f0b4b1-b59d-40eb-b2e0-74857893a055","email":"lilla.farkas@example.com","status":"unconfirmed","source":"api","fields":{"city":"Szeged","last_name":"Farkas","name":"Lilla","rendelesek_szama":3,"vasarloi_szint":"Ezüst"},"groups":[{"id":"257e83a2-d55e-4a8c-b9a4-b3e939c151cc","name":"Webshop vásárlók"}],"signup_ip":"203.0.113.25","optin_ip":null,"subscribed_at":"2026-10-22T08:52:08.527488Z","opted_in_at":null,"unsubscribed_at":null,"timezone":null,"location_country":null,"location_city":null,"sent":0,"opens":0,"clicks":0,"open_rate":0,"click_rate":0,"created_at":"2026-10-22T08:52:08.527488Z","updated_at":"2026-10-22T08:52:08.568881Z"}}
```

**Status of a new subscriber.** If double opt-in is on for API sign-ups in the workspace's subscribe settings, a new subscriber starts `unconfirmed` and gets the confirmation email; after confirming they become `active`. If it's off, they start `active`. An existing subscriber keeps their status: the API never resubscribes someone who unsubscribed.

**Refused addresses** answer `422 validation` with the reason in `fields.email`:

| Case | `fields.email` (English message) |
|---|---|
| Not a valid address | `Enter a valid email address.` |
| A role address such as abuse@ or postmaster@ | `Role addresses such as abuse@ or postmaster@ can't be added.` |
| On the suppression list (bounced or spam complaint before) | `This address is on the suppression list because it bounced or reported spam.` |

```json
{"error":{"code":"validation","message":"Please check the highlighted fields.","fields":{"email":"This address is on the suppression list because it bounced or reported spam."}}}
```

Other field errors name the key with a `fields.` prefix:

```json
{"error":{"code":"validation","message":"Please check the highlighted fields.","fields":{"fields.rendelesek_szama":"Enter a number, e.g. 12 or 3.5."}}}
```

```json
{"error":{"code":"validation","message":"Please check the highlighted fields.","fields":{"fields.kedvenc_szin":"Unknown field."}}}
```

An unknown group ID: `{"fields":{"groups":"Choose an existing group."}}`. An unknown top-level key (for example `tags`): `{"fields":{"tags":"Unknown field."}}`.

## Get a subscriber

`GET /subscribers/{id}` takes the subscriber's ID or email address (URL-encode the `@` as `%40` if your client needs it):

```bash
curl -H "Authorization: Bearer $ECHO_TOKEN" "$ECHO_API/subscribers/lilla.farkas@example.com"
```

The answer is `{"data": <subscriber>}`, or `404 not_found`:

```json
{"error":{"code":"not_found","message":"Not found."}}
```

Only `GET` takes an email address; the other subscriber endpoints need the ID.

## Change a subscriber

`PUT /subscribers/{id}` changes field values and adds groups in one transaction. Every key is optional.

| Key | Meaning |
|---|---|
| `fields` | Values by field key; `null` clears a value. |
| `groups` | Group IDs to add (never removed). |
| `status` | Only `unsubscribed` ([below](#unsubscribe-a-subscriber)). |

```bash
curl -X PUT "$ECHO_API/subscribers/d5f0b4b1-b59d-40eb-b2e0-74857893a055" \
  -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \
  -d '{"fields":{"city":"Pécs","company":null}}'
```

```json
{"data":{"id":"d5f0b4b1-b59d-40eb-b2e0-74857893a055","email":"lilla.farkas@example.com","status":"unconfirmed","source":"api","fields":{"city":"Pécs","last_name":"Farkas","name":"Lilla","rendelesek_szama":3,"vasarloi_szint":"Ezüst"},"groups":[{"id":"257e83a2-d55e-4a8c-b9a4-b3e939c151cc","name":"Webshop vásárlók"}],"signup_ip":"203.0.113.25","optin_ip":null,"subscribed_at":"2026-10-22T08:52:08.527488Z","opted_in_at":null,"unsubscribed_at":null,"timezone":null,"location_country":null,"location_city":null,"sent":0,"opens":0,"clicks":0,"open_rate":0,"click_rate":0,"created_at":"2026-10-22T08:52:08.527488Z","updated_at":"2026-10-22T08:52:10.212948Z"}}
```

The email address can't be changed through the API.

## Unsubscribe a subscriber

```bash
curl -X PUT "$ECHO_API/subscribers/d5f0b4b1-b59d-40eb-b2e0-74857893a055" \
  -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \
  -d '{"status":"unsubscribed"}'
```

The answer is the subscriber with `"status":"unsubscribed"` and `unsubscribed_at` set. Any other status is refused, because only the subscriber can give consent again:

```json
{"error":{"code":"validation","message":"Please check the highlighted fields.","fields":{"status":"Through the API a subscriber can only be unsubscribed."}}}
```

## Add to a group, remove from a group

```bash
curl -X POST "$ECHO_API/subscribers/d5f0b4b1-b59d-40eb-b2e0-74857893a055/groups/257e83a2-d55e-4a8c-b9a4-b3e939c151cc" \
  -H "Authorization: Bearer $ECHO_TOKEN"
```

`200 OK` with the subscriber. Adding an existing member changes nothing.

```bash
curl -X DELETE "$ECHO_API/subscribers/d5f0b4b1-b59d-40eb-b2e0-74857893a055/groups/257e83a2-d55e-4a8c-b9a4-b3e939c151cc" \
  -H "Authorization: Bearer $ECHO_TOKEN"
```

`204 No Content`. Adding to a group can start an automation workflow whose trigger is joining that group.

## Delete a subscriber

```bash
curl -X DELETE "$ECHO_API/subscribers/8273ff7d-cb69-4a72-83e6-dbebbec33115" -H "Authorization: Bearer $ECHO_TOKEN"
```

`204 No Content`. The subscriber is removed; their past events stay in the campaign statistics without the person. Echo no longer knows that a deleted address unsubscribed, so adding the address again later makes it a new subscriber. To stop emails but keep the record, unsubscribe instead.

## Forget a subscriber (GDPR)

```bash
curl -X POST "$ECHO_API/subscribers/98cc16c4-cb25-4ec6-8dbe-9704c4e198c4/forget" -H "Authorization: Bearer $ECHO_TOKEN"
```

`204 No Content`, and the erasure is complete when the answer arrives: the subscriber, their notes and memberships and the personal details of their events are deleted. The address may be added again later as a new subscriber. A `subscriber.deleted` webhook for a forgotten subscriber carries only the ID ([Webhooks](https://docs.uptools.io/echo/developer-guide/webhooks/)).

## A subscriber's activity

`GET /subscribers/{id}/activity` lists the subscriber's events, newest first, 50 a page. It pages forward only: pass `next_cursor` in `cursor`; `prev_cursor` is always `null`.

| `filter` | Events |
|---|---|
| `all` (default) | Everything |
| `campaign_emails`, `automation_emails` | Campaign or workflow emails sent |
| `opens`, `clicks` | Opens, clicks |
| `bounces`, `soft_bounces`, `junks` | Permanent bounces, soft bounces, spam complaints |
| `unsubscribes`, `forwards` | Unsubscribes, forwards |
| `marketing_preferences`, `preference_center` | Preference changes |

```bash
curl -H "Authorization: Bearer $ECHO_TOKEN" \
  "$ECHO_API/subscribers/1022eacd-0544-4c99-9b38-c678933ed6b9/activity?filter=clicks"
```

```json
{"data":[{"event_id":"87949","type":"click","source_kind":"campaign","occurred_at":"2026-10-01T08:25:03.153043Z","data":{"link":1,"ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"},"campaign":{"id":"522e9156-b62c-45df-8fa8-4d93715379fa","name":"VIP: őszi zártkörű vásár"},"automation":null},{"event_id":"87948","type":"click","source_kind":"campaign","occurred_at":"2026-10-01T08:24:03.153043Z","data":{"link":1,"ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"},"campaign":{"id":"522e9156-b62c-45df-8fa8-4d93715379fa","name":"VIP: őszi zártkörű vásár"},"automation":null},{"event_id":"51770","type":"click","source_kind":"campaign","occurred_at":"2026-08-06T07:38:17.436946Z","data":{"link":1,"ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"},"campaign":{"id":"d5ca5c93-93d3-49ad-8d0f-3a069952faee","name":"Őszi felmérés: mit ültetnél?"},"automation":null}],"meta":{"next_cursor":null,"prev_cursor":null,"limit":50}}
```

`type` is one of `sent`, `delivered`, `open`, `click`, `bounce_hard`, `bounce_soft`, `complaint`, `unsubscribe`, `forward`, `preference_change`, `preference_center`, `optin_sent`, `preference_email`. `source_kind` is `campaign`, `automation` or `null`. `data` depends on the type (for a click: the link's number in the email and the user agent) and may gain keys later, so read it leniently. `campaign` and `automation` name the campaign or the workflow whose email the event belongs to (`id` and `name`), or are `null` when there is none or it has been deleted since.

A malformed or expired cursor answers `422` with `fields.cursor`:

```json
{"error":{"code":"validation","message":"Please check the highlighted fields.","fields":{"cursor":"Invalid value."}}}
```
