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 /contactsorPOST /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.
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}}).
Endpoints#
Create a contact#
Creates one contact. Fires the contact.created webhook and starts any active workflow triggered by contact.created.
https://api.poststack.dev/contacts// 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' },
});curl -X POST https://api.poststack.dev/contacts \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"first_name": "Alice",
"properties": { "plan": "pro" }
}'{
"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.
unsubscribedbooleandefaultfalse- 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:idevery 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
| Status | Meaning |
|---|---|
| 201 | Created. The body is { contact }. |
| 400 | Body failed validation, e.g. email: Invalid email address. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended. |
| 409 | Contact with this email already exists. Update the existing contact instead. |
| 422 | A property failed its definition: Missing required property "plan" or Invalid value for property "seats": expected a number. |
| 429 | More than 60 requests in a minute from this key. |
List contacts#
Returns one page of contacts, newest first.
https://api.poststack.dev/contactsconst { data, meta } = await poststack.contacts.list({
search: 'alice',
page: 1,
per_page: 50,
});curl "https://api.poststack.dev/contacts?search=alice&unsubscribed=false&per_page=50" \
-H "Authorization: Bearer sk_live_..."{
"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
pageintegerdefault1- Page number, from 1.
per_pageintegerdefault20- 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 a404. 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
| Status | Meaning |
|---|---|
| 200 | The page of contacts. |
| 400 | A query parameter is invalid, e.g. per_page above 100. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found — segment_id is not a segment in your team. |
Retrieve a contact#
Returns one contact by its publicId.
https://api.poststack.dev/contacts/:idconst contact = await poststack.contacts.get('con_k3v9x2m7q4w8e1r5t6y0u2i8');curl https://api.poststack.dev/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8 \
-H "Authorization: Bearer sk_live_..."{
"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
| Status | Meaning |
|---|---|
| 200 | The contact. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Contact 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.
https://api.poststack.dev/contacts/by-email/:emailconst contact = await poststack.contacts.getByEmail('alice@example.com');curl https://api.poststack.dev/contacts/by-email/alice%40example.com \
-H "Authorization: Bearer sk_live_..."{
"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
| Status | Meaning |
|---|---|
| 200 | The contact. |
| 400 | email: Invalid email address. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Contact 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.
https://api.poststack.dev/contacts/:idconst 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' },
});curl -X PATCH https://api.poststack.dev/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8 \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "first_name": "Alicia" }'{
"first_name": "Alicia",
"properties": { "plan": "enterprise", "seats": 40 }
}{
"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. Settingfalsedoes not remove a suppression. propertiesobject- Replaces the stored properties. Required-property checks are not applied on update; sending
nullor""for a defined property clears it.
Response fields
contactobject- The contact after the update.
Status codes
| Status | Meaning |
|---|---|
| 200 | Updated (or unchanged, when the body was empty). |
| 400 | Body failed validation. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended. |
| 404 | Contact not found. |
| 422 | A 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.
https://api.poststack.dev/contacts/:idawait poststack.contacts.delete('con_k3v9x2m7q4w8e1r5t6y0u2i8');curl -X DELETE https://api.poststack.dev/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8 \
-H "Authorization: Bearer sk_live_..."{
"success": true
}Path parameters
idstringrequired- The contact's
publicId(con_…).
Response fields
successboolean- Always true.
Status codes
| Status | Meaning |
|---|---|
| 200 | Deleted. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended. |
| 404 | Contact 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.
https://api.poststack.dev/contacts/:id/unsubscribeconst contact = await poststack.contacts.unsubscribe('con_k3v9x2m7q4w8e1r5t6y0u2i8');curl -X POST https://api.poststack.dev/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8/unsubscribe \
-H "Authorization: Bearer sk_live_..."{
"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
| Status | Meaning |
|---|---|
| 200 | Unsubscribed (also when it already was). |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended. |
| 404 | Contact 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.
https://api.poststack.dev/contacts/importconst result = await poststack.contacts.import({
contacts: [
{ email: 'alice@example.com', first_name: 'Alice' },
{ email: 'bob@example.com', properties: { plan: 'pro' } },
],
});
// { imported, skipped, errors }curl -X POST https://api.poststack.dev/contacts/import \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "email": "alice@example.com", "first_name": "Alice" },
{ "email": "bob@example.com", "properties": { "plan": "pro" } }
]
}'{
"contacts": [
{ "email": "alice@example.com", "first_name": "Alice" },
{ "email": "bob@example.com", "properties": { "plan": "pro" } }
]
}{
"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", not12). 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
| Status | Meaning |
|---|---|
| 200 | Import finished. Check skipped and errors — a 200 does not mean every row was created. |
| 400 | The 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. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:manage (a sending-access key never does), or the team is suspended. |
| 429 | More 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.
https://api.poststack.dev/contacts/export# 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.csvemail,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.000ZQuery parameters
segment_idstring- A segment's
seg_…id: export only its members. Works for both manual and rule-based segments.
Status codes
| Status | Meaning |
|---|---|
| 200 | The CSV body. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Segment not found — an unknown segment_id is an error here, never a silent full export. |
| 429 | More than 5 exports in a minute from this key. |
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.
https://api.poststack.dev/contacts/:id/activitycurl "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
limitintegerdefault50- 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
| Status | Meaning |
|---|---|
| 200 | The events. |
| 400 | limit is not an integer from 1 to 200. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Contact not found. |
Get contact engagement#
Lifetime sending and engagement totals for one contact.
https://api.poststack.dev/contacts/:id/engagementcurl 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
| Status | Meaning |
|---|---|
| 200 | The summary. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Contact not found. |
Get contact engagement over time#
Daily opens and clicks for one contact, one entry per UTC day, zero-filled.
https://api.poststack.dev/contacts/:id/engagement-timeseriescurl "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
daysintegerdefault30- Window length. Only
7,30and90are 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
| Status | Meaning |
|---|---|
| 200 | The series. |
| 400 | days is not an integer from 1 to 365. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_scope: the key does not hold contacts:read (a sending-access key never does), or the team is suspended. |
| 404 | Contact not found. |
Get the engagement breakdown#
How many of your subscribed contacts are in each engagement tier. Unsubscribed contacts are not counted.
https://api.poststack.dev/contacts/engagement-breakdowncurl 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
| Status | Meaning |
|---|---|
| 200 | The counts. |
| 401 | Missing, invalid or revoked API key. |
| 403 | insufficient_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.
| Event | Sent when |
|---|---|
contact.created | A contact is created with POST /contacts, in the dashboard, or by a signup form submission for a new address. |
contact.updated | PATCH /contacts/:id changed at least one field. |
contact.deleted | The contact was deleted. |
contact.unsubscribed | POST /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.