
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.
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.
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.
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.
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:
GET /users/{user_id}/preferences.preferences:user:updated event as it arrives.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 hereconst { 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.
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.
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.
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.

Test Courier emails end to end with your AI agent
Give your coding agent an AgentMail inbox, and it can test your Courier emails the way a user would: send the change, check what arrives, review how it renders, and fix what it finds before the change reaches Production.

Let your agent check every email before it ships
Courier Device Preview gives your coding agent real screenshots from Outlook, Gmail, and Apple Mail. Here's the prompt and workflow to have it check every email, fix the draft, and check again before you publish.

React Native solved a problem nobody had. Use Flutter.
Shopify moved back to Swift and Kotlin because coding agents made two codebases affordable. That kills React Native's reason to exist. It does not touch Flutter's, and the canvas is why.