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

# Release notes HTTP API

> Read published release history, exact app versions and managed images without credentials.

Use the anonymous API from a browser, desktop app or server without installing the React SDK. Your client owns presentation, response validation, Markdown sanitization, retry policy and any cache. For a ready-made renderer, see [React](https://docs.feedbackloop.page/integrations/release-notes/react.md) or [Tauri](https://docs.feedbackloop.page/integrations/release-notes/tauri.md).

The base origin is `https://feedbackloop.page`. Find the public project ID in **Release notes → Add to app**. [Publish notes](https://docs.feedbackloop.page/integrations/release-notes.md#prepare-a-release) before testing; project IDs are public identifiers, not access keys.

## List published releases

```http theme={null}
GET /api/v1/projects/{projectId}/releases
```

For a first request, replace the project placeholder:

```sh theme={null}
curl --fail-with-body 'https://feedbackloop.page/api/v1/projects/YOUR_PROJECT_ID/releases'
```

A successful response contains at most ten notes, newest publication first. It includes unversioned notes and preserves publication order rather than sorting semantic versions. The page size is fixed; there is no `limit`, offset, sorting or authentication parameter.

```json theme={null}
{
  "items": [
    {
      "id": "release-note-id",
      "version": "1.2.3+desktop.7",
      "title": "A useful update",
      "excerpt": "A faster start.",
      "previewImageSource": "./images/image-token",
      "markdown": "## What changed\n\nA faster start.\n\n![Preview](./images/image-token)",
      "date": "2026-10-25",
      "url": "https://feedbackloop.page/p/project-id/release-notes/release-note-id"
    }
  ],
  "nextCursor": null
}
```

| Field | Type | Meaning |
| - | - | - |
| `items` | array | Zero to ten published notes, in server order. |
| `items[].id` | string | Stable public release-note identifier. |
| `items[].version` | string or null | Full normalized app SemVer, or null for unversioned updates. |
| `items[].title` | string | Human-readable release title. |
| `items[].excerpt` | string | Plain-text preview, at most 240 Unicode characters. |
| `items[].previewImageSource` | string or null | Referenced summary image, otherwise the first current image, or null for text-only notes. |
| `items[].markdown` | string | Complete saved Markdown, including portable managed image references. |
| `items[].date` | string | Calendar publication day in `YYYY-MM-DD` form; avoid shifting it through a local timezone. |
| `items[].url` | string | Canonical public HTTP(S) article, which may use the project's configured community domain. |
| `nextCursor` | string or null | Opaque continuation token; null after the final page. |

Only active published notes in an available project are returned. Drafts, unpublished/deleted notes, removed projects and unavailable parent ownership do not become public through this endpoint. A valid project with no notes returns `200` with `items: []` and `nextCursor: null`.

Current server responses always include `excerpt` and `previewImageSource`. They are optional in the SDK's `Release` type so older responses and saved copies remain readable. When absent, a compact preview can show the title and article link without an excerpt or image. Selected images must still occur in the saved Markdown; removing the selected image clears its preview or falls back to the first remaining image.

Render excerpts as text, not HTML. Pass a preview source through the same [managed/external image policy](#managed-and-external-images) as document images. Never treat the source as a pre-signed or executable URL; a failed image should leave the text and article link available.

The TypeScript `Release` and `ReleasePage` types are also exported from `@feedbackloop/react`. They describe the wire contract; a type annotation does not validate untrusted JSON. The package root does not export its internal fetch/parser helpers.

## Continue history

Pass the previous `nextCursor` through `URLSearchParams` without inspecting or modifying it:

```js theme={null}
const projectId = 'YOUR_PROJECT_ID'
const url = new URL(
  `https://feedbackloop.page/api/v1/projects/${encodeURIComponent(projectId)}/releases`,
)
// page is the previous successful response.
if (page.nextCursor !== null) {
  url.searchParams.set('cursor', page.nextCursor)
  const response = await fetch(url, {
    credentials: 'omit',
    headers: { Accept: 'application/json' },
  })
  if (!response.ok)
    throw new Error(`Release request failed: ${response.status}`)
  const nextPage = await response.json()
  // Validate the response before appending its items in server order.
}
```

Stop when `nextCursor` is null. Do not use semantic version as a history cursor, and do not assume a cursor is a permanent snapshot while owners publish or edit notes. Treat it as scoped to this project's history request. A supplied cursor must be nonempty and at most 4096 characters; invalid cursors return `400`.

## Select an exact app version

```http theme={null}
GET /api/v1/projects/{projectId}/releases?version=1.2.3%2Bdesktop.7
```

`version` must be a complete valid SemVer, at most 256 characters. Ranges such as `^1.2.3`, partial versions, empty values and the word `latest` are invalid. Accepted input is normalized; prerelease and build metadata are preserved. Build metadata is part of exact identity: `1.2.3+desktop.7` does not select `1.2.3+desktop.8`.

An exact request returns zero or one item and always `nextCursor: null`. A valid version with no published match returns `200` with an empty array, not a different release or an unavailable-project error.

```js theme={null}
const projectId = 'YOUR_PROJECT_ID'
const url = new URL(
  `https://feedbackloop.page/api/v1/projects/${encodeURIComponent(projectId)}/releases`,
)
url.searchParams.set('version', '1.2.3+desktop.7')
const response = await fetch(url, {
  credentials: 'omit',
  headers: { Accept: 'application/json' },
})
if (!response.ok) throw new Error(`Release request failed: ${response.status}`)
const page = await response.json()
if (
  !Array.isArray(page.items) ||
  page.items.length > 1 ||
  page.nextCursor !== null
)
  throw new Error('Unexpected exact-version response')
// Validate each returned field before using the document in your renderer or cache.
```

Never combine `version` with `cursor`. Unknown list query parameters, repeated selection parameters and simultaneous version/cursor values return `400`. Use `URLSearchParams` so the `+` in build metadata is encoded as `%2B`, rather than decoded as a space.

## Status and delivery contract

| Status | Meaning and client action |
| - | - |
| `200` | JSON release page; an empty history or absent exact match is successful. |
| `204` | `OPTIONS` preflight accepted with no body. |
| `302` | Image endpoint redirects to a fresh short-lived signed storage URL. |
| `400` | Invalid list selection, version or cursor; correct the request. |
| `404` | Unavailable/malformed project or image resource; stop treating a cached copy as currently published. |
| `405` | Unsupported method; use `GET` or `OPTIONS`. |
| `503` | Temporary API, backend or image-provider failure; allow a later bounded retry. |

Errors use a safe JSON message such as `{"message":"Invalid release selection."}`. Use the HTTP status for control flow rather than matching message text. Cancellation, connectivity errors and invalid response bodies are separate client failures. Avoid tight retry loops and do not cache failures as permanent missing releases.

Responses carry `Cache-Control: no-store`, `Access-Control-Allow-Origin: *` and `Access-Control-Allow-Methods: GET, OPTIONS`. This is noncredentialed CORS: send no cookies, bearer token or custom owner headers, and use `credentials: 'omit'`. No `Access-Control-Allow-Credentials` is provided. Browser and native WebView origins can read the same public content. The API does not need a customer-specific Convex client.

## Managed and external images

Markdown may contain `./images/<id>`, where `<id>` contains only letters, digits, `_` or `-`. Resolve that exact relative shape through the owning release:

```http theme={null}
GET /api/v1/projects/{projectId}/releases/{releaseNoteId}/image?source=.%2Fimages%2Fimage-token
```

For each image, apply this policy in your renderer:

```js theme={null}
// projectId and release.id identify the document being rendered.
// source is this image's original Markdown source.
let imageUrl
if (/^\.\/images\/[a-zA-Z0-9_-]+$/.test(source)) {
  const url = new URL(
    `https://feedbackloop.page/api/v1/projects/${encodeURIComponent(projectId)}/releases/${encodeURIComponent(release.id)}/image`,
  )
  url.searchParams.set('source', source)
  imageUrl = url.href
} else {
  try {
    const url = new URL(source)
    if (
      ['https:', 'http:'].includes(url.protocol) &&
      !url.username &&
      !url.password
    )
      imageUrl = url.href
  } catch {
    // Leave invalid or unsupported sources unrendered.
  }
}
// Use imageUrl only when defined, as the image element's src.
```

The endpoint rechecks the project's availability, the note's current publication/membership and whether the source is still referenced in its saved Markdown before obtaining a fresh signer URL. Missing, invalid, removed or mismatched sources are unavailable. A missing/empty source or one longer than 2048 characters returns `404`.

Set the image element's `src` to the release-scoped endpoint and let the browser follow the `302`. Do not store its signed `Location` as a durable image URL or expose private storage credentials. Previously issued signed URLs can remain valid until their normal expiry after a note changes. `no-store` does not recall a URL already issued.

External Markdown images should allow only explicit HTTP(S) URLs without embedded credentials. Do not resolve arbitrary relative paths against your app origin. With a CSP, `img-src` must allow the API origin **and the actual final storage/CDN or external image origins**; `connect-src` needs the API origin for release JSON.

## Render and cache safely

Treat every document field as untrusted content. For a minimal safe text display, use DOM text nodes rather than HTML insertion:

```js theme={null}
const article = document.createElement('article')
const heading = document.createElement('h2')
heading.textContent = release.title
const body = document.createElement('pre')
body.textContent = release.markdown
article.append(heading, body)
document.querySelector('#releases').append(article)
```

This displays literal Markdown. To render formatted Markdown, use a maintained Markdown renderer with raw HTML disabled, or parse then sanitize through an explicit allowlist. Remove scripts, executable event attributes, frames, forms and unsafe URL schemes. Validate links as explicit HTTP(S), reject URLs with embedded credentials, and add `rel="noopener noreferrer"` when opening a new tab. Apply the image policy above after sanitization. Never pass API Markdown straight to `innerHTML` or an equivalent unsafe HTML prop.

If you add offline storage, label saved content, bound its size and lifetime, isolate by project and exact version, and revalidate before claiming current publication. Clear affected entries on authoritative unavailability. A cached exact document must not acknowledge an installed app version as freshly published; app-owned update and persistence policy remains separate from this API.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.