# Audiences and contacts

A named list with subscription state and per-list merge fields, and the import that fills it.

Most of what emails.sh sends is transactional: one email, caused by one thing a person did. An audience is the other case, a list of people you mail together: a product changelog, a release note, an occasional announcement.

You send to one with a broadcast, and you narrow one with a segment. Both of those are their own chapters: /docs/broadcasts and /docs/segments. This page is the list itself, who is on it, and what you know about each of them.

What is deliberately absent is a way to mail people who never asked. Every address here has to have come from somewhere you can point at, imports check the suppression list before they write, and an audience whose complaint rate climbs pauses the workspace. Cold outreach is not a feature we forgot to build.

### Create an audience

POST /v1/audiences:
```bash
curl -X POST https://emails.sh/v1/audiences \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Product updates", "description": "Monthly changelog"}'
```

### Membership, not contact

Two ids matter here and confusing them is the most common mistake against this API. A contact is a person on the workspace and has a contact_id. A membership is that person on one list, and it has its own id. Every route under /v1/audiences/:id/contacts/:member takes the membership id, and nothing there accepts a contact id or an email address.

GET /v1/audiences/:id/contacts, and id is the membership id:
```json
{
  "contact_count": 2,
  "subscribed_count": 1,
  "contacts": [
    {
      "id": "6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480",
      "contact_id": "b4c0e21f-7d69-4a55-8e13-2f9a6c081d77",
      "email": "ada@example.com",
      "status": "subscribed",
      "attributes": { "first_name": "Ada", "plan": "pro" },
      "subscribed_at": "2026-07-02T11:20:04.118Z",
      "unsubscribed_at": null
    },
    {
      "id": "c8a95b70-1e34-4d29-b6f5-0a72e4c31d96",
      "contact_id": "5d3f8a12-6b04-47ce-9a81-cf20e5b7a344",
      "email": "grace@example.com",
      "status": "unsubscribed",
      "attributes": { "first_name": "Grace" },
      "subscribed_at": "2026-06-14T08:02:51.663Z",
      "unsubscribed_at": "2026-07-19T16:45:12.907Z"
    }
  ]
}
```

### Import

POST /v1/audiences/:id/contacts is a bulk import rather than a single add. It takes a bare array, a { "contacts": [] } envelope, a { "data": [] } envelope, or one object, and it takes CSV when the Content-Type says csv. Each row is an email, optional attributes, an optional status, and optional tags. Up to 10000 rows and 8 MB per call, past which you get 413 too_many_contacts or 413 import_too_large.

POST /v1/audiences/:id/contacts:
```bash
curl -X POST https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      {
        "email": "ada@example.com",
        "attributes": { "first_name": "Ada", "plan": "pro" },
        "status": "subscribed",
        "tags": ["beta"]
      },
      {
        "email": "grace@example.com",
        "attributes": { "first_name": "Grace", "plan": "free" },
        "tags": ["beta", "waitlist"]
      }
    ]
  }'
```

201 Created:
```json
{
  "created": 1,
  "updated": 1,
  "unchanged": 0,
  "duplicates": 0,
  "tags_added": 3,
  "imported": 2,
  "held_back": 0,
  "skipped": 0,
  "held": [],
  "errors": []
}
```

created counts memberships this call actually wrote, so it is the number of people the list gained. A row naming somebody who is already a member is counted in duplicates instead, and nothing is written for it: that is what a second spelling of one address in the same file looks like, and it is how you can tell a re-upload added nobody rather than trusting that it did not.

An address already on the suppression list is written as cleaned rather than subscribed and counted in held_back, whatever status the row asked for. That is the whole point: an import cannot resurrect somebody who bounced or complained, and the count tells you it happened instead of hiding it.

Add dry_run=true to the query string to find out what would happen without writing anything. It answers 200 with would_create, would_update, would_leave_unchanged, would_hold_back, the columns it found, and a sample of parsed rows, which is what you want in front of a person before importing a CSV somebody exported from a spreadsheet.

Check a CSV before importing it:
```bash
# A CSV whose columns are not our names. Map them in the query string.
curl -X POST "https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts?dry_run=true&mapping.email=Email%20Address&mapping.attr.First%20Name=first_name&mapping.ignore=Internal%20Notes" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @subscribers.csv
```

- **mapping.email**: Which column holds the address. Required when no column is called email or email_address.
- **mapping.status**: Which column holds the subscription state.
- **mapping.tags**: Which column holds tags, as a comma-separated string.
- **mapping.attr.<column>**: Store that column as an attribute under the name you give. Repeat it once per column.
- **mapping.ignore**: Drop a column entirely. Repeat it once per column.

### Attributes are merge fields here

The attributes on a membership are per-list merge fields, and they are what a broadcast substitutes into {{ first_name }}. They are a different store from the workspace-level contact attributes at /v1/contacts/:id/attributes, which is what segments and automations read. /docs/contact-data is the chapter about the difference, and it is worth reading before you decide where to put a field.

### Unsubscribes

Record an unsubscribe rather than deleting the membership. A deleted row can be re-added by the next import and mailed again, which is how a company ends up in a complaint report. A membership set to unsubscribed stays unsubscribed, and DELETE on a membership records nothing.

PATCH the membership, not the contact:
```bash
curl -X PATCH https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts/6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subscribed": false}'
```

A broadcast filed under a topic gets its List-Unsubscribe header and its one-click opt-out built for it. If you are sending to an audience some other way, put the header on yourself: Gmail and Yahoo both require one-click unsubscribe on bulk mail, and the alternative to a working link is the spam button.

### Statuses

| Status | What it means |
| --- | --- |
| subscribed | Mailable. The only status a broadcast sends to. |
| pending | Added under double opt-in and has not confirmed yet. Not mailed except by the confirmation itself. See /docs/double-opt-in. |
| unsubscribed | They asked to stop. Kept as a row so a later import cannot undo it. |
| cleaned | The address bounced, complained, or was already suppressed when it was imported. Not mailable, and not something an import can change back. |

- `GET /v1/audiences` Audiences on the workspace.
- `POST /v1/audiences` { name, description? } creates one.
- `GET /v1/audiences/:id` One audience, with its double opt-in setting and where that setting came from.
- `PATCH /v1/audiences/:id` { name?, description?, require_double_opt_in?, confirmation_subject?, confirmation_body? }
- `DELETE /v1/audiences/:id` Soft delete an audience.
- `GET /v1/audiences/:id/contacts` Members. ?status=&limit=&offset=. id on each row is the membership id.
- `POST /v1/audiences/:id/contacts` Bulk import of { email, attributes?, status?, tags? } rows, as JSON or CSV. ?dry_run=true reports without writing.
- `GET /v1/audiences/:id/contacts/:member` One membership, by membership id.
- `PATCH /v1/audiences/:id/contacts/:member` { attributes?, subscribed?, status? } on one membership.
- `DELETE /v1/audiences/:id/contacts/:member` Remove one membership. It records no unsubscribe.
- `POST /v1/audiences/:id/contacts/:member/confirm` Send the double opt-in confirmation email. Nothing else ever sends it.

#### `audiences.list`

`{ }`

Audiences on the workspace. An audience is a named list of contacts with their subscription state.

Returns: { audiences: Audience[] }

#### `audiences.create`

`{ name: string }`

Create an audience.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |

Returns: { id, name }

#### `audiences.members`

`{ audience_id: string, status?: string, limit?: number, offset?: number }`

Members of one audience. Each row carries a membership id, which is the id every other member route takes, and a contact_id, which is the workspace-level contact behind it.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| status | `string` | no |
| limit | `number` | no |
| offset | `number` | no |

Returns: { contact_count, subscribed_count, contacts: Member[] }

#### `audiences.import`

`{ audience_id: string, contacts: { email, attributes?, status?, tags? }[], dry_run?: boolean }`

Bulk import into an audience. Up to 10000 rows and 8 MB per call. An address already on the suppression list lands as cleaned rather than subscribed and is counted in held_back.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| contacts | `{ email, attributes?, status?, tags? }[]` | yes |
| dry_run | `boolean` | no |

Returns: { created, updated, unchanged, duplicates, tags_added, imported, held_back, skipped, held[], errors[] }

#### `audiences.updateMember`

`{ audience_id: string, member: string, attributes?: Record<string, string>, subscribed?: boolean, status?: string }`

Change one membership. Recording an unsubscribe here is what keeps the address from being mailed by the next broadcast.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| member | `string` | yes |
| attributes | `Record<string, string>` | no |
| subscribed | `boolean` | no |
| status | `string` | no |

Returns: Member

#### `audiences.removeMember`

`{ audience_id: string, member: string }`

Remove one membership from an audience. It does not delete the contact, and it does not record an unsubscribe: set status to unsubscribed for that.

| Parameter | Type | Required |
| --- | --- | --- |
| audience_id | `string` | yes |
| member | `string` | yes |

Returns: { deleted: id }

Sending to an audience is POST /v1/broadcasts/:id/send. Sending one transactional message is still POST /v1/emails, and the two are separate on purpose: a broadcast checks topics, suppression, and segment membership per recipient before anything leaves.

---

Base URL: https://emails.sh/v1. Auth: `Authorization: Bearer esh_...`.
Whole API in one file: https://emails.sh/llms.txt. All documentation: https://emails.sh/docs.md.
