The feed and the preferences as JSON, for a frontend that Phoenix does
not render: React, Vue, Svelte, a mobile app. Mounted with
Chime.Router.chime_api/2:
scope "/api" do
pipe_through :api_authenticated
chime_api "/notifications"
endMount it behind the app's own API pipeline, the one that knows who is
signed in (a session, a bearer token of the app's): the resolver reads
the person from the conn (Chime.Resolver.resolve_recipient/1). A
request with no person is a 401. A request may instead carry a token of
Chime.Token, as Bearer <token>, for pages on another origin; and
GET /stream takes it as ?token= too, as an EventSource cannot send
headers. Elsewhere a token in the address is not read: it would stay in
logs for its day.
The routes
GET /- the person's feed, the latest first:{"notifications": [...], "more": bool, "unseen": n, "unread": n}.?before=<id>the page after a notification;?limit=up to 100 (20);?filter=inbox(not archived, the default),unread,archivedorall.GET /counts-{"unseen": n, "unread": n}: one row, cheap enough to ask often.POST /seen- the feed seen: the dot goes. The counts.POST /read- every notification read. The counts.POST /:id/read,/:id/unread,/:id/archive,/:id/unarchive- one notification. The counts; a 404 for one the person does not have.POST /:id/actions/:action- takes an action of the notification (Chime.act/3): the counts, withactionandurl(where to go next, or null); a 404, or a 422 with"unknown_action","already"(an action was taken), or"failed"and the notifier'sreason.GET /push-{"vapidPublicKey": key, "services": ["web", ...]}, the app's public key for a browser'spushManager.subscribe/1.GET /devices- the person's devices:{"devices": [{"id", "kind", "name", "enabled", "lastUsedAt", "insertedAt"}]}.POST /devices- registers one, with{"kind": "web", "token": subscription.endpoint, "keys": subscription.keys, "name": "..."}, or{"kind": "apns" | "fcm", "token": "..."}: a 201 with{"device"}, or a 422 with"unknown_kind","invalid_token","invalid_subscription".PATCH /devices/:id-{"enabled": false}turns it off, and on;{"name": "..."}names it.DELETE /devices/:id- removes it.GET /preferences- the person's settings:{"categories": [{"key", "label", "description", "required", "settings": {"feed": "on", "email": "daily"}}], "channels": [{"key": "email", "settings": ["on", "daily", "weekly", "off"]}]}.PUT /preferences- changes settings, with{"preferences": {"comments": {"email": "daily"}}}as the body: the settings asGETgives them, or a 422 with{"error": code, "category", "channel"}("required","unknown_category","unknown_channel","invalid_setting") and nothing changed.GET /stream- the person's live news, as server-sent events (text/event-stream):countsat once, thennotifiedwhen a notification comes andupdatedwhen they see or read in another tab, each with{"unseen": n, "unread": n}as its data, and a comment every 25 seconds to keep the connection.POST /token- a token ofChime.Tokenfor the person of the request:{"token": "...", "expiresIn": seconds}.
A notification is:
{
"id": "0193…",
"kind": "new_comment",
"category": "comments",
"title": "Ana and 2 others commented on Tokyo",
"body": "Lovely light.",
"url": "/stories/42",
"icon": "comment",
"actions": [{"key": "reply", "label": "Reply"}],
"action": null, "actedAt": null, "clickedAt": null,
"extra": {},
"count": 3,
"actors": [{"id": "user:7", "name": "Ana"}],
"seen": true, "read": false, "archived": false,
"seenAt": "2026-10-25T14:03:00Z", "readAt": null, "archivedAt": null,
"insertedAt": "2026-10-25T14:02:11Z", "updatedAt": "2026-10-25T14:09:40Z"
}title, body, url and icon are the notifier's feed/2, rendered
for the request (its language, through the app's own plugs); extra
has whatever else it gave.
An error is {"error": code}: "unauthorized" (a 401, with
"token_expired" for a token past its time), "not_found".
Behind the app's protect_from_forgery, a POST needs the CSRF token
of the session in the x-csrf-token header.