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

# API v3 Reference (Private Beta)

> The next generation of the Formbricks management API, including the Feedback Records API

> [!WARNING]
>
> API v3 is in **private beta**. This reference is unlisted: it is not in the docs navigation and can change
> without notice. Please share the URL only with people who have been invited to the beta.

API v3 is the management API the [MCP server](/docs/platform/mcp/overview.md) is built on, and the one the Formbricks app
is moving onto. It covers surveys, workflows, action classes, contact attribute keys and — the reason most beta
users are here — **Feedback Records**, the normalized store behind Unify Feedback.

Every operation is listed in the sidebar of this section. This page covers what is not obvious from the
operation pages.

## Authentication

Create a management API key — **API Keys** in the organization dropdown of the breadcrumbs, see
[Generate API key](/docs/api-reference/generate-key.md) — and send it in the `x-api-key` header (an
`Authorization: Bearer <key>` header carrying the same key is also accepted). Keys carry per-workspace
permissions — `read`, `write` or `manage` — and every operation states which one it needs.

```bash theme={null}
curl "https://app.formbricks.com/api/v3/surveys?workspaceId=<workspaceId>" \
  -H "x-api-key: $FORMBRICKS_API_KEY"
```

Send credentialed requests over **HTTPS only** — an API key in `x-api-key` crosses the wire in cleartext
otherwise. Operations also accept a signed-in browser session, which is how the Formbricks app itself calls
them; the operation pages document the API-key contract.

### Self-hosted instances

Substitute your own origin, and note that Feedback Records is the one part of v3 that needs the API gateway
routed to it, because it is served by the gateway rather than the application:

* **Kubernetes / Helm** — off by default. Set `envoy.enabled: true` and
  `envoy.formbricks.routes.feedbackRecords: true`. Enabling the route also installs its authorization policy,
  so the two cannot drift apart.
* **One-click Docker** — the installer writes the Traefik routes for you, so it works out of the box.
* **Formbricks Cloud** — already enabled.

The quickest way to tell whether an instance has it: an unauthenticated `GET /api/v3/feedbackRecords` answers
`401` when the route is live, and `404` when it is not routed and the request falls through to the application.

## Feedback Records

A [Feedback Record](/docs/unify-feedback/feedback-records.md) is one piece of feedback in a normalized shape, stored in a
[Feedback Dataset](/docs/unify-feedback/feedback-datasets.md). The eight operations under `/api/v3/feedbackRecords` let
you list, filter, search, create, correct and delete them.

They are served by the **API gateway** rather than by the application routes that serve the rest of v3: the
gateway authenticates you, checks that the API key may act on the dataset, then forwards the call to the
feedback store. Three things follow from that, and they trip people up.

### `tenant_id` is your dataset id

The wire name for the dataset is **`tenant_id`**. It is required on every collection operation — as a query
parameter on `GET` and `DELETE /api/v3/feedbackRecords`, in the JSON body on `POST /api/v3/feedbackRecords` and
`POST /api/v3/feedbackRecords/search/semantic`. Single-record operations (`/{id}`, `/{id}/similar`) do not take
it: the gateway resolves the dataset from the record.

The dataset id is not shown in the app today. Get it from the MCP server's `list_feedback_datasets` tool, which
reports it as `dataset_id`, or ask your Formbricks contact.

### Field names are `snake_case`

The payload is the feedback store's own, passed through unchanged, so members and filters are `snake_case`
(`value_text`, `submission_id`, `created_since`) where the rest of v3 is camelCase. Filters on a value read off a
response use the same name as the response.

### Errors come in two shapes

| Layer | Statuses | Body |
| - | - | - |
| Gateway refusals | `400`, `401`, `403`, `503` | `text/plain` — a constant message such as `Forbidden` |
| Proxy refusal | `413` | `text/plain`, but the body is not contractual — see below |
| Feedback store | `400`, `404`, `409`, `500`, `503` | `application/problem+json` with a stable `code` to branch on |

An unknown dataset or record id is a `403` rather than a `404`, so ids cannot be probed, and any path under
`/api/v3/feedbackRecords` that is not one of the eight answers `400`. The `404`s that do exist all come from the
store: on `/{id}/similar` for a record that has no embedding yet, and on the single-record operations for a
record deleted in the last minute — the gateway caches the record's dataset for 60 seconds, so during that
window the call is still authorized and reaches a store that no longer has the record.

The `413` is the one refusal the application does not write itself: the proxy in front of it stops reading the
body at 1 MiB and answers on its own, so branch on that status rather than on its body.

### Permissions

| Operation | API key permission on a workspace the dataset is assigned to |
| - | - |
| List, get, semantic search, similar records | `read` |
| Create, update | `write` |
| Delete one record, delete a user's records | `manage` |

An API key may **update or delete** only in a dataset assigned to exactly one workspace. On a shared dataset no
workspace permission can say whose records they are, so those calls answer `403`; reading and creating stay
allowed. The organization must hold the Unify Feedback entitlement.

### Example

```bash theme={null}
# The 20 most recent negative text answers in a dataset
curl "https://app.formbricks.com/api/v3/feedbackRecords?tenant_id=<datasetId>&field_type=text&sentiment=negative&sentiment=very_negative&limit=20" \
  -H "x-api-key: $FORMBRICKS_API_KEY"
```

Pagination is keyset: pass the response's `next_cursor` back as `cursor` with the same `sort` and `order`. It is
absent on the last page.

### Incremental extraction

To keep a copy of a dataset in sync without re-reading it, list with `updated_since`. A record's `updated_at`
is stamped when it is created, corrected, or enriched (translation, sentiment, emotions), so one filter returns
both new and changed records.

```bash theme={null}
# Everything created or changed since the last run, in the order it changed
curl "https://app.formbricks.com/api/v3/feedbackRecords?tenant_id=<datasetId>&updated_since=2026-10-05T07:55:00Z&updated_until=2026-10-05T09:00:00Z&sort=updated_at&order=asc&limit=1000" \
  -H "x-api-key: $FORMBRICKS_API_KEY"
```

* **Sort by `updated_at`, ascending.** Every page is then one range of an index, so a sync reads only the
  records it returns. A record that changes while you page normally gets a newer `updated_at` and comes back
  later in the same run, so upsert by `id`. `updated_at` can only be sorted ascending: descending, or with no
  `order` (the default is `desc`), answers `400`. Even ascending, a write that started before your position and
  commits after you have read past it is missed by this run; the next run's `updated_since`, below, picks it up.
* **Bound each run with `updated_until`.** Otherwise a run keeps chasing records that change while it pages.
  Start each run with a cheap request (a page with `limit=1`) and read its `Date` header: that one value, *S*,
  is this run's `updated_until`, and *S* minus a few minutes is the next run's `updated_since`. Take both from
  the same *S* — an `updated_until` from your own clock can fall short of the next run's `updated_since` and
  leave records that neither run ever reads.
* **Next `updated_since`: *S* minus a few minutes.** *S* comes from our `Date` header, so drift between your
  clock and ours cannot open a gap. Do not use the newest `updated_at` you received: `updated_at` is when a
  write *started*, so a write still committing when you read past its timestamp is missing from this run with
  a timestamp older than the newest you saw, and a watermark taken from the results skips it for good. For the
  same reason the overlap has to exceed the longest write to a record, plus the time the first response took and
  the difference between the clock that stamped `Date` and the database's. Writes are short, so five minutes is
  a comfortable default, but no hard limit is enforced; the full sweep below repairs anything a slower write
  slips past.
* **Let the latest fetch win, and run one sync at a time** (the full sweep included). The overlap re-reads
  some records, so upsert by `id` and keep the copy from the most recent run. Don't compare `updated_at` to choose: two writes to one record can
  commit in the opposite order to the one they started in, so the newer state can carry the older timestamp.
* **Deletions are not returned.** A deleted record leaves nothing for `updated_since` to match. To reconcile
  them, periodically list every record id in the dataset with no `updated_since`, noting when that listing
  started from its first response's `Date` header. Remove the local records that were not returned **and
  were created before that start, minus the overlap**. Anything newer may have been stored by your sync after
  the listing passed it, and deleting it would lose it. This catches every kind of delete, including deleting
  a user's records. The sweep returns every record in full, so upsert what it returns too: that repairs any change
  the incremental sync missed. It reads the whole dataset, so run it less often than the incremental sync, for
  example daily against an hourly sync.

## Custom CSS

Survey CSS travels as `customCss` on the survey resource (`POST /api/v3/surveys`, `GET` and
`PATCH /api/v3/surveys/{surveyId}`); the CSS shared by every survey in a workspace has its own resource,
`GET` and `PATCH /api/v3/workspaces/{workspaceId}/custom-css`. Both take and return **source** —
`{ "light": "...", "dark": "..." }`, where light applies in both appearances and dark adds overrides. The server
compiles it on every write and never accepts compiled output.

* **Preview first.** `POST /api/v3/surveys/validate` with `operation: "customCss"` returns the compiled result and
  the constructs it removed (`warnings`, each with a stable `code` and a line and column), without saving
  anything.
* **Removed constructs are warnings, not errors.** `@import`, `@font-face`, `url()` and the like are dropped and
  the save succeeds; writes return them as a top-level `warnings` array beside `data`. Syntax errors and size or
  complexity limits are a `422` with `details.errors`, and the saved CSS stays in effect.
* **Who may write.** Survey CSS: anyone who may edit that survey. Workspace CSS: organization owners and
  managers, or an API key with `manage` access to the workspace — `write` is not enough.
* **Plan.** On Formbricks Cloud, adding or editing CSS needs the Scale plan (`403`
  `custom_css_plan_required`). Clearing it never does, and a `PATCH` that sends the stored CSS back unchanged is
  not an edit.
* **Undo.** The workspace resource keeps one `previous` revision; send its source back to restore it.

## Feedback

Beta feedback goes to your Formbricks contact or to [hola@formbricks.com](mailto:hola@formbricks.com).


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