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:
| Manual | Dynamic | |
|---|---|---|
| rules | null | { operator, conditions } |
| Members | The 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 when | You add or remove a contact. | A contact’s data or engagement changes — nothing is stored, so there is nothing to refresh. |
| Growth chart | Yes, 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.
{
"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 with400. comparatorstringrequired- One of the 14 comparators below.
valuestringdefault""- Always a string, even for numbers and dates (
"3", not3). Up to 500 characters. Ignored byis_setandis_not_set. windowDaysintegerdefault30- Only for
emailsOpenedandemailsClicked: the rolling window in days, 1–365. Ignored on other fields.
Fields
| Field | Holds | Compare it with |
|---|---|---|
email | The address, lower-case. | Text comparators, is_set. |
firstName | First name, or nothing. | Text comparators, is_set / is_not_set. |
lastName | Last 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. |
createdAt | When the contact was created. | before, after, in_last_days, and the four ordering comparators with a date value. |
lastEngagedAt | Last open or click, as of the daily recalculation. Empty if never. | Date comparators, is_set / is_not_set. |
emailsOpened | Opens 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. |
emailsClicked | Clicks 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
| Comparator | Matches when the field… | value |
|---|---|---|
equals | is exactly the value. Case-sensitive. | Any string. |
not_equals | is not exactly the value. A field with no value at all never matches. | Any string. |
contains | contains the value. Case-insensitive; % and _ are literal. | Any string. |
starts_with | starts with the value. Case-insensitive. | Any string. |
ends_with | ends with the value. Case-insensitive. | Any string. |
greater_than | is greater than the value. | A number; a date on createdAt / lastEngagedAt. |
less_than | is less than the value. | Same. |
greater_than_or_equal | is at least the value. | Same. |
less_than_or_equal | is at most the value. | Same. |
before | is a date earlier than the value. | YYYY-MM-DD, optionally with a time and offset. |
after | is a date later than the value. | Same. |
in_last_days | is a date no more than N days ago. Future dates match too. | A whole number of days. |
is_set | has a non-empty value. | Not used. |
is_not_set | is 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#
/segmentsCreates a manual segment (omit rules or send null) or a dynamic one (send rules).
{
"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 }
]
}
}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"
}
}// 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' }],
},
});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:idand assegment_idon 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;
nullfor a manual one. segment.createdAtstring (ISO 8601)- When the segment was created.
segment.updatedAtstring (ISO 8601)- When it was last changed.
Status codes
| Status | Meaning |
|---|---|
| 201 | Created. The body is { segment }. |
| 400 | Body failed validation, e.g. rules.conditions.0.value: Enter a whole number of days. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended. |
| 429 | More than 30 creates in a minute from this key. |
| 409 | A segment named "…" already exists — names are unique within your team. |
List segments#
/segmentsReturns 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.
{
"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
}
]
}const { segments } = await poststack.segments.list();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:idand assegment_idon 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;
nullfor 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.contactCountis then 0 and means nothing.
Status codes
| Status | Meaning |
|---|---|
| 200 | The segments. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
Retrieve a segment#
/segments/:idReturns one segment with its member count and its first 100 members, oldest first. Page through the rest with List segment members.
{
"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", ... }
]
}
}const segment = await poststack.segments.get('seg_p8d2f6h1j5l9n3b7c4v0x6z2');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:idand assegment_idon 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;
nullfor 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.contactCountis then 0 and means nothing. segment.contactsobject[]- Up to 100 members, as contact objects.
Status codes
| Status | Meaning |
|---|---|
| 200 | The segment. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found — no segment with this id in your team. |
List segment members#
/segments/:id/membersPages through a segment's members, oldest contact first. For a dynamic segment the rules are evaluated on every call.
{
"data": [
{ "id": 9120, "publicId": "con_k3v9x2m7q4w8e1r5t6y0u2i8", "email": "alice@example.com", ... }
],
"meta": { "page": 1, "perPage": 50, "total": 342, "totalPages": 7 }
}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
pageintegerdefault1- Page number, from 1.
per_pageintegerdefault50- 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
| Status | Meaning |
|---|---|
| 200 | The page of members. |
| 400 | per_page is above 100, or a query value is not a positive integer. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found — no segment with this id in your team. |
Update a segment#
/segments/:idRenames a segment and/or replaces its rules. Send only what changes: an omitted field keeps its current value.
{
"name": "Engaged pro users",
"rules": {
"operator": "and",
"conditions": [
{ "field": "properties.plan", "comparator": "equals", "value": "pro" },
{ "field": "unsubscribed", "comparator": "equals", "value": "false" }
]
}
}{
"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"
}
}const segment = await poststack.segments.update('seg_p8d2f6h1j5l9n3b7c4v0x6z2', {
rules: null, // make it a manual segment; the name is left as it is
});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
nullto 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
| Status | Meaning |
|---|---|
| 200 | Updated. |
| 400 | Body failed validation, e.g. an unknown rule field. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found — no segment with this id in your team. |
| 409 | The new name is already used by another segment in your team. |
Delete a segment#
/segments/:idDeletes 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.
{
"success": true
}await poststack.segments.delete('seg_p8d2f6h1j5l9n3b7c4v0x6z2');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
| Status | Meaning |
|---|---|
| 200 | Deleted. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found — no segment with this id in your team. |
Preview rules#
/segments/previewCounts the contacts a rule set matches right now, without saving anything. Returns the count only.
{
"rules": {
"operator": "or",
"conditions": [
{ "field": "engagementSegment", "comparator": "equals", "value": "at_risk" },
{ "field": "lastEngagedAt", "comparator": "is_not_set" }
]
}
}{
"count": 838
}const { count } = await poststack.segments.previewRules({
rules: {
operator: 'and',
conditions: [{ field: 'properties.plan', comparator: 'equals', value: 'pro' }],
},
});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
| Status | Meaning |
|---|---|
| 200 | The count. |
| 400 | The rules failed validation, e.g. an unknown field. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 429 | More than 10 previews in a minute from this key. |
Add contacts to a segment#
/segments/:id/contactsAdds 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.
{
"contact_ids": [
"con_k3v9x2m7q4w8e1r5t6y0u2i8",
"con_b6n1m5q9w3e7r2t8y4u0i6o1"
]
}{
"added": 2
}// Resolves to { added } at runtime.
await poststack.segments.addContacts('seg_r4t7y1u5i9o3p6a2s8d0f4g7', {
contact_ids: ['con_k3v9x2m7q4w8e1r5t6y0u2i8', 'con_b6n1m5q9w3e7r2t8y4u0i6o1'],
});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
| Status | Meaning |
|---|---|
| 200 | Done — check added. |
| 400 | No valid contacts found (none of the ids is a contact in your team), or contact_ids is empty or longer than 1,000. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found — no segment with this id in your team. |
Remove a contact from a segment#
/segments/:id/contacts/:contactIdRemoves one contact from a manual segment's member list. The contact is not deleted. Succeeds even if the contact was not a member.
{
"success": true
}await poststack.segments.removeContact(
'seg_r4t7y1u5i9o3p6a2s8d0f4g7',
'con_k3v9x2m7q4w8e1r5t6y0u2i8',
);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
| Status | Meaning |
|---|---|
| 200 | Removed, or was not a member. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found — no segment with this id in your team. |
List broadcasts using a segment#
/segments/:id/broadcastsEvery broadcast that targets this segment, newest first, in any status.
{
"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"
}
]
}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.
draftorsent. broadcasts[].sentAtstring (ISO 8601) | null- When it was sent.
broadcasts[].createdAtstring (ISO 8601)- When it was created.
Status codes
| Status | Meaning |
|---|---|
| 200 | The broadcasts. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found — no segment with this id in your team. |
Get segment growth#
/segments/:id/growthDaily 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.
{
"available": true,
"daily": [
{ "date": "2026-09-22", "members": 51 },
{ "date": "2026-09-23", "members": 58 }
]
}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
daysintegerdefault30- Only
7,30and90are 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
| Status | Meaning |
|---|---|
| 200 | The series. |
| 400 | days is not an integer from 1 to 365. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found — no segment with this id in your team. |
Example rule sets#
| Audience | Rules |
|---|---|
| Subscribed customers on the pro plan | and: properties.plan equals "pro", unsubscribed equals "false" |
| Signed up this week | and: createdAt in_last_days "7" |
| Opened nothing in 90 days | and: emailsOpened equals "0" (windowDays 90) |
| Clicked at least twice this month | and: emailsClicked greater_than_or_equal "2" (windowDays 30) |
| Company addresses | or: email ends_with "@acme.io", email ends_with "@acme.com" |
| Trial ends before October | and: 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.