---
title: Widget HTTP API
description:
  The four public HTTP endpoints behind the feedback widget, with request and response
  shapes, the auth model, error codes, and rate limits.
---

The embedded widget talks to your Feeblo server over a small public API. Your backend can
call the same four endpoints directly to collect feedback without loading the SDK in a
browser.

## Base URL

Every widget route lives under `/api/widget/v1` on your Feeblo server. With the default
development setup that is `http://localhost:3000`, the `API_URL` value from
`.env.example`. In production, use whatever origin serves your API, for example
`https://api.example.com`.

Each running Feeblo server also publishes its full HTTP surface as an OpenAPI document.
Open `/docs` in a browser for the Scalar UI, or fetch `/docs/openapi.json` to generate a
client for your language.

## Authentication

No endpoint on this API requires an API key or session. The endpoints exist so anonymous
visitors can browse boards, read updates, and file feedback. Every request is rate limited
by client IP instead (see [Rate limits](#rate-limits)).

Identity comes from one place: the optional `token` field on `POST /feedback`. That token
is the same HS256 JWT you mint for widget SSO, signed with your organization secret. When
you send it, Feeblo verifies the signature against the secrets stored for your
organization, binds it to your organization via its audience claim, then upserts a contact
from the claims (email, name, avatar, custom fields, companies) and attaches the new post
to that contact. Omit the token and the post stays anonymous.

See [Widget SSO](/developers/widget-sso) for how to sign the token on your server.

:::note[CORS]
Browser calls work from origins your server already trusts: the dashboard origin, the API
origin itself, and subdomains of your configured root domain. Requests without an `Origin`
header (server-to-server calls) bypass CORS entirely.
:::

## Endpoints

### POST /suggestions

Given a draft title and body, returns up to five similar public posts on the board. The
widget shows these before submission so visitors can react to an existing discussion
instead of filing a duplicate. Matching runs on embeddings first and falls back to lexical
scoring, so you get results even while the embedding backend is unavailable.

```bash
curl -X POST https://api.example.com/api/widget/v1/suggestions \
  -H "Content-Type: application/json" \
  -d '{
    "organizationId": "org_123",
    "boardId": "brd_456",
    "title": "Dark mode",
    "content": "A dark theme would be easier on my eyes at night."
  }'
```

Request body:

| Field            | Type   | Required | Notes                       |
| ---------------- | ------ | -------- | --------------------------- |
| `organizationId` | string | yes      | Your Feeblo organization ID |
| `boardId`        | string | yes      | Board to search             |
| `title`          | string | yes      | Draft title, max 200 chars  |
| `content`        | string | yes      | Draft body, max 20,000 chars |

Response `200 OK`, an array of matches (possibly empty):

```json
[
  {
    "id": "pst_9f2ka81m",
    "title": "Dark mode support",
    "excerpt": "My eyes hurt after sunset, please add a dark theme…",
    "slug": "dark-mode-support"
  }
]
```

### POST /feedback

Creates a feedback post on a public board. Feeblo assigns your organization's default
status automatically, sanitizes content before storage, and fires the
`feedback.post.created` webhook event (see [Webhooks](/developers/webhooks)).

```bash
curl -X POST https://api.example.com/api/widget/v1/feedback \
  -H "Content-Type: application/json" \
  -d '{
    "organizationId": "org_123",
    "boardId": "brd_456",
    "title": "Dark mode",
    "content": "A dark theme would be easier on my eyes at night.",
    "metadata": {
      "plan": "pro",
      "host": "app.example.com"
    },
    "token": "<signed-JWT>"
  }'
```

Request body:

| Field            | Type                   | Required | Notes                                                          |
| ---------------- | ---------------------- | -------- | -------------------------------------------------------------- |
| `organizationId` | string                 | yes      | Your Feeblo organization ID                                     |
| `boardId`        | string                 | yes      | Must reference a **public** board                               |
| `title`          | string                 | yes      | Max 200 chars                                                   |
| `content`        | string                 | yes      | Max 20,000 chars                                                |
| `metadata`       | map of string → string | no       | Up to 20 properties, keys max 64 chars, values max 500 chars     |
| `token`          | string                 | no       | HS256 identity JWT, max 8,192 chars                             |

:::warning[Public boards only]
The request fails when the board does not exist under that organization (`404`) or the
board is private (`400`). There is no way to submit through this endpoint to a private
board, by design.
:::

Response `200 OK`:

```json
{
  "id": "pst_7tqz04vd",
  "slug": "dark-mode",
  "title": "Dark mode",
  "boardId": "brd_456",
  "organizationId": "org_123",
  "createdAt": "2025-06-01T12:00:00.000Z"
}
```

### GET /boards

Lists the organization's public boards with names, slugs, and timestamps, so you can
render a board picker without the SDK.

```bash
curl "https://api.example.com/api/widget/v1/boards?organizationId=org_123"
```

Query parameters:

| Field            | Type   | Required | Notes                       |
| ---------------- | ------ | -------- | --------------------------- |
| `organizationId` | string | yes      | Your Feeblo organization ID |

Response `200 OK`, an array of boards:

```json
[
  {
    "id": "brd_456",
    "name": "Feature requests",
    "slug": "feature-requests",
    "organizationId": "org_123",
    "createdAt": "2025-03-14T09:30:00.000Z",
    "updatedAt": "2025-05-20T16:45:00.000Z"
  }
]
```

Private boards never appear here.

### GET /updates

Returns published changelog entries for the organization, newest first. `content` holds
sanitized HTML you can render directly; `imageUrl` is the cover image or `null`.

```bash
curl "https://api.example.com/api/widget/v1/updates?organizationId=org_123"
```

Query parameters:

| Field            | Type   | Required | Notes                       |
| ---------------- | ------ | -------- | --------------------------- |
| `organizationId` | string | yes      | Your Feeblo organization ID |

Response `200 OK`, an array of updates:

```json
[
  {
    "id": "chg_31kx8p2n",
    "title": "Changelog categories are here",
    "slug": "changelog-categories-are-here",
    "content": "<p>You can now group entries by category.</p>",
    "excerpt": "You can now group entries by category.",
    "imageUrl": null,
    "publishedAt": "2025-05-28T10:00:00.000Z"
  }
]
```

## Errors

Errors return a JSON body carrying the error tag and usually a message:

```json
{
  "_tag": "NotFoundError",
  "message": "Board not found"
}
```

| Status | `_tag`                      | Where it comes from                                        |
| ------ | --------------------------- | ---------------------------------------------------------- |
| 400    | `DataValidationError`       | Malformed body or query params; a private board on `/feedback` |
| 401    | `UnauthorizedError`         | A token that fails verification on `/feedback`              |
| 404    | `NotFoundError`             | No board matching `boardId` and `organizationId`            |
| 500    | `InternalServerError`       | Something failed on the server                              |
| 429    | `RateLimitExceededError`    | You crossed an endpoint's limit                             |
| 503    | `RateLimitUnavailableError` | The rate limiter itself is unreachable                      |

`message` is optional on the 400, 401, and 404 bodies. Handle its absence in client code.
`InternalServerError` always carries a `message` and may add a `detail`.

## Rate limits

Limits apply per client IP, per minute:

| Endpoint            | Limit                    |
| ------------------- | ------------------------ |
| `GET /boards`       | 120 requests             |
| `GET /updates`      | 120 requests             |
| `POST /feedback`    | 20 requests              |
| `POST /suggestions` | 5 requests               |

Suggestions cost more because each call runs a similarity search. If you batch-import old
feedback, keep every source IP under twenty `POST /feedback` calls per minute. Exceeding a
limit returns `429`. A `503` means the limiter itself was unreachable; back off and retry.

## Next steps

<CardGroup cols={2}>
  <Card title="Webhooks" href="/developers/webhooks">
    Subscribe to events such as feedback.post.created.
  </Card>
  <Card title="Widget SSO" href="/developers/widget-sso">
    Sign the JWTs this API accepts as identity tokens.
  </Card>
</CardGroup>
