> ## Documentation Index
> Fetch the complete documentation index at: https://gleanfeed.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Render updates in your own UI

> Fetch published Glean Feed updates for a custom "What's new" page, menu, or feed.

Use the headless changelog when you want published Glean Feed updates inside an interface you design—a page, menu, feed, or modal. Glean Feed provides public update data and canonical links; your product controls the markup and interactions.

If you are still choosing an integration, start with the [Headless SDK overview](/headless/overview).

It gives you two things:

* **Read** published entries for a workspace.
* An **unread badge** tracked per browser, so returning customers see what's new since their last visit.

Everything here is **anonymous and public** — no login, no accounts, no credentials. You can call it directly from the browser.

## Quickstart: fetch and render updates

Add a container where updates should appear:

```html theme={null}
<section>
  <h2>What's new</h2>
  <div id="updates"></div>
  <a id="all-updates">View all updates</a>
</section>
```

Fetch the first page, render each title and publication date, and link to the full update on your public changelog:

```html theme={null}
<script type="module">
  const workspaceId = "YOUR_WORKSPACE_ID";
  const response = await fetch(
    `https://app.gleanfeed.com/api/sdk/changelog?workspaceId=${workspaceId}&limit=5`,
  );
  if (!response.ok) throw new Error(`Could not load updates (${response.status})`);

  const page = await response.json();
  const list = document.getElementById("updates");

  for (const entry of page.entries) {
    const link = document.createElement("a");
    link.href = `${page.changelogUrl}#${entry.id}`;
    link.textContent = `${entry.title} — ${new Date(entry.publishedAt).toLocaleDateString()}`;
    link.style.display = "block";
    list.append(link);
  }

  document.getElementById("all-updates").href = page.changelogUrl;
</script>
```

Publish an update in Glean Feed, reload your page, and confirm its title appears. Selecting the title should open that exact update on your public changelog.

The endpoint returns public, published entries that fit the workspace's current plan limit. Entries labeled **Archived (plan limit)** in the dashboard are preserved but excluded from this endpoint and the public portal. They return automatically when the workspace gains capacity. The endpoint does not require an API key and supports cross-origin browser requests.

## Concepts

### Pagination

Each response includes `hasMore` and `nextOffset`. When `hasMore` is `true`, request the next page with the returned offset:

```js theme={null}
const nextPage = await fetch(
  `https://app.gleanfeed.com/api/sdk/changelog?workspaceId=${workspaceId}&limit=20&offset=${page.nextOffset}`,
).then((response) => response.json());
```

Continue until `hasMore` is `false`. Updates are returned newest first.

### Unread state

Unread state is a timestamp stored in the current browser's `localStorage`. It is not a Glean Feed account record, so it does not follow a customer to another browser or device. The helpers compare that timestamp with the `publishedAt` values in the page you fetched.

When storage is unavailable, the helpers treat the changelog as never seen and silently skip persistence. Your updates still render.

### Links to full updates

`changelogUrl` is the workspace's canonical public changelog—using its active custom domain when it has one. Link all updates to this URL, or link one entry to `${changelogUrl}#${entry.id}`. Glean Feed resolves the URL on every response, so your links follow later domain changes.

## Reference

### What you need

| Value                 | Where to find it                               | Secret?                          |
| --------------------- | ---------------------------------------------- | -------------------------------- |
| **Workspace ID**      | Settings → General                             | No — safe to ship in the browser |
| **Glean Feed origin** | Your app URL, e.g. `https://app.gleanfeed.com` | No                               |

### Public endpoint

The read endpoint lives under your Glean Feed origin and sends permissive CORS headers, so you can call it directly from any page.

```bash theme={null}
curl "https://app.gleanfeed.com/api/sdk/changelog?workspaceId=YOUR_WORKSPACE_ID&limit=20&offset=0"
```

Query parameters:

| Parameter     | Required | Default | Notes                                |
| ------------- | -------- | ------- | ------------------------------------ |
| `workspaceId` | Yes      | —       | Your workspace ID.                   |
| `limit`       | No       | `20`    | Entries per page, `1`–`50`.          |
| `offset`      | No       | `0`     | Skip this many entries (for paging). |

Response:

```json theme={null}
{
  "entries": [
    {
      "id": "b3f47bc7-…",
      "title": "Dark mode is here",
      "bodyHtml": "<p>You can now switch to a dark theme…</p>",
      "version": "2.4.0",
      "publishedAt": "2026-07-03T18:00:00.000Z"
    }
  ],
  "hasMore": true,
  "nextOffset": 20,
  "changelogUrl": "https://feedback.acme.com/changelog"
}
```

`bodyHtml` is sanitized on the server, so it's safe to inject into the DOM. Only public **published** entries within the current plan limit are ever returned — drafts, plan-limit archived entries, and internal fields never leave the server. Paginate by passing `nextOffset` back as `offset` until `hasMore` is `false`.

For authenticated server workflows that also need drafts or administrative changelog operations, use the REST API and review [authentication](/api/authentication) and [rate limits](/api/overview#limits). The public headless endpoint above has separate per-IP protection; retry with backoff if it returns `429`.

### JavaScript helpers

Load the widget script once, then call the `GleanFeed.changelog` helpers. Setting `data-workspace` mounts the prebuilt launcher too; omit it to load the helpers alone.

```html theme={null}
<script src="https://cdn.gleanfeed.com/widget.js"></script>
<script type="module">
  const config = {
    workspaceId: "YOUR_WORKSPACE_ID",
    portalBaseUrl: "https://app.gleanfeed.com",
  };

  const { entries, hasMore, nextOffset, changelogUrl } =
    await window.GleanFeed.changelog.getEntries(config, { limit: 10 });
</script>
```

Using a bundler instead? Import the same helpers from the package:

```ts theme={null}
import { getChangelogEntries } from "@gleanfeed/widget";

const page = await getChangelogEntries(
  { workspaceId: "YOUR_WORKSPACE_ID", portalBaseUrl: "https://app.gleanfeed.com" },
  { limit: 10 },
);
```

## Add an unread badge

Track unread per browser with a "last seen" timestamp in `localStorage`. The helper does the bookkeeping:

```ts theme={null}
const config = { workspaceId: "YOUR_WORKSPACE_ID", portalBaseUrl: "https://app.gleanfeed.com" };
const cl = window.GleanFeed.changelog;

const { entries } = await cl.getEntries(config, { limit: 20 });

// Count entries newer than this browser's last visit.
const unread = cl.countUnreadSince(entries, cl.getLastSeen(config.workspaceId));

// When the customer opens your panel, record it — the badge clears.
cl.markSeen(config.workspaceId);
```

## Optional: build a "What's new" dropdown

This longer dependency-free example adds a trigger, unread badge, five-entry dropdown, per-entry links, and a **View all** link. Opening the panel clears this browser's badge.

```html theme={null}
<!-- Drop this trigger anywhere in your app — a nav item, a bell, a menu row. -->
<div class="gf-whatsnew">
  <button id="wn-trigger" aria-haspopup="dialog" aria-expanded="false">
    What's new
    <span id="wn-badge" style="display: none"></span>
  </button>

  <div id="wn-panel" role="dialog" aria-label="What's new" hidden>
    <div id="wn-list"></div>
    <a id="wn-more" target="_blank" rel="noopener">View all updates →</a>
  </div>
</div>

<script src="https://cdn.gleanfeed.com/widget.js"></script>
<script>
  const cl = window.GleanFeed.changelog;
  const config = { workspaceId: "YOUR_WORKSPACE_ID", portalBaseUrl: "https://app.gleanfeed.com" };

  const trigger = document.getElementById("wn-trigger");
  const panel = document.getElementById("wn-panel");
  const list = document.getElementById("wn-list");
  const more = document.getElementById("wn-more");
  const badge = document.getElementById("wn-badge");

  // Escape untrusted-looking text before putting it in innerHTML.
  const esc = (s) => {
    const d = document.createElement("div");
    d.textContent = s;
    return d.innerHTML;
  };

  // A one-line plain-text snippet from the sanitized HTML — no parsing needed,
  // just read textContent. The full post lives on your changelog page.
  function snippet(html, max = 100) {
    const d = document.createElement("div");
    d.innerHTML = html;
    const text = d.textContent.trim();
    return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text;
  }

  async function load() {
    const { entries, changelogUrl } = await cl.getEntries(config, { limit: 5 });

    more.href = changelogUrl; // "View all" → your public changelog page

    list.innerHTML = entries
      .map(
        (e) => `
          <a class="wn-item" href="${changelogUrl}#${e.id}" target="_blank" rel="noopener">
            <span class="wn-title">${esc(e.title)}</span>
            <time>${new Date(e.publishedAt).toLocaleDateString()}</time>
            <span class="wn-snippet">${esc(snippet(e.bodyHtml))}</span>
          </a>`,
      )
      .join("");

    // Unread badge: everything published since this browser last opened the panel.
    // Toggle `style.display`, not the `hidden` attribute — the badge's CSS
    // `display: inline-flex` would override `[hidden]`, so `badge.hidden` can't
    // reliably hide it. An inline style always wins over the stylesheet.
    const count = cl.countUnreadSince(entries, cl.getLastSeen(config.workspaceId));
    badge.textContent = count > 9 ? "9+" : String(count);
    badge.style.display = count > 0 ? "inline-flex" : "none";
  }

  function open() {
    panel.hidden = false;
    trigger.setAttribute("aria-expanded", "true");
    cl.markSeen(config.workspaceId); // opening the panel clears the badge
    badge.style.display = "none";
  }
  function close() {
    panel.hidden = true;
    trigger.setAttribute("aria-expanded", "false");
  }

  trigger.addEventListener("click", () => (panel.hidden ? open() : close()));
  document.addEventListener("keydown", (e) => e.key === "Escape" && close());
  document.addEventListener("click", (e) => {
    if (!e.target.closest(".gf-whatsnew")) close(); // click outside to dismiss
  });

  load();
</script>
```

Style it however you like — position `#wn-panel` under your trigger, size it, add your own type and colors. A minimal starting point:

```css theme={null}
.gf-whatsnew {
  position: relative;
  display: inline-block;
}
#wn-panel {
  position: absolute;
  right: 0;
  margin-top: 8px;
  width: 320px;
  background: #fff;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 8px;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
  z-index: 50;
}
.wn-item {
  display: grid;
  gap: 2px;
  padding: 10px;
  border-radius: 8px;
  text-decoration: none;
  color: inherit;
}
.wn-item:hover {
  background: #f3f4f6;
}
.wn-title {
  font-weight: 600;
}
.wn-snippet {
  color: #6b7280;
  font-size: 13px;
}
#wn-badge {
  display: inline-flex;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  border-radius: 999px;
  background: #ef4444;
  color: #fff;
  font-size: 11px;
  align-items: center;
  justify-content: center;
}
```

## Optional: React

Same helpers, wrapped in a component. This renders the trigger, badge, dropdown, per-entry links, and "View all" — using the framework you already have.

```tsx theme={null}
"use client";

import { useEffect, useRef, useState } from "react";
import {
  countUnreadSince,
  getChangelogEntries,
  getLastSeen,
  markSeen,
  type GleanFeedChangelogEntry,
} from "@gleanfeed/widget";

const config = { workspaceId: "YOUR_WORKSPACE_ID", portalBaseUrl: "https://app.gleanfeed.com" };

const snippet = (html: string, max = 100) => {
  const d = document.createElement("div");
  d.innerHTML = html;
  const text = d.textContent?.trim() ?? "";
  return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text;
};

export function WhatsNew() {
  const [entries, setEntries] = useState<GleanFeedChangelogEntry[]>([]);
  const [changelogUrl, setChangelogUrl] = useState("");
  const [unread, setUnread] = useState(0);
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    getChangelogEntries(config, { limit: 5 }).then((page) => {
      setEntries(page.entries);
      setChangelogUrl(page.changelogUrl);
      setUnread(countUnreadSince(page.entries, getLastSeen(config.workspaceId)));
    });
  }, []);

  // Close on outside click.
  useEffect(() => {
    const onClick = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("click", onClick);
    return () => document.removeEventListener("click", onClick);
  }, []);

  function toggle() {
    if (!open) {
      markSeen(config.workspaceId); // opening clears the badge
      setUnread(0);
    }
    setOpen((v) => !v);
  }

  return (
    <div className="gf-whatsnew" ref={ref} style={{ position: "relative" }}>
      <button aria-expanded={open} aria-haspopup="dialog" onClick={toggle} type="button">
        What's new{unread > 0 && <span> ({unread > 9 ? "9+" : unread})</span>}
      </button>

      {open && (
        <div aria-label="What's new" role="dialog">
          {entries.map((e) => (
            <a key={e.id} href={`${changelogUrl}#${e.id}`} rel="noopener" target="_blank">
              <strong>{e.title}</strong>
              <time>{new Date(e.publishedAt).toLocaleDateString()}</time>
              <p>{snippet(e.bodyHtml)}</p>
            </a>
          ))}
          <a href={changelogUrl} rel="noopener" target="_blank">
            View all updates →
          </a>
        </div>
      )}
    </div>
  );
}
```

## Notes and limits

* **Only published entries** are returned; `limit` is capped at 50 and requests are rate-limited per IP.
* **`bodyHtml` is server-sanitized** (a strict tag/attribute allowlist), so it's safe to render.
* **Unread is per browser**, so it won't follow a customer across devices — that's the trade-off for a zero-login, zero-account badge.
* **The badge counts within the entries you fetch.** The dropdown above fetches 5, so its badge maxes out at 5. If you want a precise "9+", fetch a larger `limit` for the count (you can still render only the first few).
