Skip to content

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.

typescript
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>',
});

Subscriber counts count records, not reach

subscriberCount counts contacts with an explicit subscribed: true record — those who subscribed through a signup form tied to the topic, the API or the dashboard. Everyone without a record also receives the topic's broadcasts, so a new topic shows 0 subscribers while reaching your whole segment.

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#

POST
/subscription-topics

Creates a topic. Every existing contact is immediately subscribed to it by default, because no contact has an opt-out record yet.

json
{
  "name": "Product updates",
  "description": "New features and release notes, about twice a month"
}
json
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"
  }
}
typescript
// 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',
});
bash
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, :topicId and topic_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

StatusMeaning
201Created. The body is { topic }.
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.
409A subscription topic named "…" already exists — names are unique within your team.
429More than 30 requests to this endpoint in a minute from this key.

List topics#

GET
/subscription-topics

Returns every topic in your team, newest first. Not paginated.

json
{
  "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
    }
  ]
}
typescript
const { topics } = await poststack.subscriptionTopics.list();
bash
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: true record — see the note above.

Status codes

StatusMeaning
200The topics.
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.

Retrieve a topic#

GET
/subscription-topics/:id

Returns one topic with its explicit subscribe and opt-out counts.

json
{
  "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
}
bash
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

StatusMeaning
200The topic.
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.
404Subscription topic not found.

List topic subscribers#

GET
/subscription-topics/:id/subscribers

Pages through the contacts with an explicit record for the topic, most recently changed first. Contacts with no record — subscribed by default — are not listed.

json
{
  "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 }
}
bash
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"default true
Send false to list the contacts who opted out.
pageintegerdefault 1
Page number, from 1.
per_pageintegerdefault 50
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

StatusMeaning
200The page.
400A query value is invalid (subscribed must be the string true or false).
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.
404Subscription topic not found.

Update a topic#

PATCH
/subscription-topics/:id

Changes the name or description. Contacts' subscription records are untouched.

json
{
  "description": "Release notes, roughly twice a month"
}
json
{
  "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"
  }
}
bash
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; null clears it.

Response fields

topicobject
The topic 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.
404Subscription topic not found.
409The new name is already used by another topic in your team.
429More than 30 requests to this endpoint in a minute from this key.

Delete a topic#

DELETE
/subscription-topics/:id

Deletes 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.

json
{
  "success": true
}
typescript
await poststack.subscriptionTopics.delete('top_a7s3d9f1g5h2j8k4l6q0w2e9');
bash
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

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.
404Subscription topic not found.
429More than 30 requests to this endpoint in a minute from this key.

Contact subscription endpoints#

List a contact's subscriptions#

GET
/subscription-topics/contacts/:contactId/subscriptions

The 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.

json
{
  "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"
      }
    }
  ]
}
typescript
const { subscriptions } = await poststack.subscriptionTopics.getContactSubscriptions(
  'con_k3v9x2m7q4w8e1r5t6y0u2i8',
);
bash
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[].subscribedboolean
false is an opt-out.
subscriptions[].updatedAtstring (ISO 8601)
When the record last changed.
subscriptions[].topicobject
The full topic object.

Status codes

StatusMeaning
200The records.
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.

Subscribe a contact to a topic#

POST
/subscription-topics/contacts/:contactId/subscriptions

Writes 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).

json
{
  "topic_id": "top_a7s3d9f1g5h2j8k4l6q0w2e9"
}
json
HTTP/1.1 201 Created

{
  "subscription": {
    "id": 501,
    "contactId": 9120,
    "topicId": 3,
    "subscribed": true,
    "updatedAt": "2026-09-25T09:00:00.000Z"
  }
}
typescript
const { subscription } = await poststack.subscriptionTopics.subscribe(
  'con_k3v9x2m7q4w8e1r5t6y0u2i8',
  'top_a7s3d9f1g5h2j8k4l6q0w2e9',
);
bash
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.subscribedboolean
false is an opt-out.
subscription.updatedAtstring (ISO 8601)
When the record last changed.

Status codes

StatusMeaning
201Subscribed; the subscription record was created.
200Subscribed; the contact already had a record for this topic (subscribed, or an opt-out that is now reversed).
400topic_id is missing.
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 or Subscription topic not found.
429More than 30 requests to this endpoint in a minute from this key.

Unsubscribe a contact from a topic#

DELETE
/subscription-topics/contacts/:contactId/subscriptions/:topicId

Records 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.

json
{
  "subscription": {
    "id": 501,
    "contactId": 9120,
    "topicId": 3,
    "subscribed": false,
    "updatedAt": "2026-09-25T09:05:00.000Z"
  }
}
typescript
await poststack.subscriptionTopics.unsubscribe(
  'con_k3v9x2m7q4w8e1r5t6y0u2i8',
  'top_a7s3d9f1g5h2j8k4l6q0w2e9',
);
bash
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

StatusMeaning
200Opted out — also when the contact 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 or Subscription topic not found.
429More than 30 requests to this endpoint in a minute from this key.

Topics and the unsubscribed flag#

ActionTopic recordsContact unsubscribedSuppression
Untick a topic in the preference centrefalse for that topicUnchangedAn earlier unsubscribe suppression is lifted
Tick unsubscribe from all in the preference centreUnchangedUnchangedAdded
DELETE …/subscriptions/:topicIdfalse for that topicUnchangedNone
{{unsubscribe_url}}UnchangedtrueAdded
POST /contacts/:id/unsubscribeUnchangedtrueNone
Signup form tied to a topictrue for that topicUnchangedUnchanged

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.

Next steps

Was this page helpful?

Related