Appearance
Notifications — API Analysis (API-A)
Concept layer — frozen. The Notifications generic. Nothing here is written by a normal playbook run; a project's own feature analysis is the live document and takes every edit. This layer names no project and links to none — the dependency runs one way, from an application to its concept.
Feature: Notifications
Paths below are written without any version or gateway prefix: how a project versions and mounts its routes is an application decision, not part of the concept. Success envelope: { data, status, message?, requestId }. Error envelope: { status, error: { code, message, details }, requestId }.
Every endpoint here is recipient-scoped from the caller's own session. That is the defining property of this surface and it is worth separating from permissions before any endpoint is described: a permission code decides whether a caller may use the endpoint at all, and the recipient filter — applied in the query, never in the response — decides which rows exist for them. Only the second prevents one user reading another's inbox, and no permission grant should be able to widen it. Where the system also scopes by tenant, both filters apply.
Four decisions this surface forces
How does a client learn that something new arrived — does it ask, or is it told?
- Polling. The client asks for the count on a timer or on navigation. Trivially compatible with any deployment, cacheable, and degrades gracefully. Costs a constant baseline of requests proportional to logged-in users, and a delay equal to the interval — which users read as the feature being slow rather than the interval being long.
- Server-push over a long-lived connection. Immediate, and the request baseline disappears. Costs a connection per active session to hold open, scale and authenticate, a reconnection story, and a fallback for clients and networks that cannot keep one — which means polling is usually still built, not replaced.
- Push notification to the device or the browser, which is not a variant of the above but a separate outbound channel with its own permission prompt, its own provider and its own delivery record. It reaches a user who does not have the application open, which is precisely what the other two cannot do.
A project that ships polling has chosen it, and should say so — otherwise every later complaint about latency is diagnosed as a bug.
Is the unread count its own endpoint, or a field on the list response?
- Its own endpoint. A tiny, cheap, frequently-called read that can be optimised, cached and rate-limited on its own terms, and called from a page that never renders the list.
- A field on the list response. One request instead of two on the surface that needs both, and no risk of the two disagreeing. Costs the count being computed on every list read, whether or not the caller wanted it, and leaves the badge on other pages with nothing to call.
Most projects end up with both, which is fine as long as one is derived from the other rather than computed twice by two different predicates. Two definitions of "unread" is a bug that presents as a badge that will not clear.
Preferences: replace the whole set, or send only what changed?
- Full replacement. The client sends the complete matrix. Idempotent, easy to reason about, and the server can reject an incomplete set. Costs a payload that grows with the taxonomy, and a last-write-wins race between two open tabs that silently reverts one of them.
- A delta of changed pairs. Small payloads and no lost-update on untouched values. Costs an upsert per item and a response that has to tell the client what the resulting state is, since the client no longer knows.
Either way the response returns the effective preferences, including values the caller did not send. Where preferences are stored sparsely over defaults, that is the only way a client can render the screen at all — and it is why the read below returns the full matrix regardless of how it is stored.
Is there a write endpoint that creates a notification? The default answer is no. Notifications are raised by the system's own code through the internal port at the end of this page, because an endpoint that sends a message to a chosen person, with chosen text, is a spam and phishing primitive wearing the operator's identity. A project that needs one — an integration raising events from outside — treats it as a distinct, separately-permissioned capability with its own rate limit and its own recipient rules, and never as "the same thing the internal code does, exposed".
1. Read my notifications
Endpoint: GET /notifications
Description
Lists the caller's own notifications, newest first, paginated. Backs the inbox.
Authorization
The endpoint's permission code, plus the recipient filter taken from the session. There is no parameter that could widen the result to another recipient, which is the point: isolation is structural rather than a check that a future parameter might get wrong.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
unreadOnly | Boolean | No | Restrict to notifications not yet marked read |
eventType | String | No | Restrict to one value of the taxonomy, or one category where the taxonomy has two levels |
pageSize / cursor or page | — | No | Per the project's list convention |
Pagination follows whatever the project uses elsewhere, with one caveat specific to this feature: notifications insert ahead of any paginated view, so page-and-offset shifts rows between pages while a reader pages through them, and a cursor anchored to the last row read does not. Either is defensible; a sort by time alone is not, because equal timestamps let a row duplicate or vanish within a page. The sort carries a tiebreaker on the id.
Request Logic
- Filter by recipient from the session, and by scope where the system has one.
- Apply the filters, order newest first with the tiebreaker, page.
- Resolve content: either return the stored rendered text, or resolve the key and parameters in the reader's locale — per the decision recorded on the feature page. A key with no translation falls back rather than failing the page, and a parameter that no longer resolves is omitted rather than rendered as an empty value.
- The reference to the affected record is returned as stored, without checking that the record still exists. Resolving it would turn one page read into N reads and would hide exactly the notifications whose subjects were deleted.
Success Response (200 OK)
json
{
"data": {
"items": [
{
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"eventType": "assigned",
"targetType": "task",
"targetId": "018fa51f-1c22-7c05-9a70-3f0b0f2f77aa",
"title": "A task was assigned to you",
"body": "Quarterly review was assigned to you by A. Editor.",
"link": "/tasks/018fa51f-1c22-7c05-9a70-3f0b0f2f77aa",
"readAt": null,
"createdAt": "2026-07-13T09:58:00Z"
}
],
"pagination": { "pageSize": 25, "cursor": null }
},
"status": 200,
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}The idempotency key is internal and never returned. It is derived from the event, so exposing it lets a client compute whether a notification exists that they cannot see — which is an inference about other people's activity, from a field that exists only to prevent duplicates.
Error Responses
400 — invalid query. An unknown taxonomy value, a malformed cursor, a page size out of range. Every failing field is reported.
2. Unread count
Endpoint: GET /notifications/unread-count
Returns a single number for the badge, under the same recipient filter. It is the most frequently called read in the feature and often the most frequently called endpoint in the system, so it is worth treating as its own performance problem rather than as a small version of the list.
Two properties are non-negotiable regardless of the decision above. It uses the same predicate as the list's unreadOnly filter — two definitions of unread produce a badge that will not clear. And it never returns an error the client renders as a zero: a failed request and an empty inbox must be distinguishable, or the badge silently lies in the reassuring direction.
3. Mark as read
Endpoint: POST /notifications/mark-read
Takes a bounded list of ids and marks the caller's own matching rows as read.
What happens to an id the caller does not own is a decision with a security consequence. Returning
404for an unknown id and403for someone else's confirms that a notification with that id exists — which is an oracle over other people's inboxes. Ignoring non-matching ids silently and reporting how many rows were actually updated leaks nothing and is idempotent by construction. The concept's position is the second: the request describes an intent over the caller's own rows, and ids outside that set simply do not match.
Marking an already-read notification read again is not an error and does not move its timestamp. The response reports the number of rows whose state actually changed, which is the only number a client can use to update a badge without re-reading it.
4. Mark everything as read
Endpoint: POST /notifications/mark-all-read
Marks the caller's unread notifications read in one operation.
It takes a cutoff, and the cutoff is the whole design. Between the moment the client rendered the list and the moment the user clicked, another notification can arrive; an unbounded "mark everything" marks that one read as well, and the user has dismissed something they were never shown. The client therefore sends the timestamp or cursor of the newest item it displayed, and the server marks nothing newer. A project that omits this will not see the bug — it produces no error, and the notification is simply never noticed.
The cutoff is required, not optional. An optional cutoff is the same as none: the first client written against the endpoint omits it, the server accepts the call, and the design is lost with no error on either side — the server cannot tell "no cutoff because nothing was shown" from "no cutoff because the client forgot". A client with an empty list has nothing to mark and does not call.
An empty result is a success with a count of zero, not a 404.
5. Read and write my preferences
Endpoints: GET /notification-preferences/me · PUT /notification-preferences/me
The read returns the effective matrix — every (taxonomy value, channel) pair with its resolved state — regardless of whether preferences are stored densely or sparsely. A client cannot render a preferences screen from a sparse set without duplicating the default table, and a default duplicated in a client is a default that will disagree with the server's.
Each pair is returned with enough information for the screen to be honest about what it offers: whether the value is the user's own choice or an inherited default, and whether it is locked — mandatory classes that cannot be disabled, per the Legal Context questions on the feature page. A control that appears editable and is then ignored on write is the one outcome that is worse than not offering it.
The write validates every value against the current taxonomy and channel set and rejects the whole payload on any unknown value rather than partially applying it — a client sending a stale taxonomy value has a stale screen, and silently dropping that pair leaves the user believing they changed something. Writes to a locked pair are rejected explicitly, with the field named, rather than accepted and discarded.
400 — invalid body. Unknown taxonomy value, unknown channel, payload outside the allowed size, or an attempt to change a locked pair. Every failing item is reported with its index.
6. Unsubscribe from outside the application
Endpoint: GET/POST on a tokenised unsubscribe path — only where the project needs one.
Where a channel's messages must carry a way to stop receiving them, the recipient acting on it is by definition not signed in, so this is the one endpoint on the surface that is not session-scoped. That makes it the one worth the most care:
- The token identifies a (recipient, class) pair, is unguessable, and grants nothing except the ability to turn that class off. It is not a login, and it never becomes one.
- A
GETthat unsubscribes on sight will be triggered by link scanners and mail clients that prefetch links, so the state change belongs behind a confirmation the user actually performs. - The response says the same thing whether the token was valid or not, so the endpoint cannot be used to test whether an address is subscribed.
- Whether a token expires is a real trade-off: an expiring token protects a forwarded message, and it also breaks the obligation to provide a working opt-out in a message someone kept.
7. Internal fan-out port (no endpoint)
Producing features raise events through a port, not over HTTP:
typescript
interface NotificationPort {
notify(event: NotificationEvent, options?: TransactionOptions): Promise<void>;
}- The port takes an event, not a recipient list and not a message. Handing it rendered text moves the taxonomy, the preference check and the localization decision into every producer, one copy per caller, and they will not stay in step.
optionscarries the caller's transaction context. Whether fan-out runs inside that transaction, after it commits, or through an outbox is the decision recorded under Transactional Operations on the feature page; the port supports all three and the project states which it uses.- The port must not silently do nothing. Where a project's implementation depends on something the caller supplies — a transaction context, a request scope — the absence of it is an error or at minimum a logged warning, never an early return. A quiet no-op here is the highest-cost defect in the feature: the business operation succeeds, nobody is told, and nothing anywhere records that a decision not to notify was taken.
- There is no delete and no update on the port. A notification already sent cannot be unsent, and a stored notification that changes after the fact contradicts whatever was delivered on every other channel.