> ## Documentation Index
> Fetch the complete documentation index at: https://www.courier.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Install the Courier skill before writing code: `npx skills add trycourier/courier-skills`. It carries the verified SDK shapes and the rules you cannot get wrong.
> Authenticate every request with `Authorization: Bearer <API_KEY>`. A workspace has several environments and each has its own keys, which are plain `pk_` strings with no environment prefix. Start with Test.
> Send with `client.send.message`, the default import of the v7 Node SDK. Reference a template by its `nt_` id or its alias.
> A send accepts a bare Elemental element list, but storing content on a template requires the top-level elements wrapped in a channel element.
> Templates and journeys can be built in the Courier app or created through the API. Either way they live in the workspace and are referenced by ID when you send.
> The hosted MCP server is https://mcp.courier.com. For a briefing on what Courier is and when to use it, read https://www.courier.com/llms.txt.
> Prefer the Guides tab for how-do-I questions and the Docs tab for how-does-it-behave questions. The API reference lives under /api-reference.

# Manage user preferences with the API

> Read and update a user's topic preferences and channel routing, per user or per tenant.

export const Endpoint = ({method, path, name, href, children, bare}) => {
  const verb = String(method || "").toUpperCase();
  const title = verb + " " + path;
  const label = children || name || path;
  if (bare) {
    return href ? <a href={href}><code>{title}</code></a> : <code>{title}</code>;
  }
  if (!href) {
    return <span className="cx-endpoint" data-method={verb} title={title}>
        <span className="cx-endpoint-label">{label}</span>
        <span className="cx-endpoint-method">{verb}</span>
      </span>;
  }
  return <a className="cx-endpoint" data-method={verb} href={href} title={title}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">{verb}</span>
    </a>;
};

export const AppLink = ({href, children, name, bare}) => {
  const label = children || name || "Open in Courier";
  if (bare) {
    return <a href={href} target="_blank" rel="noreferrer">{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="app" href={href} target="_blank" rel="noreferrer">
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method" aria-hidden="true">↗</span>
    </a>;
};

export const Guide = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="guide" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">GUIDE</span>
    </a>;
};

export const Doc = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="doc" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">DOC</span>
    </a>;
};

<Info>
  The <Doc href="/docs/recipients/preferences/model">preferences model & API surface</Doc> and <Doc href="/docs/recipients/preferences/overview">how preferences resolve</Doc> cover the concepts behind this API.
</Info>

The User Preferences API reads and writes one user's topic opt-ins and per-topic channel choices, one topic at a time or the whole set at once. Call it from your backend with your workspace API key (the examples below), or from your frontend with a [client SDK](#client-sdks) as the signed-in user. Every call needs a `user_id`, the same ID you send to, and single-topic calls also need a `topic_id`.

## Prerequisites

* <AppLink href="https://app.courier.com/~/test/platform/preferences">Your subscription topics under Platform → Preferences</AppLink>
* <AppLink href="https://app.courier.com/settings/api-keys">A Courier API key</AppLink>

## Read a user's preferences

Reading a user's preferences returns every topic they have an override for, with their `status`, the topic's `default_status`, and any `custom_routing`. Fetch them from the <Endpoint method="GET" path="/users/{user_id}/preferences" name="Get user's Preferences" href="/docs/api-reference/user-preferences/get-users-preferences">get preferences endpoint</Endpoint>:

<CodeGroup>
  ```javascript Node.js theme={null}
  import Courier from '@trycourier/courier';

  const client = new Courier({
    apiKey: process.env['COURIER_API_KEY'],
  });

  const preference = await client.users.preferences.retrieve('user_123');

  console.log(preference.items);
  ```

  ```python Python theme={null}
  import os
  from courier import Courier

  client = Courier(
      api_key=os.environ.get("COURIER_API_KEY"),
  )
  preference = client.users.preferences.retrieve(
      user_id="user_123",
  )
  print(preference.items)
  ```

  ```bash cURL theme={null}
  curl -X GET https://api.courier.com/users/user_123/preferences \
    -H "Authorization: Bearer $COURIER_API_KEY"
  ```

  ```ruby Ruby theme={null}
  require "courier"

  courier = Courier::Client.new(api_key: ENV["COURIER_API_KEY"])

  preference = courier.users.preferences.retrieve("user_123")

  puts(preference)
  ```

  ```go Go theme={null}
  preference, err := client.Users.Preferences.Get(
  	context.TODO(),
  	"user_123",
  	courier.UserPreferenceGetParams{},
  )
  ```

  ```java Java theme={null}
  PreferenceRetrieveResponse preference = client.users().preferences().retrieve("user_123");
  ```

  ```php PHP theme={null}
  $preference = $client->users->preferences->retrieve('user_123');
  ```

  ```csharp C# theme={null}
  PreferenceRetrieveParams parameters = new() { UserID = "user_123" };

  var preference = await client.Users.Preferences.Retrieve(parameters);
  ```

  ```bash CLI theme={null}
  courier users:preferences retrieve \
    --api-key "$COURIER_API_KEY" \
    --user-id user_123
  ```

  ```text MCP theme={null}
  With Courier MCP, show me user_123's notification preferences.
  ```
</CodeGroup>

## Update one topic

Updating one topic creates or changes a single preference. Wrap it in a `topic` object with a `status` of `OPTED_IN` or `OPTED_OUT`. `REQUIRED` is a topic default set in the editor, not a user choice, and the API rejects opting a user out of a required topic. To set the channels the user wants, add `has_custom_routing: true` and list them in `custom_routing` (`email`, `sms`, `push`, `inbox`, `direct_message`, `webhook`). To opt out entirely, send `{ "topic": { "status": "OPTED_OUT" } }` with no routing. Call the <Endpoint method="PUT" path="/users/{user_id}/preferences/{topic_id}" name="Update or create user Preferences for a Subscription Topic" href="/docs/api-reference/user-preferences/update-or-create-user-preferences-for-a-subscription-topic">update topic endpoint</Endpoint>:

<Note>
  Where custom routing over the API is not available, the single-topic write returns `402`, the bulk `PUT` returns `400`, and the bulk `POST` returns `200` with a per-item error. Only `status` is written.
</Note>

<CodeGroup>
  ```javascript Node.js highlight={9-10} theme={null}
  import Courier from '@trycourier/courier';

  const client = new Courier({
    apiKey: process.env['COURIER_API_KEY'],
  });

  const response = await client.users.preferences.updateOrCreateTopic('pt_01kx4h2jdafq8bk996nn92357r', {
    user_id: 'user_123',
    topic: {
      status: 'OPTED_IN',
      has_custom_routing: true,
      custom_routing: ['inbox', 'email'],
    },
  });

  console.log(response.message);
  ```

  ```python Python highlight={7-8,10-11} theme={null}
  import os
  from courier import Courier

  client = Courier(
      api_key=os.environ.get("COURIER_API_KEY"),
  )
  response = client.users.preferences.update_or_create_topic(
      topic_id="pt_01kx4h2jdafq8bk996nn92357r",
      user_id="user_123",
      topic={
          "status": "OPTED_IN",
          "has_custom_routing": True,
          "custom_routing": ["inbox", "email"],
      },
  )
  print(response.message)
  ```

  ```bash cURL highlight={4} theme={null}
  curl -X PUT https://api.courier.com/users/user_123/preferences/pt_01kx4h2jdafq8bk996nn92357r \
    -H "Authorization: Bearer $COURIER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "topic": { "status": "OPTED_IN", "has_custom_routing": true, "custom_routing": ["inbox", "email"] } }'
  ```

  ```ruby Ruby highlight={5,8} theme={null}
  require "courier"

  courier = Courier::Client.new(api_key: ENV["COURIER_API_KEY"])

  response = courier.users.preferences.update_or_create_topic(
    "pt_01kx4h2jdafq8bk996nn92357r",
    user_id: "user_123",
    topic: {status: :OPTED_IN}
  )

  puts(response)
  ```

  ```go Go highlight={6-7} theme={null}
  response, err := client.Users.Preferences.UpdateOrNewTopic(
  	context.TODO(),
  	"pt_01kx4h2jdafq8bk996nn92357r",
  	courier.UserPreferenceUpdateOrNewTopicParams{
  		UserID: "user_123",
  		Topic: courier.UserPreferenceUpdateOrNewTopicParamsTopic{
  			Status:           shared.PreferenceStatusOptedIn,
  			HasCustomRouting: courier.Bool(true),
  			CustomRouting:    []shared.ChannelClassification{shared.ChannelClassificationInbox, shared.ChannelClassificationEmail},
  		},
  	},
  )
  ```

  ```java Java highlight={3-5} theme={null}
  PreferenceUpdateOrCreateTopicParams params = PreferenceUpdateOrCreateTopicParams.builder()
      .userId("user_123")
      .topicId("pt_01kx4h2jdafq8bk996nn92357r")
      .topic(PreferenceUpdateOrCreateTopicParams.Topic.builder()
          .status(PreferenceStatus.OPTED_IN)
          .build())
      .build();
  PreferenceUpdateOrCreateTopicResponse response = client.users().preferences().updateOrCreateTopic(params);
  ```

  ```php PHP highlight={4-5} theme={null}
  $response = $client->users->preferences->updateOrCreateTopic(
    'pt_01kx4h2jdafq8bk996nn92357r',
    userID: 'user_123',
    topic: [
      'status' => PreferenceStatus::OPTED_IN,
      'customRouting' => [
        ChannelClassification::INBOX, ChannelClassification::EMAIL
      ],
      'hasCustomRouting' => true,
    ],
  );
  ```

  ```csharp C# highlight={5,7} theme={null}
  PreferenceUpdateOrCreateTopicParams parameters = new()
  {
      UserID = "user_123",
      TopicID = "pt_01kx4h2jdafq8bk996nn92357r",
      Topic = new()
      {
          Status = PreferenceStatus.OptedIn,
          CustomRouting =
          [
              ChannelClassification.Inbox, ChannelClassification.Email
          ],
          HasCustomRouting = true,
      },
  };

  var response = await client.Users.Preferences.UpdateOrCreateTopic(parameters);
  ```

  ```bash CLI highlight={1,4-5} theme={null}
  courier users:preferences update-or-create-topic \
    --api-key "$COURIER_API_KEY" \
    --user-id user_123 \
    --topic-id pt_01kx4h2jdafq8bk996nn92357r \
    --topic '{status: OPTED_IN}'
  ```

  ```text MCP theme={null}
  With Courier MCP, opt user_123 into that topic and route it to inbox and email.
  ```
</CodeGroup>

To reset a topic to its default, <Endpoint method="DELETE" path="/users/{user_id}/preferences/{topic_id}" name="Delete user Subscription Topic" href="/docs/api-reference/user-preferences/delete-user-subscription-topic">delete the topic override</Endpoint>.

## Client SDKs

From your frontend, a client SDK updates the **signed-in user's** own preferences. It authenticates with a short-lived JWT rather than your API key, issued for that user with the `read:preferences` and `write:preferences` scopes. See <Doc href="/docs/in-app/authenticate-users">Authenticate users</Doc> for the token flow. It can only read and write that one user's data. The <Guide href="/docs/guides/build-a-preference-center#embedded-component">embedded preference center</Guide> calls this same client.

Every client SDK exposes the same `preferences` methods: `getUserPreferences()` to list, `getUserPreferenceTopic()` to read one, and `putUserPreferenceTopic()` to opt a topic in or out and set its channels. On the web, React, Web Components, Vue, and Angular all call the shared `@trycourier/courier-js` client (React also through the `useCourier()` hook). The mobile SDKs expose an identical client.

<CodeGroup>
  ```tsx React highlight={6} theme={null}
  import { useCourier } from "@trycourier/courier-react";

  // After courier.shared.signIn({ userId, jwt }) for the signed-in user:
  const courier = useCourier();

  await courier.preferences.putUserPreferenceTopic({
    topicId: "TOPIC_ID",
    status: "OPTED_OUT",
    hasCustomRouting: true,
    customRouting: ["email", "push"],
  });
  ```

  ```typescript Web Components highlight={5} theme={null}
  import { Courier } from "@trycourier/courier-js";

  Courier.shared.signIn({ userId: "user_123", jwt });

  await Courier.shared.client?.preferences.putUserPreferenceTopic({
    topicId: "TOPIC_ID",
    status: "OPTED_OUT",
    hasCustomRouting: true,
    customRouting: ["email", "push"],
  });
  ```

  ```typescript Vue highlight={6} theme={null}
  // Vue uses the same @trycourier/courier-js client behind its component wrapper.
  import { Courier } from "@trycourier/courier-js";

  Courier.shared.signIn({ userId: "user_123", jwt });

  await Courier.shared.client?.preferences.putUserPreferenceTopic({
    topicId: "TOPIC_ID",
    status: "OPTED_OUT",
    hasCustomRouting: true,
    customRouting: ["email", "push"],
  });
  ```

  ```typescript Angular highlight={6} theme={null}
  // Angular uses the same @trycourier/courier-js client behind its component wrapper.
  import { Courier } from "@trycourier/courier-js";

  Courier.shared.signIn({ userId: "user_123", jwt });

  await Courier.shared.client?.preferences.putUserPreferenceTopic({
    topicId: "TOPIC_ID",
    status: "OPTED_OUT",
    hasCustomRouting: true,
    customRouting: ["email", "push"],
  });
  ```

  ```swift iOS highlight={3} theme={null}
  let client = CourierClient(jwt: jwt, userId: "user_123")

  try await client.preferences.putUserPreferenceTopic(
      topicId: "TOPIC_ID",
      status: .optedOut,
      hasCustomRouting: true,
      customRouting: [.email, .push]
  )
  ```

  ```kotlin Android highlight={3} theme={null}
  val client = CourierClient(jwt = jwt, userId = "user_123")

  client.preferences.putUserPreferenceTopic(
      topicId = "TOPIC_ID",
      status = CourierPreferenceStatus.OPTED_OUT,
      hasCustomRouting = true,
      customRouting = listOf(CourierPreferenceChannel.EMAIL, CourierPreferenceChannel.PUSH)
  )
  ```

  ```dart Flutter highlight={3} theme={null}
  final client = CourierClient(jwt: jwt, userId: "user_123");

  await client.preferences.putUserPreferenceTopic(
    topicId: "TOPIC_ID",
    status: CourierUserPreferencesStatus.optedOut,
    hasCustomRouting: true,
    customRouting: [CourierUserPreferencesChannel.email, CourierUserPreferencesChannel.push],
  );
  ```

  ```tsx React Native highlight={9} theme={null}
  import {
    CourierClient,
    CourierUserPreferencesStatus,
    CourierUserPreferencesChannel,
  } from "@trycourier/courier-react-native";

  const client = new CourierClient({ userId: "user_123", jwt });

  await client.preferences.putUserPreferenceTopic({
    topicId: "TOPIC_ID",
    status: CourierUserPreferencesStatus.OptedOut,
    hasCustomRouting: true,
    customRouting: [CourierUserPreferencesChannel.Email, CourierUserPreferencesChannel.Push],
  });
  ```
</CodeGroup>

## Import a full preference set (bulk)

Two bulk endpoints set many topics for a user in one request, up to 50 topics each. They differ in what happens to the topics you do not send.

* **Replace** makes the body the user's complete override set. Topics you send are created or updated, and any existing override you leave out is reset to its default. Validation is atomic: one invalid topic fails the whole request.
* **Update** is additive. It touches only the topics you send and processes each independently, returning successes in `items` and failures in `errors`.

Send the <Endpoint method="PUT" path="/users/{user_id}/preferences" name="Replace user Preferences in bulk" href="/docs/api-reference/user-preferences/replace-user-preferences-in-bulk">replace</Endpoint> or <Endpoint method="POST" path="/users/{user_id}/preferences" name="Update user Preferences in bulk" href="/docs/api-reference/user-preferences/update-user-preferences-in-bulk">update</Endpoint> bulk request:

<CodeGroup>
  ```javascript Node.js theme={null}
  import Courier from '@trycourier/courier';

  const client = new Courier({
    apiKey: process.env['COURIER_API_KEY'],
  });

  const response = await client.users.preferences.bulkReplace('user_123', {
    topics: [
      {
        topic_id: 'pt_01kx4h2jdafq8bk996nn92357r',
        status: 'OPTED_IN',
        has_custom_routing: true,
        custom_routing: ['inbox', 'email'],
      },
    ],
  });

  console.log(response.deleted);
  ```

  ```python Python theme={null}
  import os
  from courier import Courier

  client = Courier(
      api_key=os.environ.get("COURIER_API_KEY"),
  )
  response = client.users.preferences.bulk_replace(
      user_id="user_123",
      topics=[{
          "topic_id": "pt_01kx4h2jdafq8bk996nn92357r",
          "status": "OPTED_IN",
          "has_custom_routing": True,
          "custom_routing": ["inbox", "email"],
      }],
  )
  print(response.deleted)
  ```

  ```bash cURL theme={null}
  curl -X PUT https://api.courier.com/users/user_123/preferences \
    -H "Authorization: Bearer $COURIER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "topics": [ { "topic_id": "pt_01kx4h2jdafq8bk996nn92357r", "status": "OPTED_IN", "has_custom_routing": true, "custom_routing": ["inbox", "email"] } ] }'
  ```

  ```ruby Ruby theme={null}
  require "courier"

  courier = Courier::Client.new(api_key: ENV["COURIER_API_KEY"])

  response = courier.users.preferences.bulk_replace(
    "user_123",
    topics: [{status: :OPTED_IN, topic_id: "pt_01kx4h2jdafq8bk996nn92357r"}]
  )

  puts(response)
  ```

  ```go Go theme={null}
  response, err := client.Users.Preferences.BulkReplace(
  	context.TODO(),
  	"user_123",
  	courier.UserPreferenceBulkReplaceParams{
  		Topics: []courier.UserPreferenceBulkReplaceParamsTopic{{
  			TopicID:          "pt_01kx4h2jdafq8bk996nn92357r",
  			Status:           "OPTED_IN",
  			HasCustomRouting: courier.Bool(true),
  			CustomRouting:    []shared.ChannelClassification{shared.ChannelClassificationInbox, shared.ChannelClassificationEmail},
  		}},
  	},
  )
  ```

  ```java Java theme={null}
  PreferenceBulkReplaceParams params = PreferenceBulkReplaceParams.builder()
      .userId("user_123")
      .addTopic(PreferenceBulkReplaceParams.Topic.builder()
          .status(PreferenceBulkReplaceParams.Topic.Status.OPTED_IN)
          .topicId("pt_01kx4h2jdafq8bk996nn92357r")
          .build())
      .build();
  PreferenceBulkReplaceResponse response = client.users().preferences().bulkReplace(params);
  ```

  ```php PHP theme={null}
  $response = $client->users->preferences->bulkReplace(
    'user_123',
    topics: [
      [
        'status' => 'OPTED_IN',
        'topicID' => 'pt_01kx4h2jdafq8bk996nn92357r',
        'customRouting' => [
          ChannelClassification::INBOX, ChannelClassification::EMAIL
        ],
        'hasCustomRouting' => true,
      ],
    ],
  );
  ```

  ```csharp C# theme={null}
  PreferenceBulkReplaceParams parameters = new()
  {
      UserID = "user_123",
      Topics =
      [
          new()
          {
              Status = Status.OptedIn,
              TopicID = "pt_01kx4h2jdafq8bk996nn92357r",
              CustomRouting =
              [
                  ChannelClassification.Inbox, ChannelClassification.Email
              ],
              HasCustomRouting = true,
          },
      ],
  };

  var response = await client.Users.Preferences.BulkReplace(parameters);
  ```

  ```bash CLI theme={null}
  courier users:preferences bulk-replace \
    --api-key "$COURIER_API_KEY" \
    --user-id user_123 \
    --topic '{status: OPTED_IN, topic_id: pt_01kx4h2jdafq8bk996nn92357r}'
  ```
</CodeGroup>

Replace is the usual way to bring subscription data from another system into Courier. Send only the topics where a user differs from the default. The rest fall back automatically. Because it is idempotent, you can re-run the import safely, keep users in sync with a source of truth, or resume a backfill mid-loop. There is no across-users endpoint, so a backfill means iterating your user base with one bulk call per user (max 50 topics each). Throttle the loop as you go.

## Scope to a tenant

If your app uses <Doc href="/docs/tenants/overview">Tenants</Doc>, scope any read or write to one Tenant with a `tenant_id`. On REST that is the `?tenant_id=` query parameter. On the SDKs it is the `tenant_id` argument on any method above. A user's per-tenant preferences are independent of their global preferences.

The read endpoint returns the topic-level `default_status`, not a tenant's default preferences, even with a `tenant_id`. To set what a tenant defaults to, see <Doc href="/docs/tenants/preferences">Set tenant default preferences</Doc>.

## Verify

<Steps>
  <Step title="Update a topic and read it back">
    Update a topic to `OPTED_OUT`, then <Endpoint method="GET" path="/users/{user_id}/preferences/{topic_id}" name="Get user Subscription Topic" href="/docs/api-reference/user-preferences/get-user-subscription-topic">read it back</Endpoint> and confirm the `status`.
  </Step>

  <Step title="Confirm the send is blocked">
    Send a message mapped to that topic and confirm the log shows it blocked as `UNSUBSCRIBED`.
  </Step>

  <Step title="Re-run an import">
    For an import, re-run the same bulk replace and confirm the result is unchanged (it is idempotent).
  </Step>
</Steps>
