Skip to content

Segments

Named groups of contacts that broadcasts are sent to — either a list you maintain by hand, or rules PostStack evaluates against your contacts every time the segment is used.

Manual and dynamic segments#

A segment is one of two kinds, decided by its rules:

ManualDynamic
rulesnull{ operator, conditions }
MembersThe contacts you added with POST /segments/:id/contacts, the dashboard, or a signup form.Every contact that matches the rules at the moment the segment is read, counted or sent to.
Changes whenYou add or remove a contact.A contact’s data or engagement changes — nothing is stored, so there is nothing to refresh.
Growth chartYes, from the date each member was added.No — GET /segments/:id/growth returns available: false.

Rules win. Adding contacts to a dynamic segment succeeds but has no effect on its members, and a signup form pointed at a dynamic segment adds rows nobody reads. Setting rules to null turns a dynamic segment into a manual one whose members are only the contacts ever added by hand — usually none. The rule matches are not copied over.

Segments include unsubscribed contacts. A broadcast sent to a segment skips those, suppressed addresses, and — when the broadcast has a topic — contacts who opted out of it. To keep them out of the count as well, add the condition { "field": "unsubscribed", "comparator": "equals", "value": "false" }.

Endpoints live under https://api.poststack.dev/segments. Reads need the contacts:read scope, writes need contacts:manage.

Rules#

A rule set is an operator — and (every condition must match) or or (at least one) — and 1–50 conditions. There is no nesting.

json
{
  "operator": "and",
  "conditions": [
    { "field": "properties.plan", "comparator": "equals", "value": "pro" },
    { "field": "createdAt", "comparator": "in_last_days", "value": "30" },
    { "field": "emailsClicked", "comparator": "greater_than", "value": "0", "windowDays": 90 }
  ]
}

Condition

fieldstringrequired
One of the fields below, or properties.<name> for a custom property. Anything else is rejected with 400.
comparatorstringrequired
One of the 14 comparators below.
valuestringdefault ""
Always a string, even for numbers and dates ("3", not 3). Up to 500 characters. Ignored by is_set and is_not_set.
windowDaysintegerdefault 30
Only for emailsOpened and emailsClicked: the rolling window in days, 1–365. Ignored on other fields.

Fields

FieldHoldsCompare it with
emailThe address, lower-case.Text comparators, is_set.
firstNameFirst name, or nothing.Text comparators, is_set / is_not_set.
lastNameLast name, or nothing.Text comparators, is_set / is_not_set.
unsubscribed"true" or "false".equals / not_equals.
engagementSegment"active", "at_risk" or "dormant" — see engagement.equals / not_equals.
createdAtWhen the contact was created.before, after, in_last_days, and the four ordering comparators with a date value.
lastEngagedAtLast open or click, as of the daily recalculation. Empty if never.Date comparators, is_set / is_not_set.
emailsOpenedOpens in the last windowDays days, excluding machine prefetches.equals, not_equals, greater_than, less_than, greater_than_or_equal, less_than_or_equal — nothing else — with a whole-number value.
emailsClickedClicks in the last windowDays days, excluding link-scanner clicks.Same as emailsOpened.
properties.<name>A custom property, read as text. properties.plan, never bare plan.Any comparator. Ordering comparators compare numerically; date comparators parse the value as a date.

emailsOpened and emailsClicked count events on every email your team sent with the contact's address in to — broadcasts, workflows and POST /emails alike. A contact who received nothing has a count of 0, so emailsOpened less_than 3 includes them.

Comparators

ComparatorMatches when the field…value
equalsis exactly the value. Case-sensitive.Any string.
not_equalsis not exactly the value. A field with no value at all never matches.Any string.
containscontains the value. Case-insensitive; % and _ are literal.Any string.
starts_withstarts with the value. Case-insensitive.Any string.
ends_withends with the value. Case-insensitive.Any string.
greater_thanis greater than the value.A number; a date on createdAt / lastEngagedAt.
less_thanis less than the value.Same.
greater_than_or_equalis at least the value.Same.
less_than_or_equalis at most the value.Same.
beforeis a date earlier than the value.YYYY-MM-DD, optionally with a time and offset.
afteris a date later than the value.Same.
in_last_daysis a date no more than N days ago. Future dates match too.A whole number of days.
is_sethas a non-empty value.Not used.
is_not_setis missing or empty.Not used.

A value that does not fit the comparator makes the condition match nobody instead of raising an error: seats greater_than abc matches no one, and a contact whose properties.seats holds "n/a" simply fails a numeric comparison. The API rejects the obvious cases up front with 400 — a before / after value that is not a real date (Enter a date as YYYY-MM-DD.), an in_last_days value that is not a whole number, and a behavioural condition with a non-integer count or a comparator it does not support.

field is checked when you create, update or preview rules: a misspelt field (firstname, or plan without properties.) is a 400 that names it, e.g. rules.conditions.0.field: Unknown segment field "firstname"…. A segment saved before this check with an unknown field still loads; that condition matches nobody until you fix it with PATCH /segments/:id.

Endpoints#

Create a segment#

POST
/segments

Creates a manual segment (omit rules or send null) or a dynamic one (send rules).

json
{
  "name": "Engaged pro users",
  "rules": {
  "operator": "and",
  "conditions": [
    { "field": "properties.plan", "comparator": "equals", "value": "pro" },
    { "field": "emailsOpened", "comparator": "greater_than_or_equal", "value": "3", "windowDays": 30 }
  ]
}
}
json
HTTP/1.1 201 Created

{
  "segment": {
    "id": 31,
    "publicId": "seg_p8d2f6h1j5l9n3b7c4v0x6z2",
    "teamId": 42,
    "name": "Engaged pro users",
    "rules": {
      "operator": "and",
      "conditions": [
        { "field": "properties.plan", "comparator": "equals", "value": "pro" },
        { "field": "emailsOpened", "comparator": "greater_than_or_equal", "value": "3", "windowDays": 30 }
      ]
    },
    "createdAt": "2026-09-23T10:00:00.000Z",
    "updatedAt": "2026-09-23T10:00:00.000Z"
  }
}
typescript
// The SDK returns the segment itself, not the { segment } envelope.
// Omit rules for a manual segment.
const segment = await poststack.segments.create({
  name: 'Pro users',
  rules: {
    operator: 'and',
    conditions: [{ field: 'properties.plan', comparator: 'equals', value: 'pro' }],
  },
});
bash
curl -X POST https://api.poststack.dev/segments \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Pro users",
    "rules": {
      "operator": "and",
      "conditions": [
        { "field": "properties.plan", "comparator": "equals", "value": "pro" }
      ]
    }
  }'

Body parameters

namestringrequired
1–100 characters, unique in your team.
rulesobject | null
{ operator, conditions } — see Rules.

Response fields

segmentobject
The new segment (no contactCount).
segment.idinteger
Internal numeric id. Not accepted by any endpoint — use publicId.
segment.publicIdstring
The segment id (seg_ + 24 characters), used as :id and as segment_id on broadcasts, forms and exports.
segment.teamIdinteger
The team that owns the segment.
segment.namestring
Segment name, unique in your team.
segment.rulesobject | null
The rule set of a dynamic segment; null for a manual one.
segment.createdAtstring (ISO 8601)
When the segment was created.
segment.updatedAtstring (ISO 8601)
When it was last changed.

Status codes

StatusMeaning
201Created. The body is { segment }.
400Body failed validation, e.g. rules.conditions.0.value: Enter a whole number of days.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended.
429More than 30 creates in a minute from this key.
409A segment named "…" already exists — names are unique within your team.

List segments#

GET
/segments

Returns every segment in your team, oldest first, each with its current member count. Not paginated. Dynamic segments are counted live, so a team with many large dynamic segments gets a slower response.

json
{
  "segments": [
    {
      "id": 30,
      "publicId": "seg_r4t7y1u5i9o3p6a2s8d0f4g7",
      "teamId": 42,
      "name": "Beta testers",
      "rules": null,
      "createdAt": "2026-09-20T10:00:00.000Z",
      "updatedAt": "2026-09-20T10:00:00.000Z",
      "contactCount": 58
    },
    {
      "id": 31,
      "publicId": "seg_p8d2f6h1j5l9n3b7c4v0x6z2",
      "teamId": 42,
      "name": "Engaged pro users",
      "rules": {
        "operator": "and",
        "conditions": [
          { "field": "properties.plan", "comparator": "equals", "value": "pro" },
          { "field": "emailsOpened", "comparator": "greater_than_or_equal", "value": "3", "windowDays": 30 }
        ]
      },
      "createdAt": "2026-09-23T10:00:00.000Z",
      "updatedAt": "2026-09-23T10:00:00.000Z",
      "contactCount": 342
    }
  ]
}
typescript
const { segments } = await poststack.segments.list();
bash
curl https://api.poststack.dev/segments \
  -H "Authorization: Bearer sk_live_..."

Response fields

segmentsobject[]
The segments.
segments[].idinteger
Internal numeric id. Not accepted by any endpoint — use publicId.
segments[].publicIdstring
The segment id (seg_ + 24 characters), used as :id and as segment_id on broadcasts, forms and exports.
segments[].teamIdinteger
The team that owns the segment.
segments[].namestring
Segment name, unique in your team.
segments[].rulesobject | null
The rule set of a dynamic segment; null for a manual one.
segments[].createdAtstring (ISO 8601)
When the segment was created.
segments[].updatedAtstring (ISO 8601)
When it was last changed.
segments[].contactCountinteger
Members right now. Dynamic segments are counted live from their rules; manual ones from their member list.
segments[].countUnavailableboolean
Present (and true) only when a dynamic segment's rules could not be evaluated. contactCount is then 0 and means nothing.

Status codes

StatusMeaning
200The segments.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.

Retrieve a segment#

GET
/segments/:id

Returns one segment with its member count and its first 100 members, oldest first. Page through the rest with List segment members.

json
{
  "segment": {
    "id": 31,
    "publicId": "seg_p8d2f6h1j5l9n3b7c4v0x6z2",
    "teamId": 42,
    "name": "Engaged pro users",
    "rules": { "operator": "and", "conditions": [ ... ] },
    "createdAt": "2026-09-23T10:00:00.000Z",
    "updatedAt": "2026-09-23T10:00:00.000Z",
    "contactCount": 342,
    "contacts": [
      { "id": 9120, "publicId": "con_k3v9x2m7q4w8e1r5t6y0u2i8", "email": "alice@example.com", ... }
    ]
  }
}
typescript
const segment = await poststack.segments.get('seg_p8d2f6h1j5l9n3b7c4v0x6z2');
bash
curl https://api.poststack.dev/segments/seg_p8d2f6h1j5l9n3b7c4v0x6z2 \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The segment's publicId (seg_…).

Response fields

segmentobject
The segment.
segment.idinteger
Internal numeric id. Not accepted by any endpoint — use publicId.
segment.publicIdstring
The segment id (seg_ + 24 characters), used as :id and as segment_id on broadcasts, forms and exports.
segment.teamIdinteger
The team that owns the segment.
segment.namestring
Segment name, unique in your team.
segment.rulesobject | null
The rule set of a dynamic segment; null for a manual one.
segment.createdAtstring (ISO 8601)
When the segment was created.
segment.updatedAtstring (ISO 8601)
When it was last changed.
segment.contactCountinteger
Members right now. Dynamic segments are counted live from their rules; manual ones from their member list.
segment.countUnavailableboolean
Present (and true) only when a dynamic segment's rules could not be evaluated. contactCount is then 0 and means nothing.
segment.contactsobject[]
Up to 100 members, as contact objects.

Status codes

StatusMeaning
200The segment.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Segment not found — no segment with this id in your team.

List segment members#

GET
/segments/:id/members

Pages through a segment's members, oldest contact first. For a dynamic segment the rules are evaluated on every call.

json
{
  "data": [
    { "id": 9120, "publicId": "con_k3v9x2m7q4w8e1r5t6y0u2i8", "email": "alice@example.com", ... }
  ],
  "meta": { "page": 1, "perPage": 50, "total": 342, "totalPages": 7 }
}
bash
curl "https://api.poststack.dev/segments/seg_p8d2f6h1j5l9n3b7c4v0x6z2/members?page=2&per_page=100" \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The segment's publicId (seg_…).

Query parameters

pageintegerdefault 1
Page number, from 1.
per_pageintegerdefault 50
1–100.

Response fields

dataobject[]
Contacts on this page, as contact objects.
meta.page / perPage / total / totalPagesinteger
Pagination. total is the member count.

Status codes

StatusMeaning
200The page of members.
400per_page is above 100, or a query value is not a positive integer.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Segment not found — no segment with this id in your team.

Update a segment#

PATCH
/segments/:id

Renames a segment and/or replaces its rules. Send only what changes: an omitted field keeps its current value.

json
{
  "name": "Engaged pro users",
  "rules": {
    "operator": "and",
    "conditions": [
      { "field": "properties.plan", "comparator": "equals", "value": "pro" },
      { "field": "unsubscribed", "comparator": "equals", "value": "false" }
    ]
  }
}
json
{
  "segment": {
    "id": 31,
    "publicId": "seg_p8d2f6h1j5l9n3b7c4v0x6z2",
    "teamId": 42,
    "name": "Engaged pro users",
    "rules": { "operator": "and", "conditions": [ ... ] },
    "createdAt": "2026-09-23T10:00:00.000Z",
    "updatedAt": "2026-09-24T08:30:00.000Z"
  }
}
typescript
const segment = await poststack.segments.update('seg_p8d2f6h1j5l9n3b7c4v0x6z2', {
  rules: null, // make it a manual segment; the name is left as it is
});
bash
curl -X PATCH https://api.poststack.dev/segments/seg_p8d2f6h1j5l9n3b7c4v0x6z2 \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "Pro users (EU)" }'

Path parameters

idstringrequired
The segment's publicId (seg_…).

Body parameters

namestring
1–100 characters, unique in your team. Omit to keep the current name.
rulesobject | null
Omit to keep the current rules, send a rule set to replace them, or null to make the segment manual (see above for what that does to its members).

Response fields

segmentobject
The segment after the update (no contactCount).

Status codes

StatusMeaning
200Updated.
400Body failed validation, e.g. an unknown rule field.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended.
404Segment not found — no segment with this id in your team.
409The new name is already used by another segment in your team.

Delete a segment#

DELETE
/segments/:id

Deletes the segment and its manual member list. The contacts themselves are kept. Broadcasts and signup forms that pointed at it are left with no segment.

json
{
  "success": true
}
typescript
await poststack.segments.delete('seg_p8d2f6h1j5l9n3b7c4v0x6z2');
bash
curl -X DELETE https://api.poststack.dev/segments/seg_p8d2f6h1j5l9n3b7c4v0x6z2 \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The segment's publicId (seg_…).

Response fields

successboolean
Always true.

Status codes

StatusMeaning
200Deleted.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended.
404Segment not found — no segment with this id in your team.

Preview rules#

POST
/segments/preview

Counts the contacts a rule set matches right now, without saving anything. Returns the count only.

json
{
  "rules": {
    "operator": "or",
    "conditions": [
      { "field": "engagementSegment", "comparator": "equals", "value": "at_risk" },
      { "field": "lastEngagedAt", "comparator": "is_not_set" }
    ]
  }
}
json
{
  "count": 838
}
typescript
const { count } = await poststack.segments.previewRules({
  rules: {
    operator: 'and',
    conditions: [{ field: 'properties.plan', comparator: 'equals', value: 'pro' }],
  },
});
bash
curl -X POST https://api.poststack.dev/segments/preview \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "rules": {
      "operator": "and",
      "conditions": [
        { "field": "properties.plan", "comparator": "equals", "value": "pro" }
      ]
    }
  }'

Body parameters

rulesobjectrequired
{ operator, conditions } — see Rules.

Response fields

countinteger
Matching contacts, including unsubscribed ones.

Status codes

StatusMeaning
200The count.
400The rules failed validation, e.g. an unknown field.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
429More than 10 previews in a minute from this key.

Add contacts to a segment#

POST
/segments/:id/contacts

Adds contacts to a manual segment's member list. Ids that are not contacts in your team are ignored, and contacts already in the segment are not added twice.

json
{
  "contact_ids": [
    "con_k3v9x2m7q4w8e1r5t6y0u2i8",
    "con_b6n1m5q9w3e7r2t8y4u0i6o1"
  ]
}
json
{
  "added": 2
}
typescript
// Resolves to { added } at runtime.
await poststack.segments.addContacts('seg_r4t7y1u5i9o3p6a2s8d0f4g7', {
  contact_ids: ['con_k3v9x2m7q4w8e1r5t6y0u2i8', 'con_b6n1m5q9w3e7r2t8y4u0i6o1'],
});
bash
curl -X POST https://api.poststack.dev/segments/seg_r4t7y1u5i9o3p6a2s8d0f4g7/contacts \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "contact_ids": ["con_k3v9x2m7q4w8e1r5t6y0u2i8"] }'

Path parameters

idstringrequired
The segment's publicId (seg_…).

Body parameters

contact_idsstring[]required
1–1,000 contact publicIds (con_…).

Response fields

addedinteger
Contacts newly added. Unknown ids and existing members are not counted.

Status codes

StatusMeaning
200Done — check added.
400No valid contacts found (none of the ids is a contact in your team), or contact_ids is empty or longer than 1,000.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended.
404Segment not found — no segment with this id in your team.

Remove a contact from a segment#

DELETE
/segments/:id/contacts/:contactId

Removes one contact from a manual segment's member list. The contact is not deleted. Succeeds even if the contact was not a member.

json
{
  "success": true
}
typescript
await poststack.segments.removeContact(
  'seg_r4t7y1u5i9o3p6a2s8d0f4g7',
  'con_k3v9x2m7q4w8e1r5t6y0u2i8',
);
bash
curl -X DELETE https://api.poststack.dev/segments/seg_r4t7y1u5i9o3p6a2s8d0f4g7/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8 \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The segment's publicId (seg_…).
contactIdstringrequired
The contact's publicId (con_…).

Response fields

successboolean
Always true.

Status codes

StatusMeaning
200Removed, or was not a member.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended.
404Segment not found — no segment with this id in your team.

List broadcasts using a segment#

GET
/segments/:id/broadcasts

Every broadcast that targets this segment, newest first, in any status.

json
{
  "broadcasts": [
    {
      "id": 77,
      "publicId": "bc_x4c8v2b6n0m3q7w1e5r9t2y6",
      "subject": "September product update",
      "status": "sent",
      "sentAt": "2026-09-24T09:00:00.000Z",
      "createdAt": "2026-09-22T14:10:00.000Z"
    }
  ]
}
bash
curl https://api.poststack.dev/segments/seg_p8d2f6h1j5l9n3b7c4v0x6z2/broadcasts \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The segment's publicId (seg_…).

Response fields

broadcasts[].publicIdstring
The broadcast id.
broadcasts[].idinteger
Internal numeric id.
broadcasts[].subjectstring
Subject line.
broadcasts[].statusstring
Broadcast status, e.g. draft or sent.
broadcasts[].sentAtstring (ISO 8601) | null
When it was sent.
broadcasts[].createdAtstring (ISO 8601)
When it was created.

Status codes

StatusMeaning
200The broadcasts.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Segment not found — no segment with this id in your team.

Get segment growth#

GET
/segments/:id/growth

Daily member count of a manual segment, rebuilt from when each current member was added. Removed members are not in the history, so the curve never goes down. Dynamic segments have no history.

json
{
  "available": true,
  "daily": [
    { "date": "2026-09-22", "members": 51 },
    { "date": "2026-09-23", "members": 58 }
  ]
}
bash
curl "https://api.poststack.dev/segments/seg_r4t7y1u5i9o3p6a2s8d0f4g7/growth?days=90" \
  -H "Authorization: Bearer sk_live_..."

Path parameters

idstringrequired
The segment's publicId (seg_…).

Query parameters

daysintegerdefault 30
Only 7, 30 and 90 are honoured; any other value from 1 to 365 is treated as 30.

Response fields

availableboolean
false for a dynamic segment (daily is then empty).
daily{ date, members }[]
One entry per UTC day, oldest first; date is YYYY-MM-DD.

Status codes

StatusMeaning
200The series.
400days is not an integer from 1 to 365.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Segment not found — no segment with this id in your team.

Example rule sets#

AudienceRules
Subscribed customers on the pro planand: properties.plan equals "pro", unsubscribed equals "false"
Signed up this weekand: createdAt in_last_days "7"
Opened nothing in 90 daysand: emailsOpened equals "0" (windowDays 90)
Clicked at least twice this monthand: emailsClicked greater_than_or_equal "2" (windowDays 30)
Company addressesor: email ends_with "@acme.io", email ends_with "@acme.com"
Trial ends before Octoberand: properties.trial_ends before "2026-10-01"

Troubleshooting#

An older segment matches fewer contacts than expected

A segment saved before fields were validated can hold a field that is not supported (a custom property without the properties. prefix, or a misspelling such as firstname). That condition matches nobody. Fix the rules with PATCH /segments/:id; the corrected rules are validated.

equals matches nobody, though the value is there

equals is case-sensitive and compares text exactly — "Pro" does not equal "pro", and "12.0" does not equal a stored 12. Use contains for a case-insensitive match, or an ordering comparator for numbers.

Contacts I added do not show up in the segment

The segment is dynamic. Its members come only from its rules; hand-added contacts are ignored. Add a condition that matches them, or create a separate manual segment.

409 A segment named … already exists

Segment names are unique within a team. Pick another name, or update the existing segment instead.

Next steps

Was this page helpful?

Related