Skip to main content

Segments API

Create and manage dynamic user segments programmatically.

All endpoints on this page are relative to the base URL: https://api-eu1.joryio.com/api - see API Overview.

Authentication​

All requests require API key authentication:

Authorization: Bearer jry_live_your_api_key_here
Content-Type: application/json

Create Segment​

Create a new user segment with filters.

Endpoint​

POST /segments

Request Body​

FieldTypeRequiredDescription
namestringYesSegment name (max 255 chars)
descriptionstringNoSegment description (max 1000 chars)
filterGroupsarrayYesArray of filter groups (max 20)
excludeFilterGroupsarrayNoUsers matching any of these groups are removed (max 20)
groupOperatorstringYesHow to combine groups: AND or OR
includeCrawlersbooleanNoCount crawler (bot) profiles as members. Default false - the Audience page hides them too.
tagsarrayNoTag names for organizing segments

Filter Structure​

Each filter group contains up to 50 filters:

{
filters: [
{
type: 'attribute' | 'default_attribute' | 'event' | 'ecommerce'
| 'behavioral' | 'segment' | 'canvas_execution'
| 'list_membership' | 'channel_subscription' | 'app'
| 'entity' | 'bounce_status' | 'wallet_pass'
| 'warehouse_sync' | 'device' | 'random_bucket'
| 'contact_import' | 'message_activity',
field?: string, // For attribute filters
operator: string, // See Filter Operators below
value?: any, // Comparison value (also carries N for count operators)
eventName?: string, // For event filters
withinDays?: number, // Time window for event filters
startDate?: string, // Absolute window start (ISO 8601, event filters)
endDate?: string, // Absolute window end (ISO 8601, event filters)
segmentId?: string, // For segment filters
listId?: string, // For list_membership filters
channel?: string // For channel_subscription filters
}
],
operator: 'AND' | 'OR'
}

A random_bucket filter compares the contact's stable random bucket number (0-9999, never changes for a person): operator is one of equals, not_equals, less_than, less_than_or_equal, greater_than, greater_than_or_equal, between; value is a whole number from 0 to 9999, or [from, to] for between (both ends included). Any other value is rejected with a 400. See Random bucket number.

A contact_import filter ("Came from import") matches contacts by the contact imports that created or updated them: operator is any_of or none_of, and value is a list of 1 to 100 import ids from GET /users/import/sources. none_of includes contacts that no import touched. An empty list is rejected with a 400. Contacts imported before this feature was released are not marked, so they match only none_of. See Came from import.

A message_activity filter ("Message activity") matches people by what happened to the messages sent to them. operator is the action: received (the message.sent event), delivered, opened, clicked, bounced, unsubscribed or failed. messageSource is campaign (with campaignId), campaign_tag (with tagIds), any_campaign, journey (with canvasId), journey_step (with canvasId and nodeId), journey_tag (with tagIds) or any. messageChannel is any or one of email, sms, push, whatsapp, in_app, webhook, viber - only a channel that reports the action (no opened for sms, push or webhook; bounced is email only). messageWindow is ever, in_last_days or not_in_last_days (value = days, 1 to 3650), between_dates (startDate and endDate), at_least (value = times, at least 1) or at_most (value = times, 0 or more). tagIds is a list of tag ids (from GET /tags), several meaning any of those tags; it matches the campaigns or journeys that carry the tag when the audience is computed, and renaming the tag does not change it. A filter saved with a tag name in tag still works and is stored by id on its next save; a name no tag carries is rejected with a 400. Anything else is rejected with a 400. Example: { "type": "message_activity", "operator": "opened", "messageSource": "any_campaign", "messageChannel": "email", "messageWindow": "in_last_days", "value": 30 }. See Message activity.

opened on email or any follows the opener rule: it also matches a person's click on the email, because a click proves the email was opened, and with at_least / at_most it compares the larger of the open and email-click counts. Bot opens and link-scanner clicks never count.

A canvas_execution filter also takes nodeId together with one canvasId - the person's run reached that step of the journey (any node type) - and the operators in_tagged_canvas / not_in_tagged_canvas with tagIds instead of canvasId: in (or not in) any journey that carries the tag. With nodeId, stepWindow limits it to when the run last entered the step: ever (default), in_last_days (withinDays, 1 to 3650) or between_dates (startDate and endDate). A one-journey operator (in_canvas, not_in_canvas, completed_canvas, exited_canvas) stored without canvasId matches nobody. See Canvas execution.

Example Request - Simple Attribute Filter​

curl -X POST https://api-eu1.joryio.com/api/segments \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Premium Users",
"description": "Users on premium plan",
"filterGroups": [
{
"filters": [
{
"type": "attribute",
"field": "plan",
"operator": "equals",
"value": "premium"
}
],
"operator": "AND"
}
],
"groupOperator": "AND"
}'

Example Request - Behavioral Segment​

curl -X POST https://api-eu1.joryio.com/api/segments \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Active Trial Users",
"description": "Trial users active in last 7 days",
"filterGroups": [
{
"filters": [
{
"type": "attribute",
"field": "plan",
"operator": "equals",
"value": "trial"
},
{
"type": "event",
"eventName": "Session Started",
"operator": "performed",
"withinDays": 7
}
],
"operator": "AND"
}
],
"groupOperator": "AND"
}'

Example Request - Complex Segment​

curl -X POST https://api-eu1.joryio.com/api/segments \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "High-Value At-Risk Users",
"description": "Paid users with high LTV who haven'\''t logged in recently",
"filterGroups": [
{
"filters": [
{
"type": "attribute",
"field": "plan",
"operator": "in",
"value": ["premium", "enterprise"]
},
{
"type": "attribute",
"field": "lifetimeValue",
"operator": "gte",
"value": 500
}
],
"operator": "AND"
},
{
"filters": [
{
"type": "event",
"eventName": "Login",
"operator": "not_performed",
"withinDays": 14
}
],
"operator": "AND"
}
],
"groupOperator": "AND"
}'

Response​

The segment object is returned directly (no wrapper envelope). Segment IDs are UUIDs. Member counts are not stored on the segment - use GET /segments/:id/size:

{
"id": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d",
"name": "Premium Users",
"description": "Users on premium plan",
"filterGroups": [
{
"filters": [
{
"type": "attribute",
"field": "plan",
"operator": "equals",
"value": "premium"
}
],
"operator": "AND"
}
],
"excludeFilterGroups": [],
"groupOperator": "AND",
"tags": [],
"status": "active",
"createdAt": "2024-01-20T10:30:00.000Z",
"updatedAt": "2024-01-20T10:30:00.000Z"
}

Get Segment​

Retrieve segment details.

Endpoint​

GET /segments/:id

Path Parameters​

ParameterTypeDescription
idstringSegment ID

Example Request​

curl -X GET https://api-eu1.joryio.com/api/segments/3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

The segment object, returned directly (for the current member count, call GET /segments/:id/size):

{
"id": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d",
"name": "Premium Users",
"description": "Users on premium plan",
"filterGroups": [...],
"excludeFilterGroups": [],
"groupOperator": "AND",
"tags": [],
"status": "active",
"createdAt": "2024-01-20T10:30:00.000Z",
"updatedAt": "2024-01-20T10:30:00.000Z"
}

List Segments​

Get all segments with pagination.

Endpoint​

GET /segments

Query Parameters​

ParameterTypeDefaultDescription
limitnumber100Results per page (max 100)
offsetnumber0Number of segments to skip
qstring-Free-text search on segment name
statusstring-Filter by status: active or archived
tagsstring-Comma-separated tag names
createdBystring-Comma-separated creator user IDs
editedBystring-Comma-separated last-editor user IDs

Example Request​

curl -X GET "https://api-eu1.joryio.com/api/segments?limit=50&status=active" \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

{
"data": [
{
"id": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d",
"name": "Premium Users",
"status": "active",
"createdAt": "2024-01-20T10:30:00.000Z"
},
{
"id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"name": "Trial Users",
"status": "active",
"createdAt": "2024-01-19T09:15:00.000Z"
}
],
"pagination": {
"total": 23,
"page": 1,
"limit": 50,
"offset": 0,
"totalPages": 1,
"hasMore": false
}
}

Update Segment​

Update segment name, description, or filters. Uses PUT (there is no PATCH route); fields you omit are left unchanged.

Endpoint​

PUT /segments/:id

Request Body​

{
"name": "Updated Name",
"description": "Updated description",
"filterGroups": [...]
}

Example Request​

curl -X PUT https://api-eu1.joryio.com/api/segments/3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"description": "Premium users who have made at least one purchase"
}'

Response​

The updated segment object, returned directly:

{
"id": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d",
"name": "Premium Users",
"description": "Premium users who have made at least one purchase",
"status": "active",
"updatedAt": "2024-01-21T14:30:00.000Z"
}

Archive Segment​

Segments cannot be hard-deleted - campaigns and journeys hold segment references, so removal is archive-only. Archived segments stop appearing in active lists and can be restored at any time.

Endpoints​

POST /segments/:id/archive
POST /segments/:id/unarchive

Example Request​

curl -X POST https://api-eu1.joryio.com/api/segments/3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d/archive \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

The segment object with its new status:

{
"id": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d",
"name": "Premium Users",
"status": "archived",
"updatedAt": "2024-01-21T14:30:00.000Z"
}

Notes​

  • Archiving a segment doesn't delete the users in it
  • Active campaigns using this segment will be affected
  • Use POST /segments/:id/unarchive to restore

Get Segment Users​

Get list of users in a segment.

Endpoint​

GET /segments/:id/users

Query Parameters​

ParameterTypeDefaultDescription
limitnumber100Number of users to return
offsetnumber0Number of users to skip

Example Request​

curl -X GET "https://api-eu1.joryio.com/api/segments/3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d/users?limit=100" \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

A bare JSON array of user documents (no pagination envelope - page with limit / offset):

[
{
"_id": "665f1e2a9b3c4d5e6f7a8b9c",
"externalId": "user_123",
"email": "[email protected]",
"attributes": {
"plan": "premium",
"signupDate": "2024-01-15"
}
},
{
"_id": "665f1e2a9b3c4d5e6f7a8b9d",
"externalId": "user_456",
"email": "[email protected]",
"attributes": {
"plan": "premium",
"signupDate": "2024-01-18"
}
}
]

Get Segment Size​

Get the current number of users in a segment. By default this is a fast approximate estimate; pass ?exact=true for a precise count. The size number is the only thing that is ever approximate - actual membership and sends are always exact.

Endpoint​

GET /segments/:id/size

Query Parameters​

ParameterTypeDefaultDescription
exactbooleanfalsetrue returns the precise count (slower on large workspaces)

Example Request​

curl -X GET https://api-eu1.joryio.com/api/segments/3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d/size \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

{
"segmentId": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d",
"size": 1234,
"approximate": true
}

Keeping a segment ready for reports​

Analytics reports that filter by a segment have to work out who is in it first, and hand that list of people to the analytics database along with the query. The list is what limits them: a retention comparison analyses at most 50,000 members of a segment, and takes a random sample when a segment is bigger.

Materializing a segment stores its membership in the analytics database instead, so a report joins to it and no list travels. The limit disappears at any size.

It is off by default and switched on per segment, because the cost is a nightly rebuild and most segments are never used in a report. Each organization has an allowance shared across its workspaces - twenty by default. Ask us to raise it.

The trade is freshness. A materialized segment is rebuilt every night and whenever you ask, so a report reads the membership as of the last rebuild rather than as of this second. Reports say which timestamp they used.

Get materialization status​

GET /segments/:id/materialization
curl -X GET https://api-eu1.joryio.com/api/segments/3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d/materialization \
-H "Authorization: Bearer jry_live_your_api_key"
{
"materialized": true,
"refreshedAt": "2026-09-03T03:00:00.000Z",
"memberCount": 482103,
"error": null,
"used": 4,
"quota": 20
}

refreshedAt is null until the first rebuild finishes. error carries why the last rebuild failed, so a stale snapshot can say why it is stale; the previous membership keeps serving in the meantime rather than the segment emptying. quota of -1 means unlimited.

List every materialized segment in the organization​

GET /segments/materialization/usage

The allowance, what is spent, and which segments spent it - across every workspace in the organization, which no workspace-scoped view can show.

{
"quota": 20,
"used": 2,
"segments": [
{
"id": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d",
"name": "Champions",
"workspaceId": "8c1f0b2a-91d4-4a77-9a3e-2d6f7b5c1e40",
"refreshedAt": "2026-09-03T03:00:00.000Z",
"memberCount": 1200,
"error": null
}
]
}

Turn it on​

POST /segments/:id/materialization

The first build runs immediately rather than waiting for the night. Returns the same status object.

Returns 400 when the organization has no free slots, naming the segments that are using them so you can choose one to turn off.

Turn it off​

DELETE /segments/:id/materialization

Deletes the stored membership and frees the slot.

Rebuild now​

POST /segments/:id/materialization/refresh

Recomputes membership immediately. Membership is rebuilt whole rather than added to, so people who no longer qualify are removed as well as new ones added - which matters because people leave a segment without doing anything: a buyer drops out of "no purchase in 30 days" purely by the calendar moving.


Testing filters before you use them​

A filter set is easy to get subtly wrong, and a wrong one is silent: it returns a smaller audience, not an error. These routes let an integration check its work before a send depends on it.

Preview an Audience​

POST /segments/preview

How many contacts a filter set matches, without saving a segment.

{
"filters": { "operator": "AND", "conditions": [
{ "type": "attribute", "field": "country", "operator": "equals", "value": "GR" }
] }
}

Response:

{ "count": 4820 }

Preview Matching Users​

POST /segments/preview-users

The same query, returning a sample of the actual contacts so you can eyeball whether they are the people you meant.

Check One User​

POST /segments/check-user

Does this contact match this filter set? The per-contact gate an integration needs when it is deciding about one person rather than building an audience.

{ "userId": "user_123", "filters": { "operator": "AND", "conditions": [] } }

Response:

{ "userId": "user_123", "matches": true }

Channel Breakdown​

POST /segments/channel-breakdown

How many of the matching contacts are actually reachable on each channel. A segment of 10,000 with 400 phone numbers is not an SMS audience of 10,000, and this is where that becomes visible before you schedule the send.

Response:

{
"email": 9210,
"sms": 400,
"push": 3110,
"whatsapp": 280,
"viber": 95,
"matched": 10000,
"capped": false
}

matched is the population the per-channel counts are a subset of, and capped is true when the segment was too large to resolve exactly (the counts are then of the resolved sample). A channel count is what the send path can actually reach: a subscribed contact with no address, no phone or no registered device is not counted. The phone STOP ledger and the address suppression lists are re-checked at send time, so a real send can only be smaller.

Bulk and exports​

Create Segments in Bulk​

POST /segments/bulk

Creates several segments in one call - for provisioning a workspace from your own configuration rather than clicking through the UI.

{
"segments": [
{ "name": "Greek customers", "filters": { "operator": "AND", "conditions": [] } },
{ "name": "Lapsed 90d", "filters": { "operator": "AND", "conditions": [] } }
]
}

List Export Jobs​

GET /segments/exports

Segment exports run asynchronously and hand back a job id. This lists those jobs with their status and, once finished, the presigned download URL.

Response:

{
"exports": [
{ "id": "exp_9f2", "segmentId": "seg_123", "status": "completed",
"rows": 4820, "url": "https://…", "expiresAt": "2026-08-27T12:00:00Z" }
]
}

Filter Operators​

Attribute Operators​

OperatorDescriptionExample
equalsExact matchplan equals "premium"
not_equalsNot equalplan not_equals "free"
inValue in listplan in ["premium", "enterprise"]
not_inValue not in listplan not_in ["free", "trial"]
containsString containsemail contains "@company.com"
not_containsString doesn't containemail not_contains "@competitor.com"
gtGreater thanlifetimeValue > 1000
gteGreater than or equalage >= 18
ltLess thanloginCount < 5
lteLess than or equalmrr <= 99
existsField existsphone exists
not_existsField doesn't existreferralCode not_exists
within_next_daysDate within next N daystrialEndsDate within_next_days 7
Contacts without a value

A contact with no value for the field matches the negative operators (not_equals, not_contains, not_in, not_exists) and none of the positive ones (equals, contains, in, exists, gt, gte, lt, lte). See Contacts without a value.

Identifiers are matched leniently

email is compared case-insensitively by equals, not_equals, in and not_in - [email protected] and [email protected] are the same address. phone is compared on its digits by the same four operators, so +972501234567, 972501234567 and +972 50-123-4567 all match the same contact. Every other field is matched exactly as you give it, including externalId - your own ids stay case-sensitive.

Event Operators​

OperatorDescriptionExample
performedUser performed eventPerformed "Order Completed"
not_performedUser didn't perform eventNot performed "Onboarding Completed"
performed_count_gteEvent count greater than or equal (N in value)Performed "Login" >= 10 times
performed_count_lteEvent count less than or equal (N in value)Performed "Login" <= 5 times
performed_in_last_daysPerformed within the last N days (N in value)Performed "Login" in last 7 days
not_performed_in_last_daysNot performed within the last N days (N in value)No "Login" in last 14 days

Filter Examples​

Attribute Filters​

// String matching
{
"type": "attribute",
"field": "email",
"operator": "contains",
"value": "@company.com"
}

// Numeric comparison
{
"type": "attribute",
"field": "lifetimeValue",
"operator": "gte",
"value": 500
}

// Multiple values
{
"type": "attribute",
"field": "plan",
"operator": "in",
"value": ["premium", "enterprise"]
}

// Field exists
{
"type": "attribute",
"field": "phone",
"operator": "exists"
}

// Date within next N days (future dates)
{
"type": "attribute",
"field": "trialEndsDate",
"operator": "within_next_days",
"value": 7
}

Event Filters​

// Event performed within timeframe
{
"type": "event",
"eventName": "Order Completed",
"operator": "performed",
"withinDays": 30
}

// Event not performed
{
"type": "event",
"eventName": "Onboarding Completed",
"operator": "not_performed"
}

// Event count (N goes in `value`)
{
"type": "event",
"eventName": "Login",
"operator": "performed_count_gte",
"value": 10,
"withinDays": 30
}

Segment Filters​

// User in another segment
{
"type": "segment",
"segmentId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"operator": "in_segment"
}

// User not in another segment
{
"type": "segment",
"segmentId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"operator": "not_in_segment"
}

Common Segment Examples​

Trial Conversion Opportunity​

{
"name": "Trial Expiring Soon",
"description": "Users whose trial ends in the next 3 days and haven't purchased",
"filterGroups": [
{
"filters": [
{
"type": "attribute",
"field": "plan",
"operator": "equals",
"value": "trial"
},
{
"type": "attribute",
"field": "trialEndsDate",
"operator": "within_next_days",
"value": 3
},
{
"type": "event",
"eventName": "Order Completed",
"operator": "not_performed"
}
],
"operator": "AND"
}
],
"groupOperator": "AND"
}

Power Users​

{
"name": "Power Users",
"filterGroups": [
{
"filters": [
{
"type": "event",
"eventName": "Login",
"operator": "performed_count_gte",
"value": 20,
"withinDays": 30
},
{
"type": "event",
"eventName": "Feature Used",
"operator": "performed_count_gte",
"value": 50,
"withinDays": 30
}
],
"operator": "AND"
}
],
"groupOperator": "AND"
}

At-Risk Customers​

{
"name": "At-Risk Premium Users",
"filterGroups": [
{
"filters": [
{
"type": "attribute",
"field": "plan",
"operator": "in",
"value": ["premium", "enterprise"]
},
{
"type": "attribute",
"field": "lifetimeValue",
"operator": "gte",
"value": 500
}
],
"operator": "AND"
},
{
"filters": [
{
"type": "event",
"eventName": "Login",
"operator": "not_performed",
"withinDays": 14
}
],
"operator": "AND"
}
],
"groupOperator": "AND"
}

Dynamic Updates​

Segments are dynamic: membership is not a stored list - the segment's filters are evaluated against current profile and event data whenever the segment is used (campaign targeting, journey gates, membership checks). There is nothing to refresh or recompute via the API.

Checking Segment Size​

# Get current size (approximate by default; add ?exact=true for a precise count)
curl -X GET https://api-eu1.joryio.com/api/segments/3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d/size \
-H "Authorization: Bearer jry_live_your_api_key"

Error Responses​

All errors use the standard error body - see API Overview: Error Response.

400 Bad Request - Invalid Filter​

{
"statusCode": 400,
"message": "Bad Request Exception",
"timestamp": "2026-01-15T10:30:00.000Z",
"path": "/segments",
"errors": [
"filterGroups.0.filters.0.operator must be one of the following values: equals, not_equals, contains, ..."
]
}

404 Not Found​

{
"statusCode": 404,
"message": "Segment with ID 3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d not found",
"timestamp": "2026-01-15T10:30:00.000Z",
"path": "/segments/3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d"
}

Rate Limits​

The Segments API has no fixed per-endpoint rate limits today - see API Overview: Rate Limiting.


Best Practices​

1. Keep Segments Focused​

Good: Specific, targeted segments

{
"name": "Premium US Users - Active Last 7 Days",
"filters": [...]
}

Bad: Overly broad segments

{
"name": "All Users",
"filters": []
}

2. Use Descriptive Names​

Good: Self-explanatory names

  • "Trial Users - Expiring This Week"
  • "High-Value At-Risk Customers"
  • "New Signups - Not Onboarded"

Bad: Unclear names

  • "Segment 1"
  • "Test"
  • "Users ABC"

3. Combine Filters Logically​

Use AND for narrowing, OR for broadening:

// AND: Premium users who are active
{
"filters": [
{ "field": "plan", "operator": "equals", "value": "premium" },
{ "eventName": "Login", "operator": "performed", "withinDays": 7 }
],
"operator": "AND"
}

// OR: Users on any paid plan
{
"filters": [
{ "field": "plan", "operator": "equals", "value": "premium" },
{ "field": "plan", "operator": "equals", "value": "enterprise" }
],
"operator": "OR"
}

4. Monitor Segment Size​

Track segment size over time:

// Poll segment size
setInterval(async () => {
const { size, approximate } = await fetch(`/segments/${segmentId}/size`).then(r => r.json());
console.log(`Segment size: ${approximate ? '≈' : ''}${size}`);
}, 60000); // Every minute

Next Steps​

Deleting vs archiving​

POST /segments/{segmentId}/archive
DELETE /segments/{segmentId}

Archive is the default. An archived segment stops appearing in pickers and in membership lookups, while every campaign and journey already pointing at it keeps working. Reverse it with POST /segments/{segmentId}/unarchive.

Delete removes it permanently, and only when nothing references it. If a campaign, journey, in-app campaign or another segment still uses it, the request returns 409 and names them:

{
"statusCode": 409,
"message": "This segment cannot be deleted because it is still used by campaign \"Winback\", journey \"Onboarding\". Archive it instead - an archived segment stops appearing in pickers and in membership lookups, and everything already pointing at it keeps working."
}
Why this matters for performance

GET /users/{userId}/segments evaluates every ACTIVE segment against the contact, so a workspace that accumulates segments pays for them on every membership lookup. Archiving or deleting the ones you no longer use is the fix.