# Segments

A saved filter over contacts, computed when it is read, that narrows an audience before a broadcast goes.

A segment is a set of rules, not a stored list. Membership is computed when you ask, so a segment is never stale and nothing has to run overnight to refresh it. Point a broadcast at one with segment_id and the send goes to the intersection of the audience and the rules.

A segment either belongs to an audience or to the workspace. One with an audience_id can ask about membership state and join dates; one without cannot, because outside an audience there is nothing for those questions to mean.

### Create one

POST /v1/segments:
```bash
curl -X POST https://emails.sh/v1/segments \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Active pro users who never clicked",
    "audience_id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
    "match": "all",
    "rules": [
      { "field": "status", "op": "eq", "value": "subscribed" },
      { "field": "attribute", "op": "eq", "name": "plan", "value": "pro" },
      { "field": "tag", "op": "not_has", "value": "churned" },
      { "field": "clicked", "op": "never" }
    ]
  }'
```

201 Created:
```json
{
  "id": "7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2",
  "name": "Active pro users who never clicked",
  "description": null,
  "audience_id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
  "match": "all",
  "rules": [
    { "field": "status", "op": "eq", "value": "subscribed" },
    { "field": "attribute", "op": "eq", "name": "plan", "value": "pro" },
    { "field": "tag", "op": "not_has", "value": "churned" },
    { "field": "clicked", "op": "never" }
  ],
  "describes": "subscribed, plan is pro, not tagged churned, and never clicked",
  "member_count": 412,
  "counted_at": "2026-07-28T09:14:01.882Z",
  "created_at": "2026-07-28T09:14:01.882Z"
}
```

describes is the rule list as a sentence, generated from the rules rather than typed by anybody. Show it next to a segment in your own UI and a person can check the filter without reading JSON.

### The rule grammar

match is all or any, up to 20 rules, and there is no nesting. That ceiling is not an oversight: a filter that needs a nested boolean is a query, and a query belongs in your own database where you can test it.

| field | Operators, and what the rule takes |
| --- | --- |
| tag | has, not_has. value is the tag, compared without case. |
| attribute | eq, ne, contains, starts_with, gt, lt, exists, not_exists. name is the attribute, value is what to compare it against, and exists and not_exists take no value. |
| status | eq, ne. value is subscribed, unsubscribed, pending, or cleaned. Needs an audience_id. |
| joined | before, after with an ISO date; within_days, not_within_days with a number from 1 to 3650. Needs an audience_id. |
| opened | within_days, not_within_days with days from 1 to 3650; ever, never with no argument. |
| clicked | The same four operators as opened. |

- **attribute ne matches an absent attribute**: A contact with no plan attribute at all satisfies plan ne pro. That is usually what you want and occasionally a surprise, so pair it with an exists rule when it is not.
- **gt and lt compare numbers only when both sides are numbers**: Attribute values are stored as text. If either side does not parse as a number the rule does not match, rather than falling back to comparing strings and quietly matching the wrong people.
- **Rules are replaced, never merged**: PATCH takes the whole rules array and writes it over the old one. Send every rule you are keeping, not just the one you are changing.
- **Opens undercount**: An opened rule reads tracking pixels, and image blocking means it is a floor rather than a count. never on opened matches people who read every message in a client that blocks images. clicked is the sturdier signal.

### Count and read it

member_count comes back cached, with counted_at saying when it was computed. Pass count=live to recompute it now, which is what you want on a screen where somebody is about to press send.

Count, then list:
```bash
# Recompute the count now
curl "https://emails.sh/v1/segments/7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2?count=live" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Who is in it, mailable only, one page at a time
curl "https://emails.sh/v1/segments/7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2/members?mailable=true&limit=500" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

The members list is cursor paged: take next_after from a response and pass it as after on the next call. It stops when next_after comes back null. mailable=true drops anybody who is not subscribed or who is suppressed, which is the set a broadcast would actually reach.

### Use it on a broadcast

Narrow a draft to a segment:
```bash
curl -X PATCH https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"segment_id": "7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2"}'
```

The segment is evaluated when the send starts, not when you attach it. Somebody who stopped matching in between is not mailed, which is the behaviour you want from a rule like "not tagged churned".

- `GET /v1/segments` Segments. ?audience_id= narrows to one audience.
- `POST /v1/segments` { name, audience_id?, match?, rules? } creates one.
- `GET /v1/segments/:id` ?count=live recomputes member_count instead of reading the cached one.
- `PATCH /v1/segments/:id` Rules are replaced wholesale, never merged.
- `DELETE /v1/segments/:id` Remove a segment. Contacts are untouched.
- `GET /v1/segments/:id/members` ?mailable=true&limit=&after= over who it matches now.

#### `segments.create`

`{ name: string, audience_id?: string, match?: "all" | "any", rules?: Rule[], description?: string }`

A saved filter over contacts. Membership is computed when it is read rather than stored, so a segment is never stale.

| Parameter | Type | Required |
| --- | --- | --- |
| name | `string` | yes |
| audience_id | `string` | no |
| match | `"all" | "any"` | no |
| rules | `Rule[]` | no |
| description | `string` | no |

Returns: { id, name, describes, member_count }

#### `segments.get`

`{ id: string, count?: string }`

One segment, its rules, and the sentence describing them.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| count | `string` | no |

Returns: Segment

#### `segments.update`

`{ id: string, match?: "all" | "any", rules?: Rule[] }`

Change a segment. Send the whole rule list every time, including the rules you are keeping.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| match | `"all" | "any"` | no |
| rules | `Rule[]` | no |

Returns: Segment

#### `segments.members`

`{ id: string, mailable?: boolean, limit?: number, after?: string }`

Who a segment currently matches, as a cursor-paged list.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| mailable | `boolean` | no |
| limit | `number` | no |
| after | `string` | no |

Returns: { segment_id, total, next_after, members[] }

Deleting a segment answers with the scope it had, audience or workspace. It removes the filter and touches no contact.

---

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.
