Skip to content

Contacts

The people you send broadcasts and workflows to: create them one at a time or in bulk, look them up by id or address, and export them as CSV.

How contacts work#

A contact is one email address in your team, plus an optional first and last name and a free-form properties object. Addresses are lower-cased on the way in and are unique per team, so Alice@Example.com and alice@example.com are the same contact.

Every endpoint below lives under https://api.poststack.dev/contacts and takes the contact's publicId (con_…) as :id — the numeric id in responses is internal and is never accepted. Reads need the contacts:read scope and writes need contacts:manage; a full-access API key has both, a sending-access key has neither. Errors are { "error": "…", "code": "…" }, as described in Errors.

Contacts reach your list three ways:

  • POST /contacts or POST /contacts/import (below).
  • The dashboard's Add contact and Import CSV buttons on the Contacts page. The CSV import parses the file in your browser and sends the rows to POST /contacts/import.
  • A signup form submission, which creates the contact or updates the existing one.

unsubscribed is not a suppression

Broadcasts skip every contact with unsubscribed: true. Setting the flag through this API does not add the address to your suppression list, so a transactional email you send with POST /emails still goes out. The {{unsubscribe_url}} link in your emails does both: it sets the flag and adds a suppression. To stop all mail to an address, add a suppression.

Properties#

properties is a JSON object of your own keys. If a key matches one of your contact property definitions, its value is checked and converted to that property's type ("12" becomes 12 on a number property), and a value that cannot be converted rejects the whole request with 422. Keys with no definition are stored exactly as sent. On create, every property marked required must be present and non-empty.

Properties are what segment rules filter on (as properties.<name>), what workflow condition steps branch on, and they are included in the payload of a workflow webhook step. Broadcast emails fill in only {{first_name}}, {{last_name}} and {{email}}; workflow emails also fill in every text, number or boolean property by name ({{plan}}).

PATCH replaces the whole properties object

PATCH /contacts/:id with a properties key overwrites the stored object — it does not merge. Keys you leave out are deleted. Read the contact first and send every property you want to keep. (Signup-form submissions are the exception: they merge.)

Endpoints#

Create a contact#

Creates one contact. Fires the contact.created webhook and starts any active workflow triggered by contact.created.

POSThttps://api.poststack.dev/contacts
Request
// The SDK returns the contact itself, not the { contact } envelope.
const contact = await poststack.contacts.create({
  email: 'alice@example.com',
  first_name: 'Alice',
  last_name: 'Smith',
  properties: { plan: 'pro', seats: '12' },
});
HTTP/1.1 201 Created

{
  "contact": {
    "id": 9120,
    "publicId": "con_k3v9x2m7q4w8e1r5t6y0u2i8",
    "teamId": 42,
    "email": "alice@example.com",
    "firstName": "Alice",
    "lastName": "Smith",
    "unsubscribed": false,
    "properties": { "plan": "pro", "seats": 12 },
    "engagementSegment": "dormant",
    "lastEngagedAt": null,
    "engagementCalculatedAt": null,
    "createdAt": "2026-09-23T10:00:00.000Z",
    "updatedAt": "2026-09-23T10:00:00.000Z"
  }
}

Body parameters

emailstringrequired
A valid email address. Lower-cased before it is stored.
first_namestring
Up to 100 characters.
last_namestring
Up to 100 characters.
unsubscribedbooleandefault false
Create the contact already opted out of broadcasts.
propertiesobject
Custom properties, validated against your property definitions (see Properties).

Response fields

contactobject
The created contact.
contact.idinteger
Internal numeric id. Not accepted by any contact endpoint — use publicId.
contact.publicIdstring
The contact id (con_ + 24 characters). This is the :id every contact endpoint takes.
contact.teamIdinteger
The team that owns the contact.
contact.emailstring
Stored lower-cased. Unique within your team.
contact.firstNamestring | null
First name.
contact.lastNamestring | null
Last name.
contact.unsubscribedboolean
true when the contact has opted out of broadcasts.
contact.propertiesobject | null
Custom properties, with defined properties normalised to their declared type.
contact.engagementSegment"active" | "at_risk" | "dormant"
Engagement tier, recalculated daily. New contacts start as dormant.
contact.lastEngagedAtstring (ISO 8601) | null
Most recent open or click, as of the last recalculation.
contact.engagementCalculatedAtstring (ISO 8601) | null
When the engagement tier was last recalculated.
contact.createdAtstring (ISO 8601)
When the contact was created.
contact.updatedAtstring (ISO 8601)
When the contact was last changed.

Status codes

StatusMeaning
201Created. The body is { contact }.
400Body failed validation, e.g. email: Invalid email address.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended.
409Contact with this email already exists. Update the existing contact instead.
422A property failed its definition: Missing required property "plan" or Invalid value for property "seats": expected a number.
429More than 60 requests in a minute from this key.

List contacts#

Returns one page of contacts, newest first.

GEThttps://api.poststack.dev/contacts
Request
const { data, meta } = await poststack.contacts.list({
  search: 'alice',
  page: 1,
  per_page: 50,
});
{
  "data": [
    {
      "id": 9120,
      "publicId": "con_k3v9x2m7q4w8e1r5t6y0u2i8",
      "teamId": 42,
      "email": "alice@example.com",
      "firstName": "Alice",
      "lastName": "Smith",
      "unsubscribed": false,
      "properties": { "plan": "pro", "seats": 12 },
      "engagementSegment": "dormant",
      "lastEngagedAt": null,
      "engagementCalculatedAt": null,
      "createdAt": "2026-09-23T10:00:00.000Z",
      "updatedAt": "2026-09-23T10:00:00.000Z"
    }
  ],
  "meta": { "page": 1, "perPage": 20, "total": 1250, "totalPages": 63 }
}

Query parameters

pageintegerdefault 1
Page number, from 1.
per_pageintegerdefault 20
Contacts per page, 1–100.
searchstring
Case-insensitive substring match on email, first name or last name.
segment_idstring
A segment's seg_… id. Only its members are listed: the contacts added by hand for a manual segment, the contacts its rules match right now for a dynamic one. Combines with the other filters. An unknown id, or another team's, is a 404.
unsubscribed"true" | "false"
Only opted-out, or only opted-in, contacts.
engagement_segment"active" | "at_risk" | "dormant"
Only contacts in this engagement tier.

Response fields

dataobject[]
The contacts on this page, each shaped like the contact object above.
meta.pageinteger
The page returned.
meta.perPageinteger
Page size used.
meta.totalinteger
Contacts matching the filters, across all pages.
meta.totalPagesinteger
ceil(total / perPage).

Status codes

StatusMeaning
200The page of contacts.
400A query parameter is invalid, e.g. per_page above 100.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Segment not found — segment_id is not a segment in your team.

Retrieve a contact#

Returns one contact by its publicId.

GEThttps://api.poststack.dev/contacts/:id
Request
const contact = await poststack.contacts.get('con_k3v9x2m7q4w8e1r5t6y0u2i8');
{
  "contact": {
    "id": 9120,
    "publicId": "con_k3v9x2m7q4w8e1r5t6y0u2i8",
    "teamId": 42,
    "email": "alice@example.com",
    "firstName": "Alice",
    "lastName": "Smith",
    "unsubscribed": false,
    "properties": { "plan": "pro", "seats": 12 },
    "engagementSegment": "dormant",
    "lastEngagedAt": null,
    "engagementCalculatedAt": null,
    "createdAt": "2026-09-23T10:00:00.000Z",
    "updatedAt": "2026-09-23T10:00:00.000Z"
  }
}

Path parameters

idstringrequired
The contact's publicId (con_…).

Response fields

contactobject
The contact object (fields as in Create a contact).

Status codes

StatusMeaning
200The contact.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Contact not found — no contact with this id in your team.

Retrieve a contact by email#

Looks a contact up by address. The match is case-insensitive.

GEThttps://api.poststack.dev/contacts/by-email/:email
Request
const contact = await poststack.contacts.getByEmail('alice@example.com');
{
  "contact": {
    "id": 9120,
    "publicId": "con_k3v9x2m7q4w8e1r5t6y0u2i8",
    "teamId": 42,
    "email": "alice@example.com",
    "firstName": "Alice",
    "lastName": "Smith",
    "unsubscribed": false,
    "properties": { "plan": "pro", "seats": 12 },
    "engagementSegment": "dormant",
    "lastEngagedAt": null,
    "engagementCalculatedAt": null,
    "createdAt": "2026-09-23T10:00:00.000Z",
    "updatedAt": "2026-09-23T10:00:00.000Z"
  }
}

Path parameters

emailstringrequired
The address, URL-encoded (@ as %40).

Response fields

contactobject
The contact object.

Status codes

StatusMeaning
200The contact.
400email: Invalid email address.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Contact not found.

Update a contact#

Changes only the fields you send. The email address cannot be changed. Fires the contact.updated webhook when something changed.

PATCHhttps://api.poststack.dev/contacts/:id
Request
const current = await poststack.contacts.get('con_k3v9x2m7q4w8e1r5t6y0u2i8');
// properties is replaced, so carry the existing keys over.
const contact = await poststack.contacts.update('con_k3v9x2m7q4w8e1r5t6y0u2i8', {
  properties: { ...current.properties, plan: 'enterprise' },
});
{
  "contact": {
    "id": 9120,
    "publicId": "con_k3v9x2m7q4w8e1r5t6y0u2i8",
    "teamId": 42,
    "email": "alice@example.com",
    "firstName": "Alicia",
    "lastName": "Smith",
    "unsubscribed": false,
    "properties": { "plan": "enterprise", "seats": 40 },
    "engagementSegment": "dormant",
    "lastEngagedAt": null,
    "engagementCalculatedAt": null,
    "createdAt": "2026-09-23T10:00:00.000Z",
    "updatedAt": "2026-09-23T10:00:00.000Z"
  }
}

Path parameters

idstringrequired
The contact's publicId (con_…).

Body parameters

first_namestring
Up to 100 characters.
last_namestring
Up to 100 characters.
unsubscribedboolean
Opt the contact out of (true) or back into (false) broadcasts. Setting false does not remove a suppression.
propertiesobject
Replaces the stored properties. Required-property checks are not applied on update; sending null or "" for a defined property clears it.

Response fields

contactobject
The contact after the update.

Status codes

StatusMeaning
200Updated (or unchanged, when the body was empty).
400Body failed validation.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended.
404Contact not found.
422A property value does not match its definition.

Delete a contact#

Permanently deletes the contact and removes it from every manual segment. Its topic subscriptions go with it. Emails already sent to the address, and any suppression for it, are kept. Fires the contact.deleted webhook.

DELETEhttps://api.poststack.dev/contacts/:id
Request
await poststack.contacts.delete('con_k3v9x2m7q4w8e1r5t6y0u2i8');
{
  "success": true
}

Path parameters

idstringrequired
The contact's publicId (con_…).

Response fields

successboolean
Always true.

Status codes

StatusMeaning
200Deleted.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended.
404Contact not found.

Unsubscribe a contact#

Sets unsubscribed to true and fires the contact.unsubscribed webhook. The contact stays in your list and in its segments; broadcasts skip it. No suppression is added. There is no request body.

POSThttps://api.poststack.dev/contacts/:id/unsubscribe
Request
const contact = await poststack.contacts.unsubscribe('con_k3v9x2m7q4w8e1r5t6y0u2i8');
{
  "contact": {
    "id": 9120,
    "publicId": "con_k3v9x2m7q4w8e1r5t6y0u2i8",
    "teamId": 42,
    "email": "alice@example.com",
    "firstName": "Alice",
    "lastName": "Smith",
    "unsubscribed": true,
    "properties": { "plan": "pro", "seats": 12 },
    "engagementSegment": "dormant",
    "lastEngagedAt": null,
    "engagementCalculatedAt": null,
    "createdAt": "2026-09-23T10:00:00.000Z",
    "updatedAt": "2026-09-23T10:00:00.000Z"
  }
}

Path parameters

idstringrequired
The contact's publicId (con_…).

Response fields

contactobject
The contact, now with unsubscribed: true.

Status codes

StatusMeaning
200Unsubscribed (also when it already was).
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended.
404Contact not found.

Import contacts#

Creates up to 10,000 contacts in one request. Addresses that already exist in your team are skipped, never updated. Imported contacts fire no webhooks and start no workflows.

POSThttps://api.poststack.dev/contacts/import
Request
const result = await poststack.contacts.import({
  contacts: [
    { email: 'alice@example.com', first_name: 'Alice' },
    { email: 'bob@example.com', properties: { plan: 'pro' } },
  ],
});
// { imported, skipped, errors }
{
  "imported": 1,
  "skipped": 1,
  "errors": []
}

Body parameters

contactsobject[]required
1–10,000 rows.
contacts[].emailstringrequired
A valid email address.
contacts[].first_namestring
Up to 100 characters.
contacts[].last_namestring
Up to 100 characters.
contacts[].propertiesobject
String values only, each up to 4,096 characters ("12", not 12). Converted to the property's type when one is defined; required properties are enforced.

Response fields

importedinteger
Contacts created.
skippedinteger
Rows not imported: the address already existed, or a property failed validation.
errorsstring[]
One "email: reason" string per row rejected for its properties (at most 100). Rows skipped as duplicates are counted but not listed.

Status codes

StatusMeaning
200Import finished. Check skipped and errors — a 200 does not mean every row was created.
400The body failed validation as a whole — one bad email or a non-string property value rejects every row, e.g. contacts.3.email: Invalid email address.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended.
429More than 10 imports in a minute from this key.

Export contacts#

Downloads your contacts as a CSV file (Content-Type: text/csv, attachment contacts.csv), newest first. Columns: email, first_name, last_name, unsubscribed, created_at. Custom properties are not exported.

GEThttps://api.poststack.dev/contacts/export
Request
# Whole list
curl https://api.poststack.dev/contacts/export \
  -H "Authorization: Bearer sk_live_..." \
  -o contacts.csv

# One segment
curl "https://api.poststack.dev/contacts/export?segment_id=seg_p8d2f6h1j5l9n3b7c4v0x6z2" \
  -H "Authorization: Bearer sk_live_..." \
  -o segment.csv
email,first_name,last_name,unsubscribed,created_at
alice@example.com,Alice,Smith,false,2026-09-23T10:00:00.000Z
bob@example.com,,,true,2026-09-20T08:12:44.000Z

Query parameters

segment_idstring
A segment's seg_… id: export only its members. Works for both manual and rule-based segments.

Status codes

StatusMeaning
200The CSV body.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Segment not found — an unknown segment_id is an error here, never a silent full export.
429More than 5 exports in a minute from this key.

The body is CSV, not JSON, so read it with response.text(). The TypeScript SDK's contacts.exportCsv() returns it as a string.

Activity and engagement#

These read-only endpoints power the contact page in the dashboard. They match the contact's address against the to recipients of every email your team sent — broadcasts, workflows and POST /emails alike. The engagement summary, time series and engagement tier leave out opens and clicks flagged as machine prefetches (Apple Mail Privacy Protection, link scanners); the activity feed lists them with is_prefetch: true.

The engagement tier is recalculated once a day for every contact with at least one real open or click: active means the last one was within 30 days, at_risk 30–90 days ago, dormant longer ago or never. A contact whose only opens were prefetches is dormant.

Get contact activity#

The contact's most recent email events, newest first, grouped by type.

GEThttps://api.poststack.dev/contacts/:id/activity
Request
curl "https://api.poststack.dev/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8/activity?limit=20&since=2026-09-01" \
  -H "Authorization: Bearer sk_live_..."
{
  "contact_id": "con_k3v9x2m7q4w8e1r5t6y0u2i8",
  "email": "alice@example.com",
  "total": 2,
  "events": {
    "sent": [],
    "delivered": [
      {
        "event_id": 88213,
        "event_type": "delivered",
        "created_at": "2026-09-24T09:01:12.000Z",
        "email_id": "em_q2w8e4r6t1y9u3i7o5p0a2s4",
        "subject": "Your September update",
        "from": "Acme <news@acme.io>",
        "geo_country": null,
        "ua_client": null,
        "is_prefetch": false,
        "metadata": null
      }
    ],
    "opened": [ { "event_id": 88240, "event_type": "opened", "...": "..." } ],
    "clicked": [], "bounced": [], "complained": [], "failed": [], "other": []
  }
}

Path parameters

idstringrequired
The contact's publicId (con_…).

Query parameters

limitintegerdefault 50
Total events across all groups, 1–200.
sincestring (ISO 8601)
Only events at or after this time. An unparseable value is ignored.

Response fields

contact_idstring
The contact publicId.
emailstring
The contact address.
totalinteger
Events returned, across all groups.
eventsobject
Arrays keyed sent (also queued and deferred), delivered, opened, clicked, bounced (also soft bounces), complained, failed, other.
events.*[].event_typestring
The raw event type, e.g. soft_bounced.
events.*[].email_idstring
The email's em_… id.
events.*[].created_atstring (ISO 8601)
When the event happened.
events.*[].subject / fromstring
Subject and From of that email.
events.*[].geo_country / ua_clientstring | null
Country and mail client, for opens and clicks.
events.*[].is_prefetchboolean
true for a machine prefetch rather than a person.
events.*[].event_id / metadatainteger / object | null
Event id and the event-specific details stored with it.

Status codes

StatusMeaning
200The events.
400limit is not an integer from 1 to 200.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Contact not found.

Get contact engagement#

Lifetime sending and engagement totals for one contact.

GEThttps://api.poststack.dev/contacts/:id/engagement
Request
curl https://api.poststack.dev/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8/engagement \
  -H "Authorization: Bearer sk_live_..."
{
  "segment": "active",
  "last_engaged_at": "2026-09-24T09:14:02.000Z",
  "engagement_calculated_at": "2026-09-25T03:00:00.000Z",
  "lifetime_sent": 18,
  "lifetime_delivered": 18,
  "lifetime_opened": 11,
  "lifetime_clicked": 4,
  "open_rate": 0.6111111111111112,
  "click_rate": 0.2222222222222222,
  "last_open": "2026-09-24T09:14:02.000Z",
  "last_click": "2026-09-19T17:40:51.000Z",
  "top_tags": [{ "tag": "newsletter", "count": 12 }]
}

Path parameters

idstringrequired
The contact's publicId (con_…).

Response fields

segment"active" | "at_risk" | "dormant"
Current engagement tier.
last_engaged_at / engagement_calculated_atstring | null
As on the contact object.
lifetime_sentinteger
Emails addressed to the contact.
lifetime_delivered / lifetime_opened / lifetime_clickedinteger
Delivered, opened and clicked events; opens and clicks exclude prefetches.
open_rate / click_ratenumber
Opens and clicks divided by deliveries, 0–1 (0 when nothing was delivered).
last_open / last_clickstring | null
Most recent open and click.
top_tags{ tag, count }[]
The five most common tags on emails sent to the contact.

Status codes

StatusMeaning
200The summary.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Contact not found.

Get contact engagement over time#

Daily opens and clicks for one contact, one entry per UTC day, zero-filled.

GEThttps://api.poststack.dev/contacts/:id/engagement-timeseries
Request
curl "https://api.poststack.dev/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8/engagement-timeseries?days=7" \
  -H "Authorization: Bearer sk_live_..."
{
  "daily": [
    { "date": "2026-09-23", "opened": 0, "clicked": 0 },
    { "date": "2026-09-24", "opened": 2, "clicked": 1 }
  ]
}

Path parameters

idstringrequired
The contact's publicId (con_…).

Query parameters

daysintegerdefault 30
Window length. Only 7, 30 and 90 are honoured; any other value from 1 to 365 is treated as 30.

Response fields

daily{ date, opened, clicked }[]
date is YYYY-MM-DD; oldest first.

Status codes

StatusMeaning
200The series.
400days is not an integer from 1 to 365.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.
404Contact not found.

Get the engagement breakdown#

How many of your subscribed contacts are in each engagement tier. Unsubscribed contacts are not counted.

GEThttps://api.poststack.dev/contacts/engagement-breakdown
Request
curl https://api.poststack.dev/contacts/engagement-breakdown \
  -H "Authorization: Bearer sk_live_..."
{
  "breakdown": { "active": 412, "at_risk": 188, "dormant": 650, "total": 1250 }
}

Response fields

breakdown.active / at_risk / dormantinteger
Contacts in each tier.
breakdown.totalinteger
Sum of the three.

Status codes

StatusMeaning
200The counts.
401Missing, invalid or revoked API key.
403insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended.

Webhook events#

Contact changes are delivered to your webhooks as these events. Imports send none of them.

EventSent when
contact.createdA contact is created with POST /contacts, in the dashboard, or by a signup form submission for a new address.
contact.updatedPATCH /contacts/:id changed at least one field.
contact.deletedThe contact was deleted.
contact.unsubscribedPOST /contacts/:id/unsubscribe, or the recipient used the unsubscribe link in an email.

Limits#

Create60 / minute
Per API key, for POST /contacts.
Import10 / minute
Per API key; up to 10,000 rows per request.
Export5 / minute
Per API key.
first_name, last_name100 characters
On create, update and import.

Every rate-limited response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 also carries Retry-After in seconds. Limits can be raised per team, so read the headers rather than hard-coding the numbers.

Troubleshooting#

409 Contact with this email already exists

Addresses are unique per team and compared lower-cased. Fetch the existing contact with GET /contacts/by-email/:email and PATCH it, or use POST /contacts/import, which skips existing addresses instead of failing.

A property disappeared after an update

PATCH replaces properties as a whole. Send the complete object, including the keys you did not change.

Import returned 200 but fewer contacts appeared

Read skipped and errors. Existing addresses are counted in skipped without an error line; rows whose properties failed a definition (or lack a required property) are listed in errors.

An unsubscribed contact still receives email

The flag only removes the contact from broadcasts. Transactional sends through POST /emails check the suppression list, not the flag — add the address with POST /suppressions.

Next steps

Was this page helpful?

Related

Contacts and audiences overviewManaging contacts, properties and consent in the dashboard.