Set a contact's topic choicesMenu
On this page

Set a contact's topic choices

Updated

Create a topic, record a contact's choice on it per channel, and read the choice back to check it.

Topics let a contact choose which kinds of marketing they get, such as product news but not weekly offers, without leaving your marketing altogether. This recipe creates a topic, records a contact’s choice on it from your own preference centre, and reads the choice back to check it.

Every POST here carries an Idempotency-Key, so running a step twice is safe: the second run returns the first response. If you change a request’s body, change its key too, or the API returns 409 IDEMPOTENCY_KEY_REUSED.

Before you start

  • An API key with topics:write, contacts:write and contacts:read. An agent key cannot change a contact’s choices. See Authentication.
  • A contact with your own id, customer-4821 here, created with POST /v1/contacts/identify. See IDs and identity.
  • cURL and jq, and KIRORA_API_URL and KIRORA_API_KEY set as in the Quickstart.

How a topic reaches a contact

A campaign or journey email or push that names a topic reaches a contact only if they have consent on that channel and the topic reaches them. A topic’s mode decides the starting point:

mode Reaches
opt_out (the default) Everyone who has not left it
opt_in Only those who joined it

A choice can hold for every channel, or for one channel only, such as product news by email but not by push.

1 Create the topic

Topic names are unique in the workspace: creating one that exists returns 409 ALREADY_EXISTS. Skip this step if the topic exists, and take its id from GET /v1/topics.

Terminal
curl "$KIRORA_API_URL/v1/topics" \
  --fail-with-body --silent --show-error -o topic.json \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: recipe-topic-create-001" \
  -d '{
    "name": "Product news",
    "description": "New products and features, about once a month",
    "mode": "opt_in"
  }'
TOPIC_ID=$(jq -er '.data.id' topic.json)

The response is 201 with the topic.

2 Record the contact’s choice

PUT /v1/contacts/{id}/topics/{topicId} (setContactTopic) sets one topic’s choice, in the shape identify’s topics takes, and ContactTopic answers with: subscribed is true or false for every channel, and channels holds a choice per channel, { "email": …, "push": … }. Give either or both. With idType=externalId, {id} is your own id. Joining on any channel needs a statement, how the contact agreed, which is kept as the proof; without it the request returns 400 VALIDATION_ERROR at statement.

The contact joins Product news by email:

Terminal
curl -X PUT "$KIRORA_API_URL/v1/contacts/customer-4821/topics/$TOPIC_ID?idType=externalId" \
  --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channels": { "email": true },
    "statement": {
      "basis": "opted_in",
      "evidence": "Preference centre on juniperkitchen.com",
      "wording": "Email me about new products"
    }
  }'

The response is 200 with the topic and the contact’s choice applied:

JSON
{
  "data": {
    "id": "0192f3b0-6a1b-7c2d-8e3f-4a5b6c7d8e9f",
    "name": "Product news",
    "description": "New products and features, about once a month",
    "mode": "opt_in",
    "createdAt": "2026-10-23T09:00:00.000Z",
    "updatedAt": "2026-10-23T09:00:00.000Z",
    "subscribed": false,
    "channels": { "email": true, "push": false }
  }
}

channels is the choice on each channel; a channel’s own choice wins over subscribed there. Here email reaches the contact and push, left out of the request, keeps the topic’s default, which for an opt_in topic is no.

Leaving needs no statement. A plain true or false sets the choice for every channel at once, which also clears the choices made per channel:

Terminal
curl -X PUT "$KIRORA_API_URL/v1/contacts/customer-4821/topics/$TOPIC_ID?idType=externalId" \
  --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "subscribed": false }'

A PUT sets a value, so repeating it sets the same value again and needs no Idempotency-Key.

To set several topics in one call, send them in topics on POST /v1/contacts/identify, keyed by topic id, each in the same shape. Joining through identify needs the consent.statement in the same body.

3 Read the choices back

GET /v1/contacts/{id}/topics (listContactTopics) returns every topic with whether it reaches the contact:

Terminal
curl "$KIRORA_API_URL/v1/contacts/customer-4821/topics?idType=externalId" \
  --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  | jq --arg topic "$TOPIC_ID" '.data[] | select(.id == $topic) | {subscribed, channels}'

The values match the last choice you set. Every change is also in the contact’s consent changes, with the statement and the key that made it:

Terminal
curl "$KIRORA_API_URL/v1/contacts/customer-4821/consent-changes?idType=externalId" \
  --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $KIRORA_API_KEY"

Each entry of a topic choice carries its topicId, granted, source and, for a join, basis, evidence and wording.

Use the topic

Name the topic in a campaign or journey send step with topicId. The eligibility check of a campaign counts contacts the topic does not reach in excluded.topicUnsubscribed. A contact can also leave a topic from the unsubscribe link of any message sent on it, which fires the contact.unsubscribed webhook with its topicId; their channel consent is unchanged.

Next steps