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.
| Field | Values | What it tells |
|---|---|---|
marketing_consent | confirmed, unconfirmed | Whether 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. |
origin | transactional, null | Where 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_at | date or null | When 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
unconfirmedcontact is not in the send list of a broadcast (nor inrecipients_countandGET /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 inGET /contacts, in the counters and in segments. - Quiet. The capture does not trigger the
contact.createdwebhook or any automation (contact.created,segment.added…), and does not push the contact to a connected CRM. The source of the change istransactional. - 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
audienceblock ofGET /usagetells 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 (
originistransactional) 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/batchandPOST /eventswith the contact,PATCH /contacts/{id}and the person signing up through a form. Confirming does not triggercontact.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 withPATCH /teamand{"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
Newest first. The list does not include each contact's segments.
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.searchstringSearches the email address, theexternal_idand the name.segment_idstringOnly 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_idstringOnly contacts subscribed to the topic: those who chose to receive it and, if the topic isopt_inby default, those who have not chosen anything.tagstringOnly contacts with the tag (ID or name, case-insensitive).unsubscribedbooleantruefor unsubscribed contacts;falsefor the others.email_statusstringResult of the email verification, comma-separated:valid,invalid,riskyandunverified(never verified).external_idstringOnly the contact with thisexternal_id(also accepted asuser_id).sourcestringOnly contacts whose last change came from this source.consentstringconfirmedorunconfirmed: only contacts that may (or may not yet) receive marketing. See Contacts kept from transactional emails.originstringtransactional: only the contacts kept from transactional emails.
Errors
- 422
validation_errorA field is invalid. The message says which one.
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);
{
"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
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
emailstringrequiredEmail address, up to 320 characters. Stored in lowercase.external_idstring | nullThe person's ID in your system, up to 255 characters, unique per team. Also accepted asuser_id.first_namestring | nullFirst name, up to 255 characters.last_namestring | nullLast name, up to 255 characters.unsubscribedbooleanUnsubscribed from all marketing emails. Default:false.propertiesobjectValues 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.idstringrequiredTopic ID.subscriptionstringrequiredopt_in(wants to receive) oropt_out(does not).
tagsstring[]Up to 50 tags, by ID or by name. Names that do not exist yet are created.sourcestringLabel for the source of the change. Also accepted in theX-Postal-Sourceheader.
Errors
- 422
validation_errorInvalid or already registered email address,external_idalready used by another contact, unknown property, a value of the wrong type, or a dynamic segment insegments. - 422
email_suppressedThe email address is on the team's suppression list, so it cannot become a contact. - 403
plan_limit_reachedThe 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). - 404
not_foundA segment, topic or tag (given by ID) does not exist.
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);
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}
#Get a contact
GET/contacts/{id}
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
idstringrequiredContact ID or email address.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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}
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
idstringrequiredContact ID or email address.
Request body (all optional)
emailstringNew email address.external_idstring | nullNewexternal_id(oruser_id).first_namestring | nullFirst name.last_namestring | nullLast name.unsubscribedbooleanUnsubscribes (true) or resubscribes (false).propertiesobjectValues to change.tagsstring[]Up to 50 tags (ID or name) that become the contact's only tags.sourcestringLabel for the source of the change.
Errors
- 422
validation_errorEmail address orexternal_idalready used by another contact, unknown property, invalid value, orsegments/topicsin the body. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}
#Delete a contact
DELETE/contacts/{id}
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
idstringrequiredContact ID or email address.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"deleted": true
}
#A contact's segments
GET/contacts/{id}/segments
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
idstringrequiredContact ID or email address.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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}
Adds the contact to the segment. If it is already there, nothing changes. Only static segments take contacts by hand.
Path parameters
idstringrequiredContact ID or email address.segment_idstringrequiredSegment ID.
Errors
- 404
not_foundThe contact or the segment does not exist. - 422
validation_errorThe segment is dynamic: it holds whoever matches its filters, and does not take contacts by hand.
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);
{
"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}
Path parameters
idstringrequiredContact ID or email address.segment_idstringrequiredSegment ID.
Errors
- 404
not_foundThe contact or the segment does not exist. - 422
validation_errorThe segment is dynamic: it holds whoever matches its filters, and does not take contacts by hand.
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);
{
"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
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
idstringrequiredContact ID or email address.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
Records the contact's choice on one or more topics. Topics you do not send stay as they are.
Path parameters
idstringrequiredContact ID or email address.
Request body
topicsobject[]requiredFrom 1 to 100 choices, with no repeated topic.idstringrequiredTopic ID.subscriptionstringrequiredopt_inoropt_out.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe contact or a topic does not exist.
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);
{
"object": "contact",
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}
#Create or update contacts in a batch
POST/contacts/batch
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-KeystringOptional. Repeating the call with the same key returns the stored response.
Request body
contactsobject[]requiredFrom 1 to 10,000 items.idstringContact ID (looked up first).external_idstringThe person's ID in your system (also accepted asuser_id).emailstringEmail address. Required to create a contact; withoutidorexternal_id, it is the lookup key.first_namestring | nullFirst name.last_namestring | nullLast name.unsubscribedbooleantrueunsubscribes;falseonly applies to new contacts or ones that were already subscribed.resubscribeobjectResubscribes someone who unsubscribed, together withunsubscribed: false:consented_at(a date that cannot be in the future) andproof(how the person consented, up to 500 characters).propertiesobjectProperty 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.
sourcestringLabel for the source of the changes. Also accepted in theX-Postal-Sourceheader.
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
- 422
validation_errorcontactsmissing, empty or with more than 10,000 items. A problem in one item does not return this error: it is listed inresults. - 403
plan_limit_reachedThe plan does not allow more contacts: items that would create contacts fail with this message inresults.
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
{
"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}
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
idstringrequiredBatch ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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.
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);
{
"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
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-KeystringOptional. Repeating the call with the same key returns the stored response.
Request body
actionstringrequiredThe action:delete,unsubscribe,resubscribe,set_topic,add_tags,remove_tags,add_to_segment,remove_from_segment,verify,exportorconfirm_consent.selectionobjectrequiredWhich contacts.modestringrequiredidsorfilter.idsstring[]Withmode: "ids": from 1 to 1,000 contact IDs. IDs from another team are ignored.filtersobjectWithmode: "filter": the same filters asGET /contacts(search,segment_id,tag,consent,origin…). An unknown filter returns422.excluded_idsstring[]Withmode: "filter": up to 5,000 contacts to leave out.expected_countintegerThe number of contacts the person saw. If the real one is very different, the response is409 selection_changed.
paramsobjectThe action's parameters.accept_responsibilitybooleanrequiredOnly forconfirm_consent, and required:trueis the declaration that the contacts agreed to receive marketing and that you are responsible for that consent.
Errors
- 403
forbiddenconfirm_consentfrom an API key or from a member who is neither owner nor admin. - 422
validation_errorInvalid action, selection or filter, an empty selection or one over the limit, or a missingaccept_responsibilityonconfirm_consent(the message says to sendaccept_responsibility: true). - 409
selection_changedThe number of contacts differs fromexpected_count; the response has the currentcount. - 409
bulk_operation_runningThe team already has a bulk operation queued or running (returned inoperation). - 401
restricted_api_keyThe key is sending-only.
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);
{
"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": {}
}
}
{
"action": "confirm_consent",
"selection": { "mode": "filter", "filters": { "consent": "unconfirmed" } },
"params": { "accept_responsibility": true }
}
{
"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}
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
idstringrequiredOperation ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredContact ID or email address.
Request body
tagsstring[]requiredFrom 1 to 50 tags, by ID or by name.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe contact or a tag (given by ID) does not exist.
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);
{
"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}
Takes the tag off the contact and returns the remaining tags. The tag itself still exists.
Path parameters
idstringrequiredContact ID or email address.tagstringrequiredTag ID or name.
Errors
- 404
not_foundThe contact or the tag does not exist.
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);
{
"object": "list",
"has_more": false,
"data": []
}
#List tags
GET/tags
The team's tags, each with contacts_count (how many contacts have it).
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.searchstringSearches the name.
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);
{
"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
Request body
namestringrequiredName, up to 64 characters, unique per team ignoring case.colorstringgray,green,yellow,red,blueorpurple. Default: the next color in the palette.
Errors
- 422
validation_errorEmpty, too long or already used name, or a color outside the list.
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);
{
"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}
Path parameters
idstringrequiredTag ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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}
Path parameters
idstringrequiredTag ID.
Request body
namestringNew name.colorstringNew color.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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}
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
idstringrequiredTag ID.
Errors
- 409
tag_in_useAn automation uses this tag. The message lists which ones. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "tag",
"id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"deleted": true
}
#Tag many contacts
POST/tags/{id}/contacts
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
idstringrequiredTag ID.
Request body
contactsstring[]requiredUp to 1,000 contacts per call, each by ID or email address. Contacts that do not exist are ignored.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "tag_contacts",
"tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"contacts": 2,
"added": 1
}
#Remove the tag from many contacts
DELETE/tags/{id}/contacts
Path parameters
idstringrequiredTag ID.
Request body
contactsstring[]requiredUp to 1,000 contacts per call, each by ID or email address. Contacts that do not exist are ignored.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "tag_contacts",
"tag_id": "019a1d70-6a7b-7c8d-9e0f-1a2b3c4d5e6f",
"contacts": 1,
"deleted": true
}