Segments API
Lists of contacts for targeting broadcasts.
Segments are lists of contacts, used to target broadcasts. A contact can be in several segments. Add and remove contacts with the contacts endpoints, through the import or with forms. The Free plan allows up to 3 segments; paid plans, as many as you like.
There are two types, in the type field: static (the default), a list you build contact by contact, and dynamic, defined by filters and kept up to date as contacts change. Only static segments take contacts by hand (through the contacts endpoints, the import and forms); trying that on a dynamic one returns 422. The type cannot change after creation, but a dynamic segment can be converted to static.
#Dynamic segments
A dynamic segment holds whoever matches the filters right now. When you create it or change its filters, the members are calculated in the background (without starting automations); after that, the list updates when a contact changes and, for time-based filters ("in the last 30 days"), every hour. When a contact enters or leaves, Postal triggers the contact.segment_entered and contact.segment_exited webhooks and the automations with the segment added or removed trigger. A dynamic segment's contacts_count is the number of contacts that match, cached for up to a minute. Creating a dynamic segment requires a plan that includes it (403 plan_limit_reached otherwise); the ones already created keep working.
Filters are groups of conditions. Each group combines its conditions with match (all: every one, any: at least one) and the segment combines the groups the same way. There can be up to 10 groups and 20 conditions in total. Text comparisons ignore case, and negative operators (is_not, not_contains, has_not…) also match contacts with no value. Postal checks everything (properties, tags, topics, broadcasts…) and returns the filters in canonical form.
{
"match": "all",
"groups": [
{
"match": "all",
"conditions": [
{ "field": "tag", "operator": "has", "tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f" },
{ "field": "created_at", "operator": "in_last_days", "value": 90 }
]
},
{
"match": "any",
"conditions": [
{ "field": "property", "key": "plan", "operator": "is", "value": "pro" },
{ "field": "event", "operator": "performed", "event": "order.paid", "within_days": 30, "min_count": 2 }
]
}
]
}
Field (field) | Operators | Value and extras |
|---|---|---|
email, first_name, last_name | is, is_not, contains, not_contains, starts_with, is_empty, is_set | value: text of up to 1,000 characters (not used with is_empty and is_set). |
created_at | before, after, between, in_last_days, more_than_days_ago | value: a YYYY-MM-DD date; between takes ["YYYY-MM-DD", "YYYY-MM-DD"]; the day-based ones take a number from 1 to 3,650. |
unsubscribed | is | value: true or false. |
email_status | is, is_not | value: a list of valid, invalid, risky and unverified (the result of the email verification). |
consent | is, is_not | value: confirmed or unconfirmed (whether the contact may receive marketing; see contacts kept from transactional emails). |
origin | is, is_not | value: transactional (the contacts kept from transactional emails). |
property | Depends on the property's type: text as above; number: is, is_not, greater_than, less_than, between, is_empty, is_set; boolean: is, is_empty, is_set; date: as created_at, plus is_empty and is_set. | key: the property key; between on numbers takes [min, max]; is: false also matches contacts with no value. |
tag | has, has_not | tag_id: the ID of a tag. |
topic | subscribed, unsubscribed | topic_id: the ID of a topic. |
segment | in, not_in | segment_id: the ID of a static segment. |
email_activity | performed, not_performed | activity: received, opened, clicked, replied, bounced or complained. Optional: one of broadcast_id, template_id and automation_id; within_days (1 to 3,650; empty: ever); min_count (1 to 1,000; default 1). |
event | performed, not_performed | event: the name of the event. Optional: within_days, min_count and property ({"key": "plan", "value": "pro"}: only events with that value count). |
#List segments
GET/segments
Each segment comes with contacts_count. To see the contacts, use GET /contacts?segment_id=….
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.searchstringSearches the name.typestringstaticordynamic.
cURL
curl https://api.postal.so/segments \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/segments', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/segments",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('segments', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "segment",
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"paused_by_plan": false,
"name": "Active customers",
"description": "Purchased in the last 90 days.",
"type": "static",
"contacts_count": 1284,
"created_at": "2026-10-20T10:00:00.000000Z",
"updated_at": "2026-10-20T10:00:00.000000Z"
},
{
"object": "segment",
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"paused_by_plan": false,
"name": "Recent VIP customers",
"description": null,
"type": "dynamic",
"contacts_count": 342,
"created_at": "2026-10-20T10:00:00.000000Z",
"updated_at": "2026-10-20T10:00:00.000000Z",
"filters": {
"match": "all",
"groups": [
{
"match": "all",
"conditions": [
{
"field": "tag",
"operator": "has",
"tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f"
},
{
"field": "created_at",
"operator": "in_last_days",
"value": 90
}
]
}
]
}
}
]
}
#Create a segment
POST/segments
Request body
namestringrequiredName, up to 255 characters.descriptionstring | nullDescription, up to 500 characters.typestringstatic(default) ordynamic.filtersobjectThe filters. Required on a dynamic segment; a static one does not take filters.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
plan_limit_reachedThe plan does not allow more segments, or does not include dynamic segments. - 404
not_foundA tag, topic, segment, broadcast, template or automation named in the filters does not exist.
cURL
curl -X POST https://api.postal.so/segments \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Active customers",
"description": "Purchased in the last 90 days."
}'
Node.js
const response = await fetch('https://api.postal.so/segments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Active customers',
description: 'Purchased in the last 90 days.',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/segments",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"name": "Active customers",
"description": "Purchased in the last 90 days.",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('segments', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'name' => 'Active customers',
'description' => 'Purchased in the last 90 days.',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment",
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Active customers",
"type": "static"
}
#Create a dynamic segment
POST/segments
The same endpoint, with type: "dynamic" and filters. Use POST /segments/preview first to see how many contacts match.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
plan_limit_reachedThe plan does not allow more segments, or does not include dynamic segments.
cURL
curl -X POST https://api.postal.so/segments \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Recent VIP customers",
"type": "dynamic",
"filters": {
"match": "all",
"groups": [
{
"match": "all",
"conditions": [
{
"field": "tag",
"operator": "has",
"tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f"
},
{
"field": "created_at",
"operator": "in_last_days",
"value": 90
}
]
}
]
}
}'
Node.js
const response = await fetch('https://api.postal.so/segments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Recent VIP customers',
type: 'dynamic',
filters: {
match: 'all',
groups: [
{
match: 'all',
conditions: [
{
field: 'tag',
operator: 'has',
tag_id: '019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f',
},
{
field: 'created_at',
operator: 'in_last_days',
value: 90,
},
],
},
],
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/segments",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"name": "Recent VIP customers",
"type": "dynamic",
"filters": {
"match": "all",
"groups": [
{
"match": "all",
"conditions": [
{
"field": "tag",
"operator": "has",
"tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
},
{
"field": "created_at",
"operator": "in_last_days",
"value": 90,
},
],
},
],
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('segments', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'name' => 'Recent VIP customers',
'type' => 'dynamic',
'filters' => [
'match' => 'all',
'groups' => [
[
'match' => 'all',
'conditions' => [
[
'field' => 'tag',
'operator' => 'has',
'tag_id' => '019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f',
],
[
'field' => 'created_at',
'operator' => 'in_last_days',
'value' => 90,
],
],
],
],
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment",
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Recent VIP customers",
"type": "dynamic"
}
#Preview a segment's filters
POST/segments/preview
Checks the filters and says how many contacts match right now, without creating anything. Returns the filters in canonical form and up to 10 sample contacts (the newest ones).
Request body
filtersobjectrequiredThe filters.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundAn item named in the filters does not exist.
cURL
curl -X POST https://api.postal.so/segments/preview \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filters": {
"match": "all",
"groups": [
{
"match": "all",
"conditions": [
{
"field": "tag",
"operator": "has",
"tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f"
},
{
"field": "created_at",
"operator": "in_last_days",
"value": 90
}
]
}
]
}
}'
Node.js
const response = await fetch('https://api.postal.so/segments/preview', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
filters: {
match: 'all',
groups: [
{
match: 'all',
conditions: [
{
field: 'tag',
operator: 'has',
tag_id: '019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f',
},
{
field: 'created_at',
operator: 'in_last_days',
value: 90,
},
],
},
],
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/segments/preview",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"filters": {
"match": "all",
"groups": [
{
"match": "all",
"conditions": [
{
"field": "tag",
"operator": "has",
"tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
},
{
"field": "created_at",
"operator": "in_last_days",
"value": 90,
},
],
},
],
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('segments/preview', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'filters' => [
'match' => 'all',
'groups' => [
[
'match' => 'all',
'conditions' => [
[
'field' => 'tag',
'operator' => 'has',
'tag_id' => '019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f',
],
[
'field' => 'created_at',
'operator' => 'in_last_days',
'value' => 90,
],
],
],
],
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment_preview",
"filters": {
"match": "all",
"groups": [
{
"match": "all",
"conditions": [
{
"field": "tag",
"operator": "has",
"tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f"
},
{
"field": "created_at",
"operator": "in_last_days",
"value": 90
}
]
}
]
},
"contacts_count": 342,
"sample": [
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"external_id": "user_42",
"user_id": "user_42",
"first_name": "Jane",
"last_name": "Doe",
"unsubscribed": false,
"marketing_consent": "confirmed",
"origin": null,
"consent_confirmed_at": null,
"email_status": "valid",
"email_verified_at": "2026-10-20T09:30:00.000000Z",
"properties": {
"plan": "pro",
"city": "Austin",
"customer_since": "2024-03-18"
},
"last_change_source": "api",
"created_at": "2026-10-24T14:00:00.000000Z",
"updated_at": "2026-10-24T14:00:00.000000Z",
"tags": [
{
"id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"name": "VIP customer",
"color": "purple"
}
]
}
]
}
#Get a segment
GET/segments/{id}
A dynamic segment also returns its filters. paused_by_plan is true when the plan no longer covers the segment: it is still listed and can be read or deleted, but cannot be changed (403 paused_by_plan).
Path parameters
idstringrequiredSegment ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment",
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"paused_by_plan": false,
"name": "Active customers",
"description": "Purchased in the last 90 days.",
"type": "static",
"contacts_count": 1284,
"created_at": "2026-10-20T10:00:00.000000Z",
"updated_at": "2026-10-20T10:00:00.000000Z"
}
#Update a segment
PATCH/segments/{id}
Changing the filters of a dynamic segment recalculates its members in the background. The type cannot change.
Path parameters
idstringrequiredSegment ID.
Request body
namestringNew name.descriptionstring | nullNew description.filtersobjectNew filters (dynamic segments only).
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
paused_by_planThe segment is paused because it is over the plan's limit. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X PATCH https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Active customers (90 days)"
}'
Node.js
const response = await fetch('https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Active customers (90 days)',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"name": "Active customers (90 days)",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'name' => 'Active customers (90 days)',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment",
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
}
#Convert to static
POST/segments/{id}/convert-to-static
The contacts that match right now become the list of a static segment, which then keeps them whatever happens. Broadcasts, automations and forms that use the segment keep pointing to it.
Path parameters
idstringrequiredSegment ID.
Errors
- 409
invalid_segment_typeThe segment is already static. - 403
paused_by_planThe segment is paused by the plan. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a/convert-to-static \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a/convert-to-static', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a/convert-to-static",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a/convert-to-static', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment",
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"paused_by_plan": false,
"name": "Recent VIP customers",
"description": "Purchased in the last 90 days.",
"type": "static",
"contacts_count": 342,
"created_at": "2026-10-20T10:00:00.000000Z",
"updated_at": "2026-10-20T10:00:00.000000Z"
}
#Delete a segment
DELETE/segments/{id}
Deletes only the list: the contacts stay in the audience. A segment that receives the contacts of a form cannot be deleted; change the form's segment first.
A static segment that a dynamic segment uses in its filters cannot be deleted either: change the filters first.
Path parameters
idstringrequiredSegment ID.
Errors
- 409
segment_in_useA form sends contacts to this segment, or a dynamic segment checks who is in it. The message says which ones. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X DELETE https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->delete('segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment",
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"deleted": true
}
#Merge segments
Joins several static segments into one. The flow has two steps: the preview answers right away, with the counts, the contacts that look duplicated and everything that points to the source segments; the merge requires a choice for each linked item (resolutions) and runs in the background, with progress at GET /segments/merges/{id}. Only one merge runs per team at a time. Dynamic segments cannot be merged: convert them to static first.
Linked item (kind) | Choices (resolutions) |
|---|---|
broadcast (draft) | retarget, keep |
broadcast (scheduled) | retarget, keep, cancel |
broadcast (queued, sending or paused) | None: it keeps the segment and prevents deleting the source until it finishes. |
automation | retarget, keep and, if it is active, pause |
form, segment (dynamic) | retarget, keep |
contact_import | None: an import in progress prevents deleting the source. |
retarget points the item to the target segment, keep leaves it as is (only if the sources are not deleted), cancel cancels the schedule and pause pauses the automation for review. Each item's key is kind:id, such as broadcast:019a…; the preview lists the items in impact, with each one's choices and default.
#Preview a merge
POST/segments/merge/preview
Changes nothing. Returns, in counts, the memberships, the unique contacts, the repeated memberships and how many contacts are new to the target; in possible_duplicates, groups of contacts that look like the same mailbox under different addresses; in impact, the linked items (see the table above); in blocking, the keys of the items that prevent deleting the sources; and in warnings, notices about the deletion.
Request body
source_idsstring[]requiredFrom 2 to 20 static segment IDs, with no repeats. The target may be one of them.targetobjectrequiredWho receives the contacts:{"segment_id": "…"}for an existing segment, or{"name": "…"}to create a new one (up to 255 characters).delete_sourcesbooleanDeletes the source segments at the end. Default:false.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundA segment does not exist.
cURL
curl -X POST https://api.postal.so/segments/merge/preview \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_ids": [
"019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b"
],
"target": {
"name": "All customers"
},
"delete_sources": false
}'
Node.js
const response = await fetch('https://api.postal.so/segments/merge/preview', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source_ids: [
'019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
'019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
],
target: {
name: 'All customers',
},
delete_sources: false,
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/segments/merge/preview",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"source_ids": [
"019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
],
"target": {
"name": "All customers",
},
"delete_sources": False,
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('segments/merge/preview', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'source_ids' => [
'019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
'019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
],
'target' => [
'name' => 'All customers',
],
'delete_sources' => false,
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment_merge_preview",
"sources": [
{
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Active customers",
"contacts_count": 1284,
"past_broadcasts_count": 2
},
{
"id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"name": "Buyers",
"contacts_count": 1900,
"past_broadcasts_count": 2
}
],
"target": {
"id": null,
"name": "All customers",
"exists": false,
"contacts_count": 0
},
"delete_sources": false,
"counts": {
"memberships": 3184,
"unique_contacts": 3100,
"duplicate_memberships": 84,
"new_to_target": 3100,
"duplicate_groups": 1,
"possible_duplicate_contacts": 1,
"unique_contacts_after_merging_duplicates": 3099
},
"possible_duplicates": {
"total_groups": 1,
"total_contacts": 1,
"has_more": false,
"groups": [
{
"mailbox": "[email protected]",
"size": 2,
"contacts": [
{
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Doe",
"unsubscribed": false,
"engaged_emails": 14,
"created_at": "2026-10-24T14:00:00.000000Z",
"keep": true
},
{
"id": "019a1e00-3c4d-7e5f-8a60-7c8d9e0f1a2b",
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Doe",
"unsubscribed": false,
"engaged_emails": 0,
"created_at": "2026-10-25T09:00:00.000000Z",
"keep": false
}
]
}
]
},
"impact": [
{
"key": "broadcast:019a1f00-6e7f-7a80-9b90-1d2e3f4a5b6c",
"kind": "broadcast",
"id": "019a1f00-6e7f-7a80-9b90-1d2e3f4a5b6c",
"name": "October sale",
"status": "draft",
"segment_ids": ["019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"],
"references": null,
"choices": ["retarget", "keep"],
"default": "retarget",
"blocks_deletion": false
}
],
"blocking": [],
"warnings": []
}
#Merge segments
POST/segments/merge
Starts the merge in the background and returns it (202). Accepts the Idempotency-Key header. The linked items the preview listed need a choice in resolutions; the ones with no option (such as a broadcast being sent) prevent deleting the sources.
Request body
source_idsstring[]requiredFrom 2 to 20 static segment IDs, with no repeats. The target may be one of them.targetobjectrequiredWho receives the contacts:{"segment_id": "…"}for an existing segment, or{"name": "…"}to create a new one (up to 255 characters).delete_sourcesbooleanDeletes the source segments at the end. Default:false.merge_duplicate_contactsbooleanAlso merges contacts that look duplicated (same mailbox). Default:false.resolutionsobjectThe choice for each linked item:{"broadcast:019a…": "retarget"}, withretarget,keep,cancelorpause.
Errors
- 422
validation_errorA choice is missing for a linked item (errors.missing), the choice is not available for the item (errors.invalid), a segment is dynamic, or the data is invalid. - 409
merge_in_progressAnother merge of the team is still running. - 409
segment_in_useSome items prevent deleting the sources (errors.blocking): wait for them to finish or keep the segments. - 403
plan_limit_reachedWith a new target and without deleting the sources, the team would go over the segment limit. - 404
not_foundA segment does not exist.
cURL
curl -X POST https://api.postal.so/segments/merge \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_ids": [
"019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b"
],
"target": {
"name": "All customers"
},
"delete_sources": false,
"resolutions": {
"broadcast:019a1f00-6e7f-7a80-9b90-1d2e3f4a5b6c": "retarget"
}
}'
Node.js
const response = await fetch('https://api.postal.so/segments/merge', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source_ids: [
'019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
'019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
],
target: {
name: 'All customers',
},
delete_sources: false,
resolutions: {
'broadcast:019a1f00-6e7f-7a80-9b90-1d2e3f4a5b6c': 'retarget',
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/segments/merge",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"source_ids": [
"019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
],
"target": {
"name": "All customers",
},
"delete_sources": False,
"resolutions": {
"broadcast:019a1f00-6e7f-7a80-9b90-1d2e3f4a5b6c": "retarget",
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('segments/merge', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'source_ids' => [
'019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
'019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
],
'target' => [
'name' => 'All customers',
],
'delete_sources' => false,
'resolutions' => [
'broadcast:019a1f00-6e7f-7a80-9b90-1d2e3f4a5b6c' => 'retarget',
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment_merge",
"id": "019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0",
"status": "pending",
"stage": null,
"progress": {
"processed": 0,
"total": 0
},
"sources": [
{
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Active customers",
"contacts_count": 1284
},
{
"id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"name": "Buyers",
"contacts_count": 1900
}
],
"target": {
"id": "019a1d92-5d6e-7f70-9b8c-9d0e1f2a3b4c",
"name": "All customers"
},
"delete_sources": false,
"merge_duplicate_contacts": false,
"counts": {
"memberships": 3184,
"unique_contacts": 3100,
"duplicate_memberships": 84,
"contacts_added": 0,
"contacts_merged": 0,
"items_updated": 0,
"sources_deleted": 0
},
"summary": {
"merged_contacts": [],
"merged_contacts_total": 0,
"items": [],
"kept_sources": []
},
"error": null,
"user_id": null,
"created_at": "2026-10-24T16:00:00.000000Z",
"started_at": null,
"completed_at": null
}
#Get a merge
GET/segments/merges/{id}
status: pending, processing, completed or failed (with error). progress shows the items handled in the current step (stage), and counts and summary sum up what was done.
Path parameters
idstringrequiredMerge ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/segments/merges/019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0 \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/segments/merges/019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/segments/merges/019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('segments/merges/019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment_merge",
"id": "019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0",
"status": "processing",
"stage": "contacts",
"progress": {
"processed": 1200,
"total": 3100
},
"sources": [
{
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Active customers",
"contacts_count": 1284
},
{
"id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"name": "Buyers",
"contacts_count": 1900
}
],
"target": {
"id": "019a1d92-5d6e-7f70-9b8c-9d0e1f2a3b4c",
"name": "All customers"
},
"delete_sources": false,
"merge_duplicate_contacts": false,
"counts": {
"memberships": 3184,
"unique_contacts": 3100,
"duplicate_memberships": 84,
"contacts_added": 1200,
"contacts_merged": 0,
"items_updated": 0,
"sources_deleted": 0
},
"summary": {
"merged_contacts": [],
"merged_contacts_total": 0,
"items": [],
"kept_sources": []
},
"error": null,
"user_id": null,
"created_at": "2026-10-24T16:00:00.000000Z",
"started_at": "2026-10-24T16:00:01.000000Z",
"completed_at": null
}
#Retry a failed merge
POST/segments/merges/{id}/retry
Runs a merge with status: "failed" again; each step picks up where it stopped.
Path parameters
idstringrequiredMerge ID.
Errors
- 409
invalid_merge_statusOnly a failed merge can be retried. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/segments/merges/019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0/retry \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/segments/merges/019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0/retry', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/segments/merges/019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0/retry",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('segments/merges/019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0/retry', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "segment_merge",
"id": "019a1e40-abb2-7ec4-9f30-5b6c7d8e9fa0",
"status": "pending",
"stage": "contacts",
"progress": {
"processed": 1200,
"total": 3100
},
"sources": [
{
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Active customers",
"contacts_count": 1284
},
{
"id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"name": "Buyers",
"contacts_count": 1900
}
],
"target": {
"id": "019a1d92-5d6e-7f70-9b8c-9d0e1f2a3b4c",
"name": "All customers"
},
"delete_sources": false,
"merge_duplicate_contacts": false,
"counts": {
"memberships": 3184,
"unique_contacts": 3100,
"duplicate_memberships": 84,
"contacts_added": 1200,
"contacts_merged": 0,
"items_updated": 0,
"sources_deleted": 0
},
"summary": {
"merged_contacts": [],
"merged_contacts_total": 0,
"items": [],
"kept_sources": []
},
"error": null,
"user_id": null,
"created_at": "2026-10-24T16:00:00.000000Z",
"started_at": "2026-10-24T16:00:01.000000Z",
"completed_at": null
}