The Messages page is a paginated per-partner inbox — announcements, operations status, and integration notices. Different surface than /notifications/ (which is per-user; see the Notifications API Reference). This page is feature-flagged behind SHOW_NOTIFICATION in the reference webapp; if off, the route redirects to /.

What you’ll build

  • A paginated message list (10 per page).
  • A detail view with mark-as-read.
  • An unread badge that polls in the background (every ~15 min).

Prerequisites

  • An authenticated user.
  • X-Preferred-Partner-Id set.

The call chain

On mount:
#MethodPathPurpose
1GET/messages/partner/?limit=10&page=1Paginated message list.
On open message:
#MethodPathPurpose
2PUT/messages/{id}/markasreadMark as read (only if not already).
Periodic background poll for the unread badge:
MethodPathNotes
GET/messages/totalunread{ total }. Poll every 15 min (UserStatusBar.js:40 in the reference webapp).

Response — /messages/partner/

{
  "total": 42,
  "limit": 10,
  "page":  1,
  "pages": 5,
  "nextPage": 2,
  "prevPage": null,
  "items": [
    {
      "id":          "msg_xyz",
      "partner_id":  "partner-id-acme",
      "title":       "Q3 attribution report ready",
      "content":     "<rich-text-or-markdown>",
      "read":        false,
      "url":         "https://...",
      "type":        "report",
      "date_created":      "2026-05-15T10:30:00Z",
      "date_last_updated": "2026-05-15T10:30:00Z"
    }
  ]
}

Reference implementation

export function MessagesPage() {
  const [page, setPage] = useState(1);
  const list = useFd<MessageList>(`/messages/partner/?limit=10&page=${page}`);
  const [active, setActive] = useState<Message | null>(null);

  const open = useCallback(async (m: Message) => {
    setActive(m);
    if (!m.read) {
      await fd.put(`/messages/${m.id}/markasread`);
      list.refetch();
      decrementUnreadBadge();
    }
  }, [list]);

  return active ? (
    <MessageDetail message={active} onBack={() => setActive(null)} />
  ) : (
    <MessageList
      messages={list.data?.items}
      page={list.data?.page}
      pages={list.data?.pages}
      onPageChange={setPage}
      onOpen={open}
    />
  );
}

Unread badge polling

function useUnreadBadge() {
  const [unread, setUnread] = useState(0);

  useEffect(() => {
    let cancelled = false;
    async function tick() {
      const { total } = await fd.get<{ total: number }>("/messages/totalunread");
      if (!cancelled) setUnread(total);
    }
    tick();
    const interval = setInterval(tick, 15 * 60 * 1000);   // 15 min
    return () => { cancelled = true; clearInterval(interval); };
  }, []);

  return unread;
}
15 minutes is the reference webapp’s interval. If your users expect more immediate feedback (e.g. you’ve wired DAG completion to Messages), drop to ~60–90 seconds, but don’t go faster than that — the server-side budget is sized for low-frequency polling.

Gotchas

/messages/... is per-partner (announcements, operational). /notifications/... is per-user (in-app activity feed). They share no state. See the API Reference.
The reference webapp doesn’t surface failures from this call — it optimistically decrements the badge. Mirror or improve as you see fit, but don’t block the open-message UI on the mark-as-read response.
GET /messages/partner/{partner_id} and POST /messages/partner/{partner_id} are fd-admin only (cross-tenant). Never wire these into partner UI.
The reference webapp renders content via dangerouslySetInnerHTML. Sanitise (DOMPurify) or render Markdown if the field is plaintext-only on your tenant.