# Set a contact's topic choices

Source: https://kirora.io/docs/api/recipe-topic-preferences

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](https://kirora.io/docs/api/reference/authentication).
- **A contact** with your own id, `customer-4821` here, created with
  `POST /v1/contacts/identify`. See [IDs and identity](https://kirora.io/docs/api/identifiers).
- **cURL and jq**, and `KIRORA_API_URL` and `KIRORA_API_KEY` set as in the
  [Quickstart](https://kirora.io/docs/api/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`.

```sh
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:

```sh
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:

```sh
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:

```sh
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:

```sh
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

- [IDs and identity](https://kirora.io/docs/api/identifiers#recording-consent): consent, statements and topics on identify
- [Campaigns and journeys](https://kirora.io/docs/api/campaigns-and-journeys): send a campaign on a topic
- [Webhooks](https://kirora.io/docs/api/webhooks): `contact.unsubscribed` and `contact.consent_changed`