Blog

Know the moment users change notification preferences

Emily LaneEmily LaneOctober 14, 2026
Preference webhooks and change history cover

Courier now tells you every time a user changes a notification preference. A preferences:user:updated event goes to your outbound webhook when someone opts in, opts out, or changes which channels a topic uses. GET /preferences/logs returns the full change history, newest first, with the old value next to the new one.

Until now, Courier only gave you the current state. If your CRM, data warehouse, or marketing tool needed to know that a user unsubscribed, you had to poll every user's preferences and diff them yourself. Now the change comes to you.

Why preference changes matter

A preference change is a user telling you exactly what they want to hear from you. An opt-out from one topic means that topic is too noisy, even if the rest of your notifications are still welcome. That's feedback you can act on: fix the topic, send it less often, or move it to a quieter channel.

It only helps if it reaches the rest of your stack. If someone unsubscribes in Courier but your CRM still has them on the list, they keep getting email they said no to, and that's how you lose their trust.

What's new

A webhook for every change, wherever it happened

The event fires no matter where the change came from: the hosted preference page, an unsubscribe link, the preference center in your app, or the API. One event type covers opting in, opting out, and editing a topic's channels, so you write one handler.

Each event describes one change to one topic:

{
"type": "preferences:user:updated",
"data": {
"id": "eb4023e3-62cc-4488-a4fc-d98230994f6a",
"timestamp": "2026-09-24T18:12:04.442Z",
"user_id": "user_123",
"tenant_id": "acme-corp",
"topic_id": "pt_01kx4h2jdafq8bk996nn92357r",
"topic_name": "Product updates",
"status": "OPTED_OUT",
"custom_routing": [],
"has_custom_routing": false,
"previous": {
"status": "OPTED_IN",
"custom_routing": ["email"],
"has_custom_routing": true
}
}
}

You get the new value and the one it replaced, so you know this user went from getting product updates by email to opting out entirely. tenant_id is there when the change was made inside a tenant.

The full history, by API

GET /preferences/logs returns every change in your environment, newest first. Pass user_id for one user's history (add tenant_id to narrow it to one tenant), or since to start from a date or time. Pages hold 25 entries by default and up to 100 with limit.

import Courier from "@trycourier/courier";
const client = new Courier();
const changes = await client.workspacePreferences.listLogs({
user_id: "user_123",
});
console.log(changes.items);

It's in all seven server SDKs (Node, Python, Ruby, Go, Java, PHP, and C#), the Courier CLI, and the Courier MCP server, so an agent can answer "when did this user opt out of product updates?" on its own.

What you can build

  • Opt-outs that reach every tool. A user unsubscribes from marketing emails through Courier's hosted page, and your handler removes them from the matching list in your CRM.
  • A preference mirror in your warehouse. Stream every change into a table and join it against sends, churn, or plan changes to see which topics people turn off and when.
  • A clear answer for support. When a customer asks why they stopped getting invoices, the history shows the day they opted out and what they had before.

How to sync Courier preferences to your CRM or database

The webhook payload is the same entry the history API returns, with the same id. The webhook keeps your copy current, and the history API fills in anything you missed:

  1. Load each user's current preferences once with GET /users/{user_id}/preferences.
  2. Apply each preferences:user:updated event as it arrives.
  3. After any gap, read GET /preferences/logs with since set to the last timestamp you applied. Apply entries oldest first and skip any id you've already seen.

A webhook handler for step 2 can be this small:

app.post("/webhooks/courier", express.raw({ type: "application/json" }), async (req, res) => {
// Verify the courier-signature header against req.body here
const { type, data } = JSON.parse(req.body);
if (type === "preferences:user:updated" && !(await db.changeApplied(data.id))) {
await crm.setSubscription(data.user_id, data.topic_id, data.status);
await db.recordChange(data.id, data.timestamp);
}
res.sendStatus(200);
});

Signature verification needs the raw body, which is why the handler parses it itself. The outbound webhooks docs cover the check.

For the catch-up in step 3, page with cursor while paging.more is true. The endpoint allows one request every two seconds per environment, so fetch pages one at a time.

Resetting a topic back to its default through the API removes the user's override rather than changing it, so it doesn't add a history entry or send an event. That covers DELETE /users/{user_id}/preferences/{topic_id} and the topics a bulk replace lists in deleted. When your code makes those calls, update your copy at the same time.

Frequently asked questions

Does Courier send a webhook when a user unsubscribes?

Yes. Courier sends a preferences:user:updated event to your outbound webhook whenever a user opts in, opts out, or changes a topic's channels. It fires for changes made on the hosted preference page, through unsubscribe links, in your in-app preference center, and through the API.

How do I see when a user opted out of a notification topic?

Call GET /preferences/logs with the user's user_id. It returns their preference changes newest first, each with a timestamp, the new status, and the previous one when Courier knows it.

Available now

Preference webhooks and the change history API are live. If you already have an outbound webhook set up, preferences:user:updated events start arriving there with the next change.


Get started: the preferences API guide for the full sync pattern, the preferences:user:updated payload, or the API reference for GET /preferences/logs.