Subscription topics
Categories of email — product updates, offers, a weekly digest — that a contact can opt out of one at a time, instead of unsubscribing from everything.
How topics work#
Topics are opt-out. A contact with no record for a topic counts as subscribed to it. Only an explicit record with subscribed: false opts them out, and such a record is written when:
- the recipient unticks the topic in the hosted preference centre,
- you call Unsubscribe a contact from a topic, or
- you switch the topic off on the contact's page in the dashboard.
A topic only filters a send when the broadcast names it with topic_id: contacts in the segment who opted out of that topic are skipped, on top of the unsubscribed and suppressed contacts every broadcast skips. A broadcast without topic_id ignores topics entirely. Transactional email sent with POST /emails never looks at topics.
await poststack.broadcasts.create({
segment_id: 'seg_p8d2f6h1j5l9n3b7c4v0x6z2',
topic_id: 'top_a7s3d9f1g5h2j8k4l6q0w2e9', // skip contacts who opted out of this topic
from: 'Acme <news@acme.io>',
subject: 'What shipped in September',
html: '<p>…</p><p><a href="{{preferences_url}}">Email preferences</a></p>',
});Endpoints live under https://api.poststack.dev/subscription-topics. Reads need the contacts:read scope, writes need contacts:manage.
The preference centre#
Put {{preferences_url}} in a broadcast or template and each recipient gets a signed link to a hosted page listing every topic in your team with a checkbox. Saving the page writes one record per topic — ticked topics subscribed: true, unticked ones false — and lifts an earlier unsubscribe suppression for the address (never a bounce or complaint suppression). A recipient who is not a contact sees the topics, but only the global choice is saved for them.
The page also has an unsubscribe from all box. Saving with it ticked adds the address to your suppression list and leaves the topic records as they were.
{{unsubscribe_url}} is different: it unsubscribes the recipient from all broadcasts and adds a suppression. It does not touch topic records.
Topic endpoints#
Create a topic#
/subscription-topicsCreates a topic. Every existing contact is immediately subscribed to it by default, because no contact has an opt-out record yet.
{
"name": "Product updates",
"description": "New features and release notes, about twice a month"
}HTTP/1.1 201 Created
{
"topic": {
"id": 3,
"publicId": "top_a7s3d9f1g5h2j8k4l6q0w2e9",
"teamId": 42,
"name": "Product updates",
"description": "New features and release notes, about twice a month",
"createdAt": "2026-09-20T10:00:00.000Z",
"updatedAt": "2026-09-20T10:00:00.000Z"
}
}// Unlike most resources, the SDK returns the { topic } envelope here.
const { topic } = await poststack.subscriptionTopics.create({
name: 'Product updates',
description: 'New features and release notes, about twice a month',
});curl -X POST https://api.poststack.dev/subscription-topics \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "name": "Product updates" }'Body parameters
namestringrequired- 1–100 characters, unique in your team.
descriptionstring- Up to 500 characters.
Response fields
topicobject- The new topic.
topic.idinteger- Internal numeric id. Not accepted by any endpoint — use publicId.
topic.publicIdstring- The topic id (
top_+ 24 characters), used as:id,:topicIdandtopic_id. topic.teamIdinteger- The team that owns the topic.
topic.namestring- Shown to recipients in the preference centre.
topic.descriptionstring | null- Shown under the name in the preference centre.
topic.createdAtstring (ISO 8601)- When the topic was created.
topic.updatedAtstring (ISO 8601)- When it was last changed.
Status codes
| Status | Meaning |
|---|---|
| 201 | Created. The body is { topic }. |
| 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. |
| 409 | A subscription topic named "…" already exists — names are unique within your team. |
| 429 | More than 30 requests to this endpoint in a minute from this key. |
List topics#
/subscription-topicsReturns every topic in your team, newest first. Not paginated.
{
"topics": [
{
"id": 3,
"publicId": "top_a7s3d9f1g5h2j8k4l6q0w2e9",
"teamId": 42,
"name": "Product updates",
"description": "New features and release notes, about twice a month",
"createdAt": "2026-09-20T10:00:00.000Z",
"subscriberCount": 204
}
]
}const { topics } = await poststack.subscriptionTopics.list();curl https://api.poststack.dev/subscription-topics \
-H "Authorization: Bearer sk_live_..."Response fields
topics[].id / publicId / teamId / name / description / createdAt—- As on the topic object (no updatedAt here).
topics[].subscriberCountinteger- Contacts with an explicit
subscribed: truerecord — see the note above.
Status codes
| Status | Meaning |
|---|---|
| 200 | The topics. |
| 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. |
Retrieve a topic#
/subscription-topics/:idReturns one topic with its explicit subscribe and opt-out counts.
{
"topic": {
"id": 3,
"publicId": "top_a7s3d9f1g5h2j8k4l6q0w2e9",
"teamId": 42,
"name": "Product updates",
"description": "New features and release notes, about twice a month",
"createdAt": "2026-09-20T10:00:00.000Z",
"updatedAt": "2026-09-20T10:00:00.000Z"
},
"subscriberCount": 204,
"unsubscribedCount": 17
}curl https://api.poststack.dev/subscription-topics/top_a7s3d9f1g5h2j8k4l6q0w2e9 \
-H "Authorization: Bearer sk_live_..."Path parameters
idstringrequired- The topic's
publicId(top_…).
Response fields
topicobject- The topic.
subscriberCountinteger- Contacts with
subscribed: true. unsubscribedCountinteger- Contacts with
subscribed: false— the ones topic broadcasts skip.
Status codes
| Status | Meaning |
|---|---|
| 200 | The topic. |
| 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 | Subscription topic not found. |
List topic subscribers#
/subscription-topics/:id/subscribersPages through the contacts with an explicit record for the topic, most recently changed first. Contacts with no record — subscribed by default — are not listed.
{
"data": [
{
"contactId": 9120,
"contactPublicId": "con_k3v9x2m7q4w8e1r5t6y0u2i8",
"email": "alice@example.com",
"firstName": "Alice",
"lastName": "Smith",
"subscribedAt": "2026-09-24T08:00:00.000Z"
}
],
"meta": { "page": 1, "perPage": 50, "total": 17, "totalPages": 1 }
}curl "https://api.poststack.dev/subscription-topics/top_a7s3d9f1g5h2j8k4l6q0w2e9/subscribers?subscribed=false" \
-H "Authorization: Bearer sk_live_..."Path parameters
idstringrequired- The topic's
publicId(top_…).
Query parameters
subscribed"true" | "false"defaulttrue- Send
falseto list the contacts who opted out. pageintegerdefault1- Page number, from 1.
per_pageintegerdefault50- 1–100.
Response fields
data[].contactPublicIdstring- The contact's
con_…id. data[].contactIdinteger- Its internal numeric id.
data[].email / firstName / lastNamestring / string | null- The contact.
data[].subscribedAtstring (ISO 8601)- When the record was last changed — the subscribe time, or the opt-out time when subscribed=false.
meta.page / perPage / total / totalPagesinteger- Pagination.
Status codes
| Status | Meaning |
|---|---|
| 200 | The page. |
| 400 | A query value is invalid (subscribed must be the string true or false). |
| 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 | Subscription topic not found. |
Update a topic#
/subscription-topics/:idChanges the name or description. Contacts' subscription records are untouched.
{
"description": "Release notes, roughly twice a month"
}{
"topic": {
"id": 3,
"publicId": "top_a7s3d9f1g5h2j8k4l6q0w2e9",
"teamId": 42,
"name": "Product updates",
"description": "Release notes, roughly twice a month",
"createdAt": "2026-09-20T10:00:00.000Z",
"updatedAt": "2026-09-25T11:30:00.000Z"
}
}curl -X PATCH https://api.poststack.dev/subscription-topics/top_a7s3d9f1g5h2j8k4l6q0w2e9 \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "description": "Release notes, roughly twice a month" }'Path parameters
idstringrequired- The topic's
publicId(top_…).
Body parameters
namestring- 1–100 characters, unique in your team.
descriptionstring | null- Up to 500 characters;
nullclears it.
Response fields
topicobject- The topic 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 | Subscription topic not found. |
| 409 | The new name is already used by another topic in your team. |
| 429 | More than 30 requests to this endpoint in a minute from this key. |
Delete a topic#
/subscription-topics/:idDeletes the topic and every contact's record for it, opt-outs included. The contacts are kept. Broadcasts and signup forms that named the topic are left without one — a draft broadcast then sends to its whole segment.
{
"success": true
}await poststack.subscriptionTopics.delete('top_a7s3d9f1g5h2j8k4l6q0w2e9');curl -X DELETE https://api.poststack.dev/subscription-topics/top_a7s3d9f1g5h2j8k4l6q0w2e9 \
-H "Authorization: Bearer sk_live_..."Path parameters
idstringrequired- The topic's
publicId(top_…).
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 | Subscription topic not found. |
| 429 | More than 30 requests to this endpoint in a minute from this key. |
Contact subscription endpoints#
List a contact's subscriptions#
/subscription-topics/contacts/:contactId/subscriptionsThe contact's explicit topic records, each with its topic. A topic with no record is not listed — the contact is subscribed to it by default.
{
"subscriptions": [
{
"id": 501,
"contactId": 9120,
"topicId": 3,
"subscribed": false,
"updatedAt": "2026-09-24T08:00:00.000Z",
"topic": {
"id": 3,
"publicId": "top_a7s3d9f1g5h2j8k4l6q0w2e9",
"teamId": 42,
"name": "Product updates",
"description": "New features and release notes, about twice a month",
"createdAt": "2026-09-20T10:00:00.000Z",
"updatedAt": "2026-09-20T10:00:00.000Z"
}
}
]
}const { subscriptions } = await poststack.subscriptionTopics.getContactSubscriptions(
'con_k3v9x2m7q4w8e1r5t6y0u2i8',
);curl https://api.poststack.dev/subscription-topics/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8/subscriptions \
-H "Authorization: Bearer sk_live_..."Path parameters
contactIdstringrequired- The contact's
publicId(con_…).
Response fields
subscriptionsobject[]- The records. Empty for a contact with none — and also for an id that is not a contact in your team (no
404). subscriptions[].idinteger- Internal id of the subscription record.
subscriptions[].contactIdinteger- The contact's internal numeric id (not its
con_…id). subscriptions[].topicIdinteger- The topic's internal numeric id (not its
top_…id). subscriptions[].subscribedbooleanfalseis an opt-out.subscriptions[].updatedAtstring (ISO 8601)- When the record last changed.
subscriptions[].topicobject- The full topic object.
Status codes
| Status | Meaning |
|---|---|
| 200 | The records. |
| 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. |
Subscribe a contact to a topic#
/subscription-topics/contacts/:contactId/subscriptionsWrites subscribed: true for the contact and topic, creating the record or reversing an opt-out. It does not change the contact's global unsubscribed flag, and it does not start contact.subscribed workflows (only signup forms do).
{
"topic_id": "top_a7s3d9f1g5h2j8k4l6q0w2e9"
}HTTP/1.1 201 Created
{
"subscription": {
"id": 501,
"contactId": 9120,
"topicId": 3,
"subscribed": true,
"updatedAt": "2026-09-25T09:00:00.000Z"
}
}const { subscription } = await poststack.subscriptionTopics.subscribe(
'con_k3v9x2m7q4w8e1r5t6y0u2i8',
'top_a7s3d9f1g5h2j8k4l6q0w2e9',
);curl -X POST https://api.poststack.dev/subscription-topics/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8/subscriptions \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "topic_id": "top_a7s3d9f1g5h2j8k4l6q0w2e9" }'Path parameters
contactIdstringrequired- The contact's
publicId(con_…).
Body parameters
topic_idstringrequired- The topic's
publicId(top_…).
Response fields
subscriptionobject- The record.
subscription.idinteger- Internal id of the subscription record.
subscription.contactIdinteger- The contact's internal numeric id (not its
con_…id). subscription.topicIdinteger- The topic's internal numeric id (not its
top_…id). subscription.subscribedbooleanfalseis an opt-out.subscription.updatedAtstring (ISO 8601)- When the record last changed.
Status codes
| Status | Meaning |
|---|---|
| 201 | Subscribed; the subscription record was created. |
| 200 | Subscribed; the contact already had a record for this topic (subscribed, or an opt-out that is now reversed). |
| 400 | topic_id is missing. |
| 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 or Subscription topic not found. |
| 429 | More than 30 requests to this endpoint in a minute from this key. |
Unsubscribe a contact from a topic#
/subscription-topics/contacts/:contactId/subscriptions/:topicIdRecords an opt-out (subscribed: false). The record is kept rather than deleted, because deleting it would put the contact back to subscribed-by-default. The contact still gets broadcasts without this topic.
{
"subscription": {
"id": 501,
"contactId": 9120,
"topicId": 3,
"subscribed": false,
"updatedAt": "2026-09-25T09:05:00.000Z"
}
}await poststack.subscriptionTopics.unsubscribe(
'con_k3v9x2m7q4w8e1r5t6y0u2i8',
'top_a7s3d9f1g5h2j8k4l6q0w2e9',
);curl -X DELETE https://api.poststack.dev/subscription-topics/contacts/con_k3v9x2m7q4w8e1r5t6y0u2i8/subscriptions/top_a7s3d9f1g5h2j8k4l6q0w2e9 \
-H "Authorization: Bearer sk_live_..."Path parameters
contactIdstringrequired- The contact's
publicId(con_…). topicIdstringrequired- The topic's
publicId(top_…).
Response fields
subscriptionobject- The record, now with
subscribed: false.
Status codes
| Status | Meaning |
|---|---|
| 200 | Opted out — also when the contact 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 or Subscription topic not found. |
| 429 | More than 30 requests to this endpoint in a minute from this key. |
Topics and the unsubscribed flag#
| Action | Topic records | Contact unsubscribed | Suppression |
|---|---|---|---|
| Untick a topic in the preference centre | false for that topic | Unchanged | An earlier unsubscribe suppression is lifted |
| Tick unsubscribe from all in the preference centre | Unchanged | Unchanged | Added |
DELETE …/subscriptions/:topicId | false for that topic | Unchanged | None |
{{unsubscribe_url}} | Unchanged | true | Added |
POST /contacts/:id/unsubscribe | Unchanged | true | None |
| Signup form tied to a topic | true for that topic | Unchanged | Unchanged |
A topic broadcast reaches a contact only if every column allows it. Subscribing a contact to a topic through the API does not undo a global unsubscribe or remove a suppression.
Troubleshooting#
A new topic shows 0 subscribers
Expected. The count is of explicit subscribe records; contacts without one are subscribed by default and still receive the topic's broadcasts.
Someone who opted out of a topic still got a broadcast
The broadcast had no topic_id, so topics were not checked. Set the topic on every broadcast that belongs to one.
Subscribing a contact did not make them receive broadcasts
The contact is unsubscribed globally or the address is suppressed. Check unsubscribed on the contact and the suppression list.