Skip to content

Replace my notification + view preferences

PATCH
/account/preferences

Upserts the caller’s preferences. The wire contract is full-object replacement: the client sends the entire resolved object, the server validates against the build’s known_event_types + known_channels catalogs, and rejects unknown values rather than silently persisting them.

There is no per-field PATCH verb. To “remove a channel from an event,” send the full notification_channels object with that channel omitted from the array. To “fall back to system default for an event,” omit the event key entirely from the map.

PATCH-time payload for the per-user preferences row. Wire contract is full-object replacement; field-level merge is NOT supported. Both fields are optional — an absent notification_channels clears the user’s overrides, an absent default_views clears the view selections.

object
notification_channels

Map from event-type key to channel list. Unknown event types or unknown channel names trigger a 400. An empty array MEANS “deliver nothing for this event” and overrides the system default; an absent key means “use system default.”

object
key
additional properties
Array<string>
email_cadence

Map from event-type key to email delivery cadence (immediate | hourly | daily | weekly). Absent key = immediate (send-now). “off” is not a cadence value — mute email by dropping email from notification_channels. Unknown event types or cadence values trigger a 400.

object
key
additional properties
string
default_views

Default-view selections — the SEED for a device that has no local choice of its own, never an override of one. The browse store reads these once at hydration and only for the keys localStorage does not already hold, so picking masonry on one laptop survives a reload even when the account default says grid.

Every value set below is a closed enum, and each member is a state the app can actually reach: home_tab mirrors the feed enum on GET /posts, browse_layout mirrors the browse store’s ViewMode, and browse_sort carries only the two orderings the client can produce. Widening one of these means first building the thing it names (#736) — a value here that nothing can serve is a promise the UI breaks silently.

object
home_tab

Which feed segment browse lands on. Empty/unset falls back to latest. trending and for_you were removed in #736: neither existed in the feed enum, so choosing one put the plain latest feed under a label promising otherwise. They come back when a ranking model does.

string
Allowed values: "" latest following
browse_layout

Default browse-feed layout. Empty/unset falls back to the per-device default (feed on a coarse pointer, grid otherwise). feed is selectable here as of #706 — it was already a mode a phone could land on but not one a user could ask for.

string
Allowed values: "" grid masonry thumbnail list feed
browse_sort

Default browse ordering: newest (posted_at descending, the built-in) or oldest. Empty/unset falls back to newest. popular and trending were removed in #706 — GET /posts has no ranking parameter and no ranking model, so both were labels over the default order.

string
Allowed values: "" newest oldest
feed_filters

Browse-feed content filters. Absent object, or an absent key inside it, means the filter is OFF — there is no third state for a boolean to fall back to, so this follows the full-object-replacement contract like everything else here.

object
show_restricted

Keep the restricted-member placeholders (#883) in GET /posts instead of subtracting them.

Default false — the feed hides them (#921). #891 shipped this machinery as an opt-in on the theory that the placeholder is the more informative answer. Measured against the stock seed dataset, a reader with no capabilities got 82 posts of which 27 were entirely placeholders: a third of the grid was locked doors. The principle the default now encodes is that a placeholder belongs where the reader ASKED A QUESTION or OPENED A CONTAINER, not where they were handed a feed.

With the default (false), three rules, and the third is the one that matters:

  1. A member the caller cannot read is OMITTED from members rather than sent as a restricted: true placeholder.
  2. A post left with NO visible members — one that HAD members and none survived — is omitted from the page. A post with no members at all (an article, ADR 0073) is unaffected: it was never showing you anything you couldn’t see.
  3. A post the CALLER AUTHORED is never omitted, however restricted its members are. An author’s post can carry other people’s assets, so rule 2 alone would make your own work vanish from your own feed over a display default.

Set it true and GET /posts returns the pre-#921 payload exactly: every placeholder present, every all-restricted post back in the page.

Applies to GET /posts and stops there. GET /posts/{id} is an explicit request for one post and answers with the placeholders intact whatever this is set to, which is not an inconsistency: rule 2 exists BECAUSE an all-restricted post rendering as an empty card is worse than the placeholder it replaced, and filtering members on the single-post read would rebuild that empty card on the one surface rule 2 could not protect. Collection contents render placeholders for the same reason — an opened container should say what is in it.

It also keeps the ask alive. #913’s “Request access” button lives on the placeholder, so it survives wherever the placeholder does: the post page and collections. The trade the DEFAULT makes — no placeholders in the feed means no Request access in the feed — is stated in the setting’s own help text rather than left to be discovered.

boolean
browse_rail

Browse-rail curation (teams + followed tags). Full-object replacement like everything else on this endpoint: an absent object clears the curation back to the default rail, and an absent list inside it clears that list. The manage panel always sends all four, which is what makes “unhide the last hidden chip” expressible.

object
hidden_team_ids

Teams the reader removed from their rail. Empty = nothing hidden.

A hide-list rather than a show-list, deliberately: an empty allow-list would have to mean “show everything”, which is the opposite of what an empty allow-list says, and the first partial write would blank the rail.

Array<string>
team_order

The reader’s explicit ordering, applied to the FOLLOWED group (the manage panel’s drag-reorder). Empty = the server’s order.

PARTIAL LISTS ARE LEGAL and are the normal case: the ids named here lead, in this order, and everything else keeps its previous relative position behind them. That is what lets “drag one team to the top” persist one id rather than a full snapshot that the next follow would immediately make stale.

Array<string>
hidden_tags

Followed tags whose chip the reader removed from their rail (#1123). Empty = nothing hidden.

HIDING IS NOT UNFOLLOWING, and the two verbs sit next to each other in the manage panel precisely so the difference is visible. Unfollowing drops the tag_follows row and changes what the Following feed CONTAINS. Hiding only takes the chip off the strip; the tag’s posts keep reaching the feed exactly as before.

Array<string>
tag_order

The reader’s explicit ordering of their followed-tag chips. Empty = the server’s order (most recently followed first).

Partial lists are legal and normal, exactly as team_order.

Array<string>
mature_content

⚠️ FULL-OBJECT REPLACEMENT, like every other member of this request. An absent object resets the opt-in to false, and so does an absent show inside a present object. A client that PATCHes one preference must GET, merge and send the whole document — sending {"default_views": {...}} alone silently opts the account back out of mature content.

object
show

Opt in to being shown mature content. Absent or false — the default for every account — hides it.

boolean

Preferences saved.

GET / PATCH response for per-user preferences. Includes the build’s known event-type + channel catalogs so the UI renders toggle grids without hard-coding the lists client-side. Per-event system defaults are surfaced so the UI can show “follows system default — in_app only” hints alongside each toggle.

object
notification_channels
required
object
key
additional properties
Array<string>
email_cadence

Per-event-type email cadence. Absent key = immediate. Values: immediate | hourly | daily | weekly.

object
key
additional properties
string
default_views
required

Default-view selections — the SEED for a device that has no local choice of its own, never an override of one. The browse store reads these once at hydration and only for the keys localStorage does not already hold, so picking masonry on one laptop survives a reload even when the account default says grid.

Every value set below is a closed enum, and each member is a state the app can actually reach: home_tab mirrors the feed enum on GET /posts, browse_layout mirrors the browse store’s ViewMode, and browse_sort carries only the two orderings the client can produce. Widening one of these means first building the thing it names (#736) — a value here that nothing can serve is a promise the UI breaks silently.

object
home_tab

Which feed segment browse lands on. Empty/unset falls back to latest. trending and for_you were removed in #736: neither existed in the feed enum, so choosing one put the plain latest feed under a label promising otherwise. They come back when a ranking model does.

string
Allowed values: "" latest following
browse_layout

Default browse-feed layout. Empty/unset falls back to the per-device default (feed on a coarse pointer, grid otherwise). feed is selectable here as of #706 — it was already a mode a phone could land on but not one a user could ask for.

string
Allowed values: "" grid masonry thumbnail list feed
browse_sort

Default browse ordering: newest (posted_at descending, the built-in) or oldest. Empty/unset falls back to newest. popular and trending were removed in #706 — GET /posts has no ranking parameter and no ranking model, so both were labels over the default order.

string
Allowed values: "" newest oldest
feed_filters
required

Browse-feed content filters, always present with every key populated — unlike default_views, where “unset” and “the default” are different states the UI renders differently, a boolean filter has no third value, so omitting it would only make the client guess false rather than read it.

object
show_restricted

Keep the restricted-member placeholders (#883) in GET /posts instead of subtracting them.

Default false — the feed hides them (#921). #891 shipped this machinery as an opt-in on the theory that the placeholder is the more informative answer. Measured against the stock seed dataset, a reader with no capabilities got 82 posts of which 27 were entirely placeholders: a third of the grid was locked doors. The principle the default now encodes is that a placeholder belongs where the reader ASKED A QUESTION or OPENED A CONTAINER, not where they were handed a feed.

With the default (false), three rules, and the third is the one that matters:

  1. A member the caller cannot read is OMITTED from members rather than sent as a restricted: true placeholder.
  2. A post left with NO visible members — one that HAD members and none survived — is omitted from the page. A post with no members at all (an article, ADR 0073) is unaffected: it was never showing you anything you couldn’t see.
  3. A post the CALLER AUTHORED is never omitted, however restricted its members are. An author’s post can carry other people’s assets, so rule 2 alone would make your own work vanish from your own feed over a display default.

Set it true and GET /posts returns the pre-#921 payload exactly: every placeholder present, every all-restricted post back in the page.

Applies to GET /posts and stops there. GET /posts/{id} is an explicit request for one post and answers with the placeholders intact whatever this is set to, which is not an inconsistency: rule 2 exists BECAUSE an all-restricted post rendering as an empty card is worse than the placeholder it replaced, and filtering members on the single-post read would rebuild that empty card on the one surface rule 2 could not protect. Collection contents render placeholders for the same reason — an opened container should say what is in it.

It also keeps the ask alive. #913’s “Request access” button lives on the placeholder, so it survives wherever the placeholder does: the post page and collections. The trade the DEFAULT makes — no placeholders in the feed means no Request access in the feed — is stated in the setting’s own help text rather than left to be discovered.

boolean
browse_rail
required

Browse-rail curation, always present with every list materialised even when empty. Same argument as feed_filters above, for the list case: “no curation” and “an empty curation” are the same rail, so omitting it would only make the client re-derive [].

object
hidden_team_ids

Teams the reader removed from their rail. Empty = nothing hidden.

A hide-list rather than a show-list, deliberately: an empty allow-list would have to mean “show everything”, which is the opposite of what an empty allow-list says, and the first partial write would blank the rail.

Array<string>
team_order

The reader’s explicit ordering, applied to the FOLLOWED group (the manage panel’s drag-reorder). Empty = the server’s order.

PARTIAL LISTS ARE LEGAL and are the normal case: the ids named here lead, in this order, and everything else keeps its previous relative position behind them. That is what lets “drag one team to the top” persist one id rather than a full snapshot that the next follow would immediately make stale.

Array<string>
hidden_tags

Followed tags whose chip the reader removed from their rail (#1123). Empty = nothing hidden.

HIDING IS NOT UNFOLLOWING, and the two verbs sit next to each other in the manage panel precisely so the difference is visible. Unfollowing drops the tag_follows row and changes what the Following feed CONTAINS. Hiding only takes the chip off the strip; the tag’s posts keep reaching the feed exactly as before.

Array<string>
tag_order

The reader’s explicit ordering of their followed-tag chips. Empty = the server’s order (most recently followed first).

Partial lists are legal and normal, exactly as team_order.

Array<string>
mature_content
required

This account’s mature-content consent, always present with show populated. Same argument as feed_filters: a boolean has no third state, so omitting it would only make the client guess.

object
show

Opt in to being shown mature content. Absent or false — the default for every account — hides it.

boolean
known_event_types
required

Canonical ordered list of notification event types this build knows about. Order is the recommended rendering order in the UI.

Array<string>
known_channels
required

Canonical channel names this build supports (in_app, email, future: sms, push, webhook).

Array<string>
default_channels_by_event
required

Per-event-type system default channel list. Applied at notification-write time when the user’s notification_channels doesn’t have that key.

object
key
additional properties
Array<string>

Validation failure (unknown event / channel / duplicate).

object
error
required

Human-readable error summary

string
Example
the request could not be completed

Authentication required, missing, or invalid

object
error
required

Human-readable error summary

string
Example
the request could not be completed
Example
{
"error": "authentication required: sign in and retry with a valid session or API token"
}

Unexpected server error

object
error
required

Human-readable error summary

string
Example
the request could not be completed