Sign in Create account
Audience

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.

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 }
      ]
    },
    {
      "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)OperatorsValue and extras
email, first_name, last_nameis, is_not, contains, not_contains, starts_with, is_empty, is_setvalue: text of up to 1,000 characters (not used with is_empty and is_set).
created_atbefore, after, between, in_last_days, more_than_days_agovalue: 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.
unsubscribedisvalue: true or false.
email_statusis, is_notvalue: a list of valid, invalid, risky and unverified (the result of the email verification).
consentis, is_notvalue: confirmed or unconfirmed (whether the contact may receive marketing; see contacts kept from transactional emails).
originis, is_notvalue: transactional (the contacts kept from transactional emails).
propertyDepends 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.
taghas, has_nottag_id: the ID of a tag.
topicsubscribed, unsubscribedtopic_id: the ID of a topic.
segmentin, not_insegment_id: the ID of a static segment.
email_activityperformed, not_performedactivity: 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).
eventperformed, not_performedevent: 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

Full-access key

Each segment comes with contacts_count. To see the contacts, use GET /contacts?segment_id=….

Query parameters

  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • searchstring
    Searches the name.
  • typestring
    static or dynamic.
GET /segments

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);
200Response
{
  "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

Full-access key

Request body

  • namestringrequired
    Name, up to 255 characters.
  • descriptionstring | null
    Description, up to 500 characters.
  • typestring
    static (default) or dynamic.
  • filtersobject
    The filters. Required on a dynamic segment; a static one does not take filters.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 403plan_limit_reached
    The plan does not allow more segments, or does not include dynamic segments.
  • 404not_found
    A tag, topic, segment, broadcast, template or automation named in the filters does not exist.
POST /segments

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);
201Response
{
  "object": "segment",
  "id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
  "name": "Active customers",
  "type": "static"
}

#Create a dynamic segment

POST/segments

Full-access key

The same endpoint, with type: "dynamic" and filters. Use POST /segments/preview first to see how many contacts match.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 403plan_limit_reached
    The plan does not allow more segments, or does not include dynamic segments.
POST /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);
201Response
{
  "object": "segment",
  "id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
  "name": "Recent VIP customers",
  "type": "dynamic"
}

#Preview a segment's filters

POST/segments/preview

Full-access key

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

  • filtersobjectrequired
    The filters.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    An item named in the filters does not exist.
POST /segments/preview

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);
200Response
{
  "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}

Full-access key

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

  • idstringrequired
    Segment ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /segments/{id}

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);
200Response
{
  "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}

Full-access key

Changing the filters of a dynamic segment recalculates its members in the background. The type cannot change.

Path parameters

  • idstringrequired
    Segment ID.

Request body

  • namestring
    New name.
  • descriptionstring | null
    New description.
  • filtersobject
    New filters (dynamic segments only).

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 403paused_by_plan
    The segment is paused because it is over the plan's limit.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /segments/{id}

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);
200Response
{
  "object": "segment",
  "id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
}

#Convert to static

POST/segments/{id}/convert-to-static

Full-access key

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

  • idstringrequired
    Segment ID.

Errors

  • 409invalid_segment_type
    The segment is already static.
  • 403paused_by_plan
    The segment is paused by the plan.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /segments/{id}/convert-to-static

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);
200Response
{
  "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}

Full-access key

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

  • idstringrequired
    Segment ID.

Errors

  • 409segment_in_use
    A form sends contacts to this segment, or a dynamic segment checks who is in it. The message says which ones.
  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /segments/{id}

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);
200Response
{
  "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.
automationretarget, keep and, if it is active, pause
form, segment (dynamic)retarget, keep
contact_importNone: 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

Full-access key

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[]required
    From 2 to 20 static segment IDs, with no repeats. The target may be one of them.
  • targetobjectrequired
    Who receives the contacts: {"segment_id": "…"} for an existing segment, or {"name": "…"} to create a new one (up to 255 characters).
  • delete_sourcesboolean
    Deletes the source segments at the end. Default: false.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    A segment does not exist.
POST /segments/merge/preview

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);
200Response
{
  "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

Full-access key

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[]required
    From 2 to 20 static segment IDs, with no repeats. The target may be one of them.
  • targetobjectrequired
    Who receives the contacts: {"segment_id": "…"} for an existing segment, or {"name": "…"} to create a new one (up to 255 characters).
  • delete_sourcesboolean
    Deletes the source segments at the end. Default: false.
  • merge_duplicate_contactsboolean
    Also merges contacts that look duplicated (same mailbox). Default: false.
  • resolutionsobject
    The choice for each linked item: {"broadcast:019a…": "retarget"}, with retarget, keep, cancel or pause.

Errors

  • 422validation_error
    A 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.
  • 409merge_in_progress
    Another merge of the team is still running.
  • 409segment_in_use
    Some items prevent deleting the sources (errors.blocking): wait for them to finish or keep the segments.
  • 403plan_limit_reached
    With a new target and without deleting the sources, the team would go over the segment limit.
  • 404not_found
    A segment does not exist.
POST /segments/merge

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);
202Response
{
  "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}

Full-access key

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

  • idstringrequired
    Merge ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /segments/merges/{id}

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);
200Response
{
  "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

Full-access key

Runs a merge with status: "failed" again; each step picks up where it stopped.

Path parameters

  • idstringrequired
    Merge ID.

Errors

  • 409invalid_merge_status
    Only a failed merge can be retried.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /segments/merges/{id}/retry

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);
202Response
{
  "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
}