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:writeandcontacts:read. Anagentkey cannot change a contact’s choices. See Authentication. - A contact with your own id,
customer-4821here, created withPOST /v1/contacts/identify. See IDs and identity. - cURL and jq, and
KIRORA_API_URLandKIRORA_API_KEYset 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.
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:
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:
{
"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:
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:
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:
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.