Chime.API (Chime v0.1.0-dev)

Copy Markdown View Source

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"
end

Mount 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, archived or all.
  • 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, with action and url (where to go next, or null); a 404, or a 422 with "unknown_action", "already" (an action was taken), or "failed" and the notifier's reason.
  • GET /push - {"vapidPublicKey": key, "services": ["web", ...]}, the app's public key for a browser's pushManager.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 as GET gives 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): counts at once, then notified when a notification comes and updated when 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 of Chime.Token for 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.