Sign in Create account
Audience

Contacts API

The people in your audience: data, properties, segments and topic subscriptions.

Contacts are the people in your marketing audience: email address, name, custom properties, segments and topic subscriptions. Each email address appears once per team. The number of contacts depends on the marketing plan (Free: 1,000, Pro: 5,000, Scale: 50,000, Enterprise: unlimited). In endpoints with {id}, you can use either the ID or the contact's email address. To bring in many contacts at once, use the CSV import. Addresses on the suppression list never become contacts (see Create a contact).

A contact also stores an external_id (alias user_id): the person's ID in your own system, unique per team. In any endpoint with {id}, you can use external_id:user_42 (or user_id:user_42) instead of the ID or the email address. Tags label contacts without needing a segment, and the batch endpoint creates or updates up to 10,000 contacts in one call. Every endpoint on this page requires an API key with full access (a sending-only key receives 401 restricted_api_key).

Whoever receives a transactional email from you also becomes a contact, automatically, but unconfirmed for marketing: see Contacts kept from transactional emails and the marketing_consent, origin and consent_confirmed_at fields.

#Where changes come from

Every change to a contact records where it came from in last_change_source: api, dashboard, form, import, automation, partner, system, transactional, postal_lifecycle, crm:{connection id} or a label of your own. To label a change, send the X-Postal-Source header or the source body field (if both are sent, the field wins): up to 64 characters made of lowercase letters, numbers, ., _, : and -. The names Postal uses and the crm: prefix are reserved and return 422. contact.* webhooks carry the source field, and a webhook endpoint can skip sources (skip_sources), which prevents echoes between two systems that sync both ways. In the list, ?source= filters by source.

#Contacts kept from transactional emails

Every address that receives a transactional email from you is kept automatically in the team's audience, so you can later send marketing to people who already interacted with your company (someone who filled in a contact form, someone who signed up…). The recipients of POST /emails, POST /emails/batch, SMTP and the authentication emails of Supabase connections are kept, in to and cc only (never bcc). The recipients of broadcasts, automations, template and broadcast tests, forwards, inbox replies and sends to test addresses are not. The contact is created in the background, after the provider accepted the email, without delaying the send. The display name (Jane Doe <[email protected]>) becomes first_name and last_name, when there is one.

FieldValuesWhat it tells
marketing_consentconfirmed, unconfirmedWhether the contact may receive marketing (broadcasts and automations). Every contact that already existed and every one created any other way (POST /contacts, batch, events, import, form, CRM) is confirmed. A contact kept from a transactional email starts unconfirmed.
origintransactional, nullWhere the contact came from: transactional for those kept from transactional emails; null for the others. It does not change when the contact is confirmed.
consent_confirmed_atdate or nullWhen the contact was confirmed by the owner's or an admin's declaration (the confirm_consent bulk action) or by an explicit action. null for those never confirmed that way.
  • Out of broadcasts and automations. An unconfirmed contact is not in the send list of a broadcast (nor in recipients_count and GET /broadcasts/audience), does not start automations and the send email step skips anyone who is no longer confirmed. Your transactional sends to them do not change. They are really in the audience: they count toward the contact limit and show up in GET /contacts, in the counters and in segments.
  • Quiet. The capture does not trigger the contact.created webhook or any automation (contact.created, segment.added…), and does not push the contact to a connected CRM. The source of the change is transactional.
  • It never touches what exists. A contact that already exists (with its consent, topics, unsubscribe and data) stays as it is, and an address on the suppression list never becomes a contact.
  • It never goes past the plan limit. The capture uses the marketing plan's contact limit and stops when it is reached, even with overage on (overage bills contacts you added on purpose; the automatic capture bills no one). It pauses quietly and resumes by itself once there is room. The audience block of GET /usage tells you whether it is paused.
  • Default segment. On the first capture, if the plan includes dynamic segments and has room for one more, the dynamic segment Received transactional emails (origin is transactional) is created, once: if you delete it, it does not come back.
  • What confirms a contact. The bulk confirmation and explicit actions: re-importing the address from a CSV, POST /contacts/batch and POST /events with the contact, PATCH /contacts/{id} and the person signing up through a form. Confirming does not trigger contact.created, and a capture never downgrades a contact that is already confirmed.
  • Turning it off. The capture is on by default. The team switch, capture_transactional_contacts, changes with PATCH /team and {"capture_transactional_contacts": false}, but only for the owner and admins, and only from the dashboard (Settings › Team): the route uses the dashboard session and does not accept an API key. Emails that were already queued before the feature went live are not captured.

To list only these contacts, use the consent=unconfirmed and origin=transactional filters on GET /contacts; they also work in the selections of bulk actions, in exports and (as the consent and origin conditions) in dynamic segments. To see how many are waiting for confirmation, use GET /contacts/consent-summary.

#List contacts

GET/contacts

Full-access key

Newest first. The list does not include each contact's segments.

Query parameters

  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • searchstring
    Searches the email address, the external_id and the name.
  • segment_idstring
    Only contacts in a segment: those on the list, if the segment is static, or those matching its filters right now, if it is dynamic.
  • topic_idstring
    Only contacts subscribed to the topic: those who chose to receive it and, if the topic is opt_in by default, those who have not chosen anything.
  • tagstring
    Only contacts with the tag (ID or name, case-insensitive).
  • unsubscribedboolean
    true for unsubscribed contacts; false for the others.
  • email_statusstring
    Result of the email verification, comma-separated: valid, invalid, risky and unverified (never verified).
  • external_idstring
    Only the contact with this external_id (also accepted as user_id).
  • sourcestring
    Only contacts whose last change came from this source.
  • consentstring
    confirmed or unconfirmed: only contacts that may (or may not yet) receive marketing. See Contacts kept from transactional emails.
  • originstring
    transactional: only the contacts kept from transactional emails.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
GET /contacts

cURL

curl 'https://api.postal.so/contacts?segment_id=019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a&unsubscribed=false&tag=VIP+customer' \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts?segment_id=019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a&unsubscribed=false&tag=VIP+customer', {
  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/contacts",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    params={
        "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
        "unsubscribed": "false",
        "tag": "VIP customer",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('contacts', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'query' => [
        'segment_id' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
        'unsubscribed' => 'false',
        'tag' => 'VIP customer',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": true,
  "data": [
    {
      "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"
        }
      ]
    }
  ]
}

#Create a contact

POST/contacts

Full-access key

Creates the contact and, if you want, adds it to static segments, applies tags and records its topic choices in the same call. Triggers the contact.created webhook.

An address on the team's suppression list never becomes a contact: the response is 422 with email_suppressed. To add it, remove it from the suppression list first. Contacts that already exist are not affected.

Want to create or update without knowing whether the contact already exists? Use the batch, which looks the contact up by ID, external_id and email address.

Request body

  • emailstringrequired
    Email address, up to 320 characters. Stored in lowercase.
  • external_idstring | null
    The person's ID in your system, up to 255 characters, unique per team. Also accepted as user_id.
  • first_namestring | null
    First name, up to 255 characters.
  • last_namestring | null
    Last name, up to 255 characters.
  • unsubscribedboolean
    Unsubscribed from all marketing emails. Default: false.
  • propertiesobject
    Values of the properties, such as {"plan": "pro"}. Each key must exist beforehand; values are converted to the property's type.
  • segmentsstring[]
    IDs of up to 100 static segments (or {"id": "…"} objects). Dynamic segments do not take contacts by hand.
  • topicsobject[]
    Up to 100 topic choices.
    • idstringrequired
      Topic ID.
    • subscriptionstringrequired
      opt_in (wants to receive) or opt_out (does not).
  • tagsstring[]
    Up to 50 tags, by ID or by name. Names that do not exist yet are created.
  • sourcestring
    Label for the source of the change. Also accepted in the X-Postal-Source header.

Errors

  • 422validation_error
    Invalid or already registered email address, external_id already used by another contact, unknown property, a value of the wrong type, or a dynamic segment in segments.
  • 422email_suppressed
    The email address is on the team's suppression list, so it cannot become a contact.
  • 403plan_limit_reached
    The plan does not allow more contacts, counting the packs of the Contacts add-on (the message says how to keep going above the limit or add a pack, if the plan allows it).
  • 404not_found
    A segment, topic or tag (given by ID) does not exist.
POST /contacts

cURL

curl -X POST https://api.postal.so/contacts \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "external_id": "user_42",
    "first_name": "Jane",
    "last_name": "Doe",
    "properties": {
      "plan": "pro",
      "city": "Austin"
    },
    "segments": ["019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"],
    "topics": [
      {
        "id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
        "subscription": "opt_in"
      }
    ],
    "tags": ["VIP customer"]
  }'

Node.js

const response = await fetch('https://api.postal.so/contacts', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: '[email protected]',
    external_id: 'user_42',
    first_name: 'Jane',
    last_name: 'Doe',
    properties: {
      plan: 'pro',
      city: 'Austin',
    },
    segments: ['019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a'],
    topics: [
      {
        id: '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
        subscription: 'opt_in',
      },
    ],
    tags: ['VIP customer'],
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/contacts",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "email": "[email protected]",
        "external_id": "user_42",
        "first_name": "Jane",
        "last_name": "Doe",
        "properties": {
            "plan": "pro",
            "city": "Austin",
        },
        "segments": ["019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"],
        "topics": [
            {
                "id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
                "subscription": "opt_in",
            },
        ],
        "tags": ["VIP customer"],
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('contacts', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'email' => '[email protected]',
        'external_id' => 'user_42',
        'first_name' => 'Jane',
        'last_name' => 'Doe',
        'properties' => [
            'plan' => 'pro',
            'city' => 'Austin',
        ],
        'segments' => ['019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a'],
        'topics' => [
            [
                'id' => '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
                'subscription' => 'opt_in',
            ],
        ],
        'tags' => ['VIP customer'],
    ],
]);

$data = json_decode($response->getBody(), true);
201Response
{
  "object": "contact",
  "id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}

#Get a contact

GET/contacts/{id}

Full-access key

Returns the contact with the segments it belongs to and its tags. email_status and email_verified_at come from the address's latest email verification and are null if it was never verified.

Path parameters

  • idstringrequired
    Contact ID or email address.

Errors

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

cURL

curl https://api.postal.so/contacts/[email protected] \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts/[email protected]', {
  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/contacts/[email protected]",
    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('contacts/[email protected]', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "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"
    }
  ],
  "segments": [
    {
      "id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
      "name": "Active customers"
    }
  ]
}

#Update a contact

PATCH/contacts/{id}

Full-access key

Only the fields you send change. In properties, the keys you send are updated and the others stay as they are; send null or "" to clear a value. Segments and topics have their own endpoints (below). If anything changed, triggers contact.updated.

In tags, the list you send replaces the contact's tags; to just add or remove one, use the contact tag endpoints. In the batch, someone who unsubscribed is only resubscribed with proof of the new consent (resubscribe).

Updating a contact that is unconfirmed (kept from a transactional email) is an explicit action: it becomes confirmed.

Path parameters

  • idstringrequired
    Contact ID or email address.

Request body (all optional)

  • emailstring
    New email address.
  • external_idstring | null
    New external_id (or user_id).
  • first_namestring | null
    First name.
  • last_namestring | null
    Last name.
  • unsubscribedboolean
    Unsubscribes (true) or resubscribes (false).
  • propertiesobject
    Values to change.
  • tagsstring[]
    Up to 50 tags (ID or name) that become the contact's only tags.
  • sourcestring
    Label for the source of the change.

Errors

  • 422validation_error
    Email address or external_id already used by another contact, unknown property, invalid value, or segments/topics in the body.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /contacts/{id}

cURL

curl -X PATCH https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "properties": {
      "plan": "scale",
      "city": null
    }
  }'

Node.js

const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    properties: {
      plan: 'scale',
      city: null,
    },
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.patch(
    "https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "properties": {
            "plan": "scale",
            "city": None,
        },
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->patch('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'properties' => [
            'plan' => 'scale',
            'city' => null,
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "contact",
  "id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}

#Delete a contact

DELETE/contacts/{id}

Full-access key

Removes the contact from the audience, along with its segments and topic choices. Triggers contact.deleted. To just stop sending, prefer unsubscribed: true.

Path parameters

  • idstringrequired
    Contact ID or email address.

Errors

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

cURL

curl -X DELETE https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f', {
  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/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
    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('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "contact",
  "id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "deleted": true
}

#A contact's segments

GET/contacts/{id}/segments

Full-access key

The segments the contact belongs to: the static ones it was added to and the dynamic ones whose filters it matches (as last checked). Accepts limit, after and before.

Path parameters

  • idstringrequired
    Contact ID or email address.

Errors

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

cURL

curl https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/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/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/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('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/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",
      "created_at": "2026-10-20T10:00:00.000000Z",
      "updated_at": "2026-10-20T10:00:00.000000Z"
    }
  ]
}

#Add to a segment

POST/contacts/{id}/segments/{segment_id}

Full-access key

Adds the contact to the segment. If it is already there, nothing changes. Only static segments take contacts by hand.

Path parameters

  • idstringrequired
    Contact ID or email address.
  • segment_idstringrequired
    Segment ID.

Errors

  • 404not_found
    The contact or the segment does not exist.
  • 422validation_error
    The segment is dynamic: it holds whoever matches its filters, and does not take contacts by hand.
POST /contacts/{id}/segments/{segment_id}

cURL

curl -X POST https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', {
  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/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/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->post('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
201Response
{
  "object": "contact_segment",
  "contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
}

#Remove from a segment

DELETE/contacts/{id}/segments/{segment_id}

Full-access key

Path parameters

  • idstringrequired
    Contact ID or email address.
  • segment_idstringrequired
    Segment ID.

Errors

  • 404not_found
    The contact or the segment does not exist.
  • 422validation_error
    The segment is dynamic: it holds whoever matches its filters, and does not take contacts by hand.
DELETE /contacts/{id}/segments/{segment_id}

cURL

curl -X DELETE https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/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/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/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('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/segments/019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "contact_segment",
  "contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
  "deleted": true
}

#A contact's topics

GET/contacts/{id}/topics

Full-access key

All of the team's topics, in alphabetical order, with the contact's subscription: its own choice or, if it has not chosen, the topic's default.

Path parameters

  • idstringrequired
    Contact ID or email address.

Errors

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

cURL

curl https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics', {
  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/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics",
    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('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
      "name": "Product updates",
      "description": "Launches and offers, once a week.",
      "visibility": "public",
      "default_subscription": "opt_in",
      "subscription": "opt_in"
    },
    {
      "id": "019a1d95-4c5e-7f70-9b8c-9d0e1f2a3b4c",
      "name": "Surveys",
      "description": null,
      "visibility": "private",
      "default_subscription": "opt_out",
      "subscription": "opt_out"
    }
  ]
}

#Change topic subscriptions

PATCH/contacts/{id}/topics

Full-access key

Records the contact's choice on one or more topics. Topics you do not send stay as they are.

Path parameters

  • idstringrequired
    Contact ID or email address.

Request body

  • topicsobject[]required
    From 1 to 100 choices, with no repeated topic.
    • idstringrequired
      Topic ID.
    • subscriptionstringrequired
      opt_in or opt_out.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The contact or a topic does not exist.
PATCH /contacts/{id}/topics

cURL

curl -X PATCH https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "topics": [
      {
        "id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
        "subscription": "opt_out"
      }
    ]
  }'

Node.js

const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    topics: [
      {
        id: '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
        subscription: 'opt_out',
      },
    ],
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.patch(
    "https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "topics": [
            {
                "id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
                "subscription": "opt_out",
            },
        ],
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->patch('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/topics', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'topics' => [
            [
                'id' => '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
                'subscription' => 'opt_out',
            ],
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "contact",
  "id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}

#Create or update contacts in a batch

POST/contacts/batch

Full-access key

Creates or updates many contacts at once (upsert). Each item looks the contact up by id, then external_id, then email; if none is found and there is an email address, it creates one. Found by external_id, a new email address replaces the old one; found by email address, the external_id is saved if the contact did not have one yet.

Up to 1,000 items are processed in the request itself and the response has one result per item (200): an item with an error does not stop the others. Above that (up to 10,000), the batch is queued and the response is 202 with the batch id, to follow at GET /contacts/batches/{id}. The call is safe to repeat: use the Idempotency-Key header.

Rules per item: tags and segments are only added (never removed); topics are saved; properties are checked against the team's definitions and merged; addresses on the suppression list do not become contacts (the item fails with code: "email_suppressed"), but contacts that already exist can be updated. Someone who unsubscribed is not subscribed again just because the item has unsubscribed: false: the item gets the kept_unsubscribed warning. To resubscribe, send resubscribe with the date and proof of the new consent.

Headers

  • Idempotency-Keystring
    Optional. Repeating the call with the same key returns the stored response.

Request body

  • contactsobject[]required
    From 1 to 10,000 items.
    • idstring
      Contact ID (looked up first).
    • external_idstring
      The person's ID in your system (also accepted as user_id).
    • emailstring
      Email address. Required to create a contact; without id or external_id, it is the lookup key.
    • first_namestring | null
      First name.
    • last_namestring | null
      Last name.
    • unsubscribedboolean
      true unsubscribes; false only applies to new contacts or ones that were already subscribed.
    • resubscribeobject
      Resubscribes someone who unsubscribed, together with unsubscribed: false: consented_at (a date that cannot be in the future) and proof (how the person consented, up to 500 characters).
    • propertiesobject
      Property values.
    • tagsstring[]
      Up to 50 tags (ID or name), only added.
    • segmentsstring[]
      Up to 100 static segments, only added.
    • topicsobject[]
      Up to 100 {"id", "subscription"} choices.
  • sourcestring
    Label for the source of the changes. Also accepted in the X-Postal-Source header.

In each item of results, status is created, updated, unchanged or failed. Failed items carry error (and code, when there is one) and id: null; items with warnings carry warnings. index is the item's position in the list you sent.

Errors

  • 422validation_error
    contacts missing, empty or with more than 10,000 items. A problem in one item does not return this error: it is listed in results.
  • 403plan_limit_reached
    The plan does not allow more contacts: items that would create contacts fail with this message in results.
POST /contacts/batch

cURL

curl -X POST https://api.postal.so/contacts/batch \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Idempotency-Key: sync-2026-10-24-01" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      {
        "external_id": "user_42",
        "email": "[email protected]",
        "first_name": "Jane",
        "properties": {
          "plan": "pro"
        },
        "tags": ["VIP customer"]
      },
      {
        "external_id": "user_43",
        "email": "[email protected]",
        "topics": [
          {
            "id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
            "subscription": "opt_in"
          }
        ]
      }
    ]
  }'

Node.js

import { Postal } from 'postal-so'

const postal = new Postal() // reads POSTAL_API_KEY

const batch = await postal.contacts.batch([
  { external_id: 'user_42', email: '[email protected]', first_name: 'Jane', properties: { plan: 'pro' }, tags: ['VIP customer'] },
  { external_id: 'user_43', email: '[email protected]', topics: [{ id: '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b', subscription: 'opt_in' }] },
])

console.log(batch.counts) // { created: 1, updated: 1, unchanged: 0, failed: 0 }

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/contacts/batch",
    headers={
        "Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}",
        "Idempotency-Key": "sync-2026-10-24-01",
    },
    json={
        "contacts": [
            {
                "external_id": "user_42",
                "email": "[email protected]",
                "first_name": "Jane",
                "properties": {
                    "plan": "pro",
                },
                "tags": ["VIP customer"],
            },
            {
                "external_id": "user_43",
                "email": "[email protected]",
                "topics": [
                    {
                        "id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
                        "subscription": "opt_in",
                    },
                ],
            },
        ],
    },
    timeout=10,
)

data = response.json()

PHP

use Postal\Client;

$postal = new Client(); // reads POSTAL_API_KEY

$batch = $postal->contacts->batch([
    ['external_id' => 'user_42', 'email' => '[email protected]', 'first_name' => 'Jane', 'properties' => ['plan' => 'pro'], 'tags' => ['VIP customer']],
    ['external_id' => 'user_43', 'email' => '[email protected]', 'topics' => [['id' => '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b', 'subscription' => 'opt_in']]],
]);

print_r($batch['counts']); // created, updated, unchanged, failed
200Response
{
  "object": "contact_batch",
  "status": "completed",
  "total": 2,
  "counts": {
    "created": 1,
    "updated": 1,
    "unchanged": 0,
    "failed": 0
  },
  "results": [
    {
      "index": 0,
      "status": "updated",
      "id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "external_id": "user_42",
      "user_id": "user_42",
      "email": "[email protected]"
    },
    {
      "index": 1,
      "status": "created",
      "id": "019a1e00-2b3c-7d4e-8f50-6b7c8d9e0f1a",
      "external_id": "user_43",
      "user_id": "user_43",
      "email": "[email protected]"
    }
  ]
}

#Get a contact batch

GET/contacts/batches/{id}

Full-access key

Shows the progress of a large batch (above 1,000 items). status: queued, processing, completed or failed (with error). processed counts the items already handled, and results has each item's result when the batch finishes.

Path parameters

  • idstringrequired
    Batch ID.

Errors

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

cURL

curl https://api.postal.so/contacts/batches/019a1e30-9aa1-7db3-8e2f-4a5b6c7d8e9f \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts/batches/019a1e30-9aa1-7db3-8e2f-4a5b6c7d8e9f', {
  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/contacts/batches/019a1e30-9aa1-7db3-8e2f-4a5b6c7d8e9f",
    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('contacts/batches/019a1e30-9aa1-7db3-8e2f-4a5b6c7d8e9f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "contact_batch",
  "id": "019a1e30-9aa1-7db3-8e2f-4a5b6c7d8e9f",
  "status": "completed",
  "source": "api",
  "total": 2,
  "processed": 2,
  "counts": {
    "created": 1,
    "updated": 1,
    "unchanged": 0,
    "failed": 0
  },
  "error": null,
  "results": [
    {
      "index": 0,
      "status": "updated",
      "id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "external_id": "user_42",
      "user_id": "user_42",
      "email": "[email protected]"
    },
    {
      "index": 1,
      "status": "created",
      "id": "019a1e00-2b3c-7d4e-8f50-6b7c8d9e0f1a",
      "external_id": "user_43",
      "user_id": "user_43",
      "email": "[email protected]"
    }
  ],
  "created_at": "2026-10-24T14:20:00.000000Z",
  "started_at": "2026-10-24T14:20:01.000000Z",
  "completed_at": "2026-10-24T14:20:03.000000Z"
}

#Consent summary

GET/contacts/consent-summary

Full-access key

How many contacts are waiting for confirmation to receive marketing (unconfirmed, those kept from transactional emails), how many are already confirmed and the last bulk confirmation. It feeds the contacts waiting for your confirmation card in the dashboard.

unconfirmed and confirmed add up to the total number of contacts. On very large teams the number can be an estimate, and then the response has estimated: true. last_confirmation is null until someone confirms; user is null if the account of the person who confirmed no longer exists. The result is cached for 30 seconds per team and refreshed after each confirmed block.

GET /contacts/consent-summary

cURL

curl https://api.postal.so/contacts/consent-summary \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts/consent-summary', {
  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/contacts/consent-summary",
    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('contacts/consent-summary', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "contact_consent_summary",
  "unconfirmed": 4213,
  "confirmed": 9871,
  "last_confirmation": {
    "at": "2026-10-27T13:05:00Z",
    "user": {
      "id": 7,
      "name": "Carla Mendes"
    },
    "count": 1150
  }
}

#Bulk action on contacts

POST/contacts/bulk

Full-access key

Runs an action on many contacts at once: the ones you chose (ids) or all those that match some filters. The selection is pinned when the request is accepted: contacts created afterwards are not included. Up to 200 contacts finish within the request itself (200, with the finished operation); above that the response is 202 with the operation queued, to follow at GET /contacts/bulk/{id}. A team runs one bulk operation at a time.

Actions: delete, unsubscribe, resubscribe, set_topic (topic_id and subscription), add_tags and remove_tags (tags), add_to_segment and remove_from_segment (segment_id, static only), verify, export and confirm_consent. Every action is idempotent per contact: those already in the final state count as skipped and the reason goes in result.skipped_reasons. To see beforehand how many contacts the action would change, use POST /contacts/bulk/estimate with the same body (it does not ask for the declaration); POST /contacts/bulk/{id}/cancel cancels a running operation and keeps what it already did.

confirm_consent: confirm that they agreed to receive. It marks the unconfirmed contacts of the selection as confirmed (and stores consent_confirmed_at), so they start receiving broadcasts and automations. It is the declaration that these people agreed to receive marketing emails from your company and that you are responsible for that consent: that is why params.accept_responsibility must be true. Only the team's owner and admins can do it, and only from the dashboard: an API key gets 403 forbidden, and so does a regular member. Without the declaration, the response is 422.

To confirm every unconfirmed contact, select by filter: {"mode": "filter", "filters": {"consent": "unconfirmed"}}. It only changes those that are still unconfirmed; those already confirmed count as skipped (already_confirmed) and keep their date. It does not send the contact.updated webhook or start automations. The selection ceiling is 2,000,000 contacts (200,000 for the other actions). Every request writes an audit record: who confirmed, when, how many contacts, the selection, the version of the declaration text (statement_version) and the IP. Repeating the request is safe and only adds another record, with 0 contacts.

Headers

  • Idempotency-Keystring
    Optional. Repeating the call with the same key returns the stored response.

Request body

  • actionstringrequired
    The action: delete, unsubscribe, resubscribe, set_topic, add_tags, remove_tags, add_to_segment, remove_from_segment, verify, export or confirm_consent.
  • selectionobjectrequired
    Which contacts.
    • modestringrequired
      ids or filter.
    • idsstring[]
      With mode: "ids": from 1 to 1,000 contact IDs. IDs from another team are ignored.
    • filtersobject
      With mode: "filter": the same filters as GET /contacts (search, segment_id, tag, consent, origin…). An unknown filter returns 422.
    • excluded_idsstring[]
      With mode: "filter": up to 5,000 contacts to leave out.
    • expected_countinteger
      The number of contacts the person saw. If the real one is very different, the response is 409 selection_changed.
  • paramsobject
    The action's parameters.
    • accept_responsibilitybooleanrequired
      Only for confirm_consent, and required: true is the declaration that the contacts agreed to receive marketing and that you are responsible for that consent.

Errors

  • 403forbidden
    confirm_consent from an API key or from a member who is neither owner nor admin.
  • 422validation_error
    Invalid action, selection or filter, an empty selection or one over the limit, or a missing accept_responsibility on confirm_consent (the message says to send accept_responsibility: true).
  • 409selection_changed
    The number of contacts differs from expected_count; the response has the current count.
  • 409bulk_operation_running
    The team already has a bulk operation queued or running (returned in operation).
  • 401restricted_api_key
    The key is sending-only.
POST /contacts/bulk

cURL

curl -X POST https://api.postal.so/contacts/bulk \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "add_to_segment",
    "selection": {
      "mode": "ids",
      "ids": ["019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"]
    },
    "params": {
      "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
    }
  }'

Node.js

const response = await fetch('https://api.postal.so/contacts/bulk', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    action: 'add_to_segment',
    selection: {
      mode: 'ids',
      ids: ['019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f'],
    },
    params: {
      segment_id: '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
    },
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/contacts/bulk",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "action": "add_to_segment",
        "selection": {
            "mode": "ids",
            "ids": ["019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"],
        },
        "params": {
            "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
        },
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('contacts/bulk', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'action' => 'add_to_segment',
        'selection' => [
            'mode' => 'ids',
            'ids' => ['019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f'],
        ],
        'params' => [
            'segment_id' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
200Response (a small selection finishes right away)
{
  "object": "contact_bulk_operation",
  "id": "019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1",
  "action": "add_to_segment",
  "status": "completed",
  "total": 1,
  "processed": 1,
  "changed": 1,
  "skipped": 0,
  "failed": 0,
  "errors": [],
  "params": {
    "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"
  },
  "selection_mode": "ids",
  "cancel_requested": false,
  "error": null,
  "created_at": "2026-10-29T15:00:00.000000Z",
  "started_at": "2026-10-29T15:00:01.000000Z",
  "completed_at": "2026-10-29T15:00:09.000000Z",
  "created_by": null,
  "result": {
    "not_found": 0,
    "skipped_reasons": {}
  }
}
confirm_consent body (dashboard session only)
{
  "action": "confirm_consent",
  "selection": { "mode": "filter", "filters": { "consent": "unconfirmed" } },
  "params": { "accept_responsibility": true }
}
202Response for a large selection: queued
{
  "object": "contact_bulk_operation",
  "id": "019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1",
  "action": "confirm_consent",
  "status": "queued",
  "total": 4213,
  "processed": 0,
  "changed": 0,
  "skipped": 0,
  "failed": 0,
  "errors": [],
  "params": {
    "accept_responsibility": true,
    "statement_version": "2026-10-v1"
  },
  "selection_mode": "filter",
  "cancel_requested": false,
  "error": null,
  "created_at": "2026-10-29T15:00:00.000000Z",
  "started_at": null,
  "completed_at": null,
  "created_by": {
    "id": 7,
    "name": "Carla Mendes"
  },
  "result": {
    "not_found": 0
  }
}

#Get a bulk action

GET/contacts/bulk/{id}

Full-access key

Shows the progress of a bulk action. status: queued, running, completed, failed or canceled. total is the size of the pinned selection; processed, changed, skipped and failed count what has been done; errors has the first 20 contacts that failed. An operation that ends completed can have failed greater than zero. For confirm_consent, changed is how many contacts were confirmed. GET /contacts/bulk lists the operations of the last 30 days, newest first (status filter).

Path parameters

  • idstringrequired
    Operation ID.

Errors

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

cURL

curl https://api.postal.so/contacts/bulk/019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1 \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts/bulk/019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1', {
  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/contacts/bulk/019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1",
    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('contacts/bulk/019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "contact_bulk_operation",
  "id": "019a1e50-bcc3-7fd5-8a40-6c7d8e9fa0b1",
  "action": "confirm_consent",
  "status": "completed",
  "total": 4213,
  "processed": 4213,
  "changed": 4200,
  "skipped": 13,
  "failed": 0,
  "errors": [],
  "params": {
    "accept_responsibility": true,
    "statement_version": "2026-10-v1"
  },
  "selection_mode": "filter",
  "cancel_requested": false,
  "error": null,
  "created_at": "2026-10-29T15:00:00.000000Z",
  "started_at": "2026-10-29T15:00:01.000000Z",
  "completed_at": "2026-10-29T15:00:09.000000Z",
  "created_by": {
    "id": 7,
    "name": "Carla Mendes"
  },
  "result": {
    "not_found": 0,
    "skipped_reasons": {
      "already_confirmed": 13
    }
  }
}

#Tags

Tags label contacts freely, without needing a segment: "VIP customer", "Met at the event". The name has up to 64 characters and is unique per team, ignoring case. Each tag has a color: gray, green, yellow, red, blue or purple. In the contact endpoints, a tag can be given by ID or by name, and names that do not exist yet are created. Every contact that gets a new tag starts the automations with the tag added trigger (tag.added), and tags also work as filters in dynamic segments and in GET /contacts?tag=.

#Add tags to a contact

POST/contacts/{id}/tags

Full-access key

Adds the tags to the contact (the ones it already had stay as they are) and returns all of its tags. Names that do not exist are created, with the next color in the palette.

Path parameters

  • idstringrequired
    Contact ID or email address.

Request body

  • tagsstring[]required
    From 1 to 50 tags, by ID or by name.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The contact or a tag (given by ID) does not exist.
POST /contacts/{id}/tags

cURL

curl -X POST https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tags": ["VIP customer"]
  }'

Node.js

const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    tags: ['VIP customer'],
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "tags": ["VIP customer"],
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'tags' => ['VIP customer'],
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
      "name": "VIP customer",
      "color": "purple"
    }
  ]
}

#Remove a tag from a contact

DELETE/contacts/{id}/tags/{tag}

Full-access key

Takes the tag off the contact and returns the remaining tags. The tag itself still exists.

Path parameters

  • idstringrequired
    Contact ID or email address.
  • tagstringrequired
    Tag ID or name.

Errors

  • 404not_found
    The contact or the tag does not exist.
DELETE /contacts/{id}/tags/{tag}

cURL

curl -X DELETE https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', {
  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/contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
    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('contacts/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": false,
  "data": []
}

#List tags

GET/tags

Full-access key

The team's tags, each with contacts_count (how many contacts have it).

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.
GET /tags

cURL

curl https://api.postal.so/tags \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/tags', {
  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/tags",
    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('tags', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "tag",
      "id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
      "name": "VIP customer",
      "color": "purple",
      "contacts_count": 86,
      "created_at": "2026-10-20T09:00:00.000000Z",
      "updated_at": "2026-10-20T09:00:00.000000Z"
    }
  ]
}

#Create a tag

POST/tags

Full-access key

Request body

  • namestringrequired
    Name, up to 64 characters, unique per team ignoring case.
  • colorstring
    gray, green, yellow, red, blue or purple. Default: the next color in the palette.

Errors

  • 422validation_error
    Empty, too long or already used name, or a color outside the list.
POST /tags

cURL

curl -X POST https://api.postal.so/tags \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "VIP customer",
    "color": "purple"
  }'

Node.js

const response = await fetch('https://api.postal.so/tags', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'VIP customer',
    color: 'purple',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/tags",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "name": "VIP customer",
        "color": "purple",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('tags', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'name' => 'VIP customer',
        'color' => 'purple',
    ],
]);

$data = json_decode($response->getBody(), true);
201Response
{
  "object": "tag",
  "id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
  "name": "VIP customer",
  "color": "purple",
  "contacts_count": 0,
  "created_at": "2026-10-20T09:00:00.000000Z",
  "updated_at": "2026-10-20T09:00:00.000000Z"
}

#Get a tag

GET/tags/{id}

Full-access key

Path parameters

  • idstringrequired
    Tag ID.

Errors

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

cURL

curl https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', {
  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/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
    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('tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "tag",
  "id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
  "name": "VIP customer",
  "color": "purple",
  "contacts_count": 86,
  "created_at": "2026-10-20T09:00:00.000000Z",
  "updated_at": "2026-10-20T09:00:00.000000Z"
}

#Update a tag

PATCH/tags/{id}

Full-access key

Path parameters

  • idstringrequired
    Tag ID.

Request body

  • namestring
    New name.
  • colorstring
    New color.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /tags/{id}

cURL

curl -X PATCH https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "color": "blue"
  }'

Node.js

const response = await fetch('https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    color: 'blue',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.patch(
    "https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "color": "blue",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->patch('tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'color' => 'blue',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "tag",
  "id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
  "name": "VIP customer",
  "color": "blue",
  "contacts_count": 86,
  "created_at": "2026-10-20T09:00:00.000000Z",
  "updated_at": "2026-10-20T09:00:00.000000Z"
}

#Delete a tag

DELETE/tags/{id}

Full-access key

Removes the tag from every contact; the contacts stay in the audience. A tag used by an automation cannot be deleted: remove it from the automation first.

Path parameters

  • idstringrequired
    Tag ID.

Errors

  • 409tag_in_use
    An automation uses this tag. The message lists which ones.
  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /tags/{id}

cURL

curl -X DELETE https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', {
  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/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
    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('tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "tag",
  "id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
  "deleted": true
}

#Tag many contacts

POST/tags/{id}/contacts

Full-access key

Applies the tag to many contacts at once. Contacts that already had it stay as they are; added counts only the ones that got the tag now.

Path parameters

  • idstringrequired
    Tag ID.

Request body

  • contactsstring[]required
    Up to 1,000 contacts per call, each by ID or email address. Contacts that do not exist are ignored.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /tags/{id}/contacts

cURL

curl -X POST https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "[email protected]"
    ]
  }'

Node.js

const response = await fetch('https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    contacts: [
      '019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f',
      '[email protected]',
    ],
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "contacts": [
            "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
            "[email protected]",
        ],
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'contacts' => [
            '019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f',
            '[email protected]',
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "tag_contacts",
  "tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
  "contacts": 2,
  "added": 1
}

#Remove the tag from many contacts

DELETE/tags/{id}/contacts

Full-access key

Path parameters

  • idstringrequired
    Tag ID.

Request body

  • contactsstring[]required
    Up to 1,000 contacts per call, each by ID or email address. Contacts that do not exist are ignored.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /tags/{id}/contacts

cURL

curl -X DELETE https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": ["019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"]
  }'

Node.js

const response = await fetch('https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts', {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    contacts: ['019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f'],
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.delete(
    "https://api.postal.so/tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "contacts": ["019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"],
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->delete('tags/019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f/contacts', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'contacts' => ['019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f'],
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "tag_contacts",
  "tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
  "contacts": 1,
  "deleted": true
}