> ## 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.

# List feedback records

> Lists the feedback records of one dataset, newest first, with optional filters and keyset
pagination. Served by the API gateway, which authorizes the call against `tenant_id` and
forwards it to the feedback store — see the introduction for how the two layers answer.

Requires an API key with **read** permission (or higher) on a workspace the dataset is assigned
to. The organization must hold the Unify Feedback entitlement.

Multi-value filters are OR-ed within a parameter and AND-ed across parameters. Repeat the
parameter to pass several values (`?field_type=text&field_type=rating`); comma-separated values
are **not** split. Filters on enrichment members (`sentiment`, `emotions`, `sentiment_score_*`)
never match records that have not been enriched — use `has_sentiment=false` / `has_emotions=false`
to find those.

**Incremental extraction.** To keep a copy of a dataset in sync, filter on `updated_since` and
page with `sort=updated_at&order=asc`, bounded by `updated_until`: every page is then one index
range, and a record that changes while you page normally comes back later in the run. Start each
run with a `limit=1` request and read its `Date` header: that value is this run's `updated_until`
and, minus a few minutes, the next run's `updated_since` — both bounds from the same value, or
records between them are never read. Do not use the newest `updated_at` received instead: it
skips a write that was still committing when you read past its timestamp.
Upsert by `id` and let the most recent fetch win. Deleted records leave nothing for
`updated_since` to match, so reconcile deletions separately: periodically list every record id
with no `updated_since`, and remove the local records that were not returned and were created
before that listing's start (its first response's `Date` header) minus the overlap. Run one
sync at a time.




## OpenAPI

````yaml /api-v3-reference/openapi.yml get /api/v3/feedbackRecords
openapi: 3.1.1
info:
  title: Formbricks API v3
  description: >
    **Surveys**: **GET /api/v3/surveys**, **POST /api/v3/surveys**, **POST
    /api/v3/surveys/generate**, **POST /api/v3/surveys/validate**, **GET
    /api/v3/surveys/{surveyId}**, **PATCH /api/v3/surveys/{surveyId}**, **PATCH
    /api/v3/surveys/{surveyId}/blocks**, **PUT
    /api/v3/surveys/{surveyId}/blocks/order**, and **DELETE
    /api/v3/surveys/{surveyId}**.


    **Custom CSS**: survey CSS through `customCss` on **POST /api/v3/surveys**,
    **GET** and **PATCH /api/v3/surveys/{surveyId}**, workspace CSS through
    **GET** and **PATCH /api/v3/workspaces/{workspaceId}/custom-css**, and
    previews through `operation: customCss` on **POST
    /api/v3/surveys/validate**. Writes take and reads return source only; the
    server compiles it, returns removed constructs as top-level `warnings`, and
    never accepts compiled output.


    **Responses**: **GET /api/v3/responses**, **GET /api/v3/responses/count**,
    **POST /api/v3/responses/validate**, **POST
    /api/v3/responses/batch-delete**, **GET /api/v3/responses/{responseId}**,
    **POST /api/v3/responses**, **PATCH /api/v3/responses/{responseId}**, and
    **DELETE /api/v3/responses/{responseId}**.


    **Workflows extension**: **GET /api/v3/workflows**, **POST
    /api/v3/workflows**, **GET /api/v3/workflows/{workflowId}**, **PATCH
    /api/v3/workflows/{workflowId}**, **DELETE /api/v3/workflows/{workflowId}**,
    **POST /api/v3/workflows/{workflowId}/duplicate**, **POST
    /api/v3/workflows/{workflowId}/enable**, **POST
    /api/v3/workflows/{workflowId}/disable**, **POST
    /api/v3/workflows/{workflowId}/archive**, **POST
    /api/v3/workflows/{workflowId}/unarchive**, **POST
    /api/v3/workflows/{workflowId}/test**, **GET /api/v3/workflows/runs**, and
    **GET /api/v3/workflows/runs/{runId}**.


    Authenticate all management endpoints with **session cookie** or
    **`x-api-key`** (management key with access to the workspace).


    **Spec location:** source of truth is the multi-file tree at
    `docs/api-v3-reference/src/` (root `openapi.yml` plus one file per path and
    component); `docs/api-v3-reference/openapi.yml` is the generated single-file
    bundle (alongside v2 at `docs/api-v2-reference/openapi.yml`).


    **workspaceId**

    `workspaceId` is the canonical container identifier for this API. It is a
    **required query parameter on collection read endpoints** (`GET
    /api/v3/surveys`, `GET /api/v3/action-classes`, `GET
    /api/v3/contact-attribute-keys`) because those operate within a workspace.
    **`POST /api/v3/surveys` instead requires `workspaceId` in the request
    body**, not as a query parameter. Single-resource endpoints
    (`GET`/`PATCH`/`DELETE /api/v3/surveys/{surveyId}`) locate the resource by
    its globally-unique id and resolve the workspace from it, so they do **not**
    accept a `workspaceId` query parameter — sending one returns **400** with an
    `invalid_params` entry naming the key itself (`name: "workspaceId"`,
    `reason: "Unsupported field 'workspaceId'"`), rather than one naming the
    body.


    **Auth**

    Authenticate with either a session cookie or **`x-api-key`**. In dual-auth
    mode, V3 checks the API key first when the header is present, otherwise it
    uses the session path. Unauthenticated callers get **401** before query
    validation.


    **Pagination**

    Cursor-based pagination with **limit** + opaque **cursor** token. Responses
    return `meta.nextCursor`; pass that value back as `cursor` to fetch the next
    page. Survey list responses also include `meta.totalCount`, the total number
    of surveys matching the current filters across all pages. Workflow list
    responses do not require total counts in Scope 1. There is no `offset` in
    this contract.

    The page-size **maximum is 250** for responses, surveys, action classes and
    contact attribute keys; only the *default* varies by resource (20 for
    surveys, 50 for the workspace reference lists). **The workflows and
    workflow-run lists remain at 100** — their bound lives in the
    `packages/workflows` contracts and moves with that package, so each
    documents the value it actually enforces. A `limit` above an endpoint's
    maximum is rejected with **400** rather than silently reduced, consistent
    with v3 rejecting unrecognized query parameters instead of ignoring them.
    Callers should not infer the end of a collection from a page shorter than
    `limit`; `meta.nextCursor` of `null` is the only end-of-collection signal.
    Note that array-valued *input* bounds are a separate concern and stay at 100
    — `filter[id][in]` and the batch-delete id list are bounded by write and
    payload risk, not by page cost.


    **Strictness**

    A query parameter that an operation does not declare returns **400**, with
    the offending key reported in `invalid_params[].name` — including on
    operations that declare no query parameters at all. Unknown parameters are
    rejected rather than ignored on purpose: a silently-dropped filter returns
    *more* data than the caller believes they asked for, and a typo in a filter
    name is indistinguishable from no filter at all. The same applies to request
    bodies, where unsupported fields are rejected instead of stripped.

    Unrecognized **headers** are tolerated, as gateways and proxies legitimately
    add them; strictness is about parameters and body fields, not transport
    metadata.

    This is the rule for the surveys, responses, tags and workspace
    reference-list operations. The workflows family does not enforce it today,
    so treat parameter strictness there as unspecified rather than guaranteed.

    **Request limits**

    A request body may be at most **2 MB**; a larger one is refused with **413**
    before it is read. Inside a body, **no array may hold more than 1 000 items,
    and all arrays together may hold at most 50 000 elements**, at any depth — a
    body that exceeds either bound is refused with **400** and a single
    `invalid_params` entry naming the offending array, before any field is
    validated. The same holds for nesting: a body may nest at most **256**
    levels of objects and arrays, counted from the root, and a deeper one is
    refused the same way, naming where it went too deep. Fields with a tighter
    bound document it as `maxItems` on the field (a survey's `blocks`,
    `endings`, `variables`, `languages`, `hiddenFields.fieldIds` and
    `distribution.triggers` since 0.2.0). A 400 or 422 lists at most **50**
    `invalid_params` entries; when there are more, the last entry says how many
    were left out. Since 0.2.0.

    **Filtering**

    Filters use explicit operator-style query parameters under the
    **`filter[...]` family**. The survey list supports `filter[name][contains]`,
    `filter[status][in]`, and `filter[type][in]`. The workflow list supports
    `filter[name][contains]` and `filter[status][in]`; the workflow run list
    supports `filter[status][in]` and `filter[isDryRun][eq]`. Resource-scoping
    identifiers (`workspaceId`, `workflowId`, `responseId`) stay plain query
    parameters. Multi-value filters use repeated keys or comma-separated values
    (e.g. `filter[status][in]=draft&filter[status][in]=inProgress` or
    `filter[status][in]=draft,inProgress`). Sorting remains a flat `sortBy`
    query parameter.


    **Responses**

    The response resource is **self-describing**. Answers come back as a typed
    `answers[]` array in which each entry carries the element's id, label, type
    and value, rather than as a map keyed by element id — so a consumer holding
    only a response can read it without fetching the survey and joining. Hidden
    fields, survey variables and auto-captured context are one `embeddedData[]`
    collection rather than three parallel concepts, each entry declaring a
    `kind` and a `type`. Data whose element has since been deleted is reported
    in `unresolved[]` instead of being dropped silently. Labels are resolved at
    read time against the current survey definition, and the `resolution` object
    discloses that policy along with the survey's `updatedAt` so a consumer can
    detect wording drift.

    Writes take answers in the **stored map form** (`data`) while reads return
    the typed array. Resolving ids into labelled answers is unambiguous; the
    reverse would mean matching caller-supplied labels back onto option ids, so
    the write side keeps the stored shape. To stop that costing
    round-trippability, the **detailed read returns `data` unchanged** — so a
    single-response read can be edited and sent back as `PATCH { "data": … }`,
    and the typed `answers[]` array is an addition rather than a replacement.


    One case where that round trip does not close: a write bound applies to
    `data` on the way in (`ResponseDataMapInput`) and none applies to the stored
    map, because v1 and v2 wrote it without one. A response carrying more answer
    keys than the write accepts therefore cannot be corrected through `PATCH` at
    all — the whole map is rejected, and sending a smaller one is not a
    workaround, because `data` is replaced wholesale and every key left out is
    deleted. Correct those through the v1/v2 management API or the dashboard.
    This is reachable only on surveys with more elements than the bound allows.

    Embedded Data is the exception, and it is symmetric rather than
    round-tripped: hidden fields and variables are read and written through
    `embeddedData`, addressed by **name** in both directions. No raw map of them
    is echoed, because a name is already what a write accepts — publishing the
    storage keys underneath (a variable's is its cuid) would expose a layout the
    caller has no use for. `data` therefore carries element answers only.

    Of those two, only `data` carries no `readOnly`, because it is the same map
    on the way in and on the way out. Embedded Data is writable through the
    **request's own** `embeddedData` map, not through the response field of that
    name — so `embeddedData[]`, like every other derived view on the resource,
    is marked `readOnly`, per its meaning of server-managed. `ResponseResource`
    lists them all. The list view omits `data`, since a bulk reader is not
    editing. `POST /api/v3/responses/validate` exists so a caller can see what a
    map would produce, and what a write would do, before committing to it.

    `createdAt` and `updatedAt` are **server-owned** on this resource: they
    cannot be supplied on create or changed by a patch. Response metering counts
    rows by `(surveyId, createdAt)`, and a client-chosen `createdAt` also lets
    rows be inserted into the middle of another consumer's already-paginated
    stream. Backdating stays available on v2 until a dedicated import path
    exists. The respondent's IP address and the `contactAttributes` snapshot
    that v1/v2 expose are not part of this resource in any view; that snapshot
    is copied from the contact at submission time and never refreshed, so it is
    stale PII by construction.

    Response filtering is allow-listed and index-backed:
    `filter[createdAt][gte|gt|lte|lt]` (at most one lower and one upper bound —
    `gte` xor `gt`, `lte` xor `lt`), `filter[finished][eq]`,
    `filter[language][in]` and `filter[id][in]`. `filter[finished][eq]` and
    `filter[language][in]` require `surveyId` or `contactId` as well, because
    both are heap residuals bounded only by an anchored index range. Per-answer,
    hidden-field, submission-metadata, contact-attribute, quota and tag filters
    are **not** offered; each is a JSON path or an unindexed join on the largest
    table in the product. The in-app dashboard does offer them, so this API is
    knowingly behind the dashboard here — closing the gap requires index work
    and is tracked separately rather than quietly omitted.

    `GET /api/v3/responses/count` answers "how many match?" directly. The list
    endpoint's `meta.totalCount` covers a caller that is already paging and is
    **opt-in** via `includeTotalCount` (default `false`, unlike the survey list)
    because responses are the highest-cardinality table in the product. Both
    response counts are **capped at 10 000** and carry an exactness flag —
    `meta.totalCountRelation` on the list, `data.relation` on `/count` — reading
    `eq` when the figure is exact and `gte` when the cap was reached and it is a
    lower bound. An exact count over this table grows superlinearly with the
    data while a capped one stays flat, so an uncapped total would be the only
    part of a response whose cost is unbounded. `/count` accepts
    `precision=exact` for the cases that genuinely need a precise figure,
    documented as the deliberate slow path. The survey list's `totalCount` stays
    exact and uncapped: surveys are low-cardinality and that count has a
    covering index built for it.

    Bulk deletion is `POST /api/v3/responses/batch-delete`, a custom method
    rather than a collection-level `DELETE`, so a call that loses its filters
    cannot degrade into erasing everything in scope. It scope-filters rather
    than rejecting: foreign and already-deleted ids are ignored and the call
    reports how many rows it removed, which keeps it both leak-free and safe to
    retry.

    **Security**

    Missing/forbidden workspace returns **403** with a generic message (not
    **404**) so resource existence is not leaked. Workflow and workflow-run
    detail endpoints also return **403** for unknown IDs or inaccessible IDs to
    avoid IDOR leaks. List responses use `private, no-store`.


    **AI survey creation**

    `POST /api/v3/surveys/generate` returns a draft `POST /api/v3/surveys`
    payload plus validation metadata. It does not create a survey. It generates
    survey content for both `link` and `app` surveys; for `app` it seeds a
    default `distribution` (display once, no triggers, no targeting) that you
    finish configuring before publishing.

    Prompt text is sent to the configured AI provider for generation, but it is
    not stored by this endpoint, not logged by default, and not included in
    audit data.


    **Feedback Records** `/api/v3/feedbackRecords` is served by the **API
    gateway** (Envoy on Formbricks Cloud, Traefik in the one-click Docker
    install), which authorizes each call against the dataset and forwards it to
    the feedback store — not by the application routes that serve the rest of
    v3. Three consequences: the payload is the store's own, so members and
    filters are **`snake_case`** (`tenant_id`, `value_text`) rather than v3's
    camelCase; the dataset is named **`tenant_id`** on the wire and is required
    on every collection operation (it is the feedback dataset id, which the MCP
    `list_feedback_datasets` tool reports as `dataset_id`); and errors come in
    two shapes — gateway refusals (**400**, **401**, **403**, **413**, **503**)
    are `text/plain`, while the store's own problems are
    `application/problem+json` with a stable `code`. These eight operations are
    the whole of the gateway-served surface; any other path it receives under
    the prefix answers **400**. Authenticate with **`x-api-key`**: the key needs
    a permission on a workspace the dataset is assigned to — `read` to list, get
    and search, `write` to create and update, `manage` to delete — and may
    update or delete only in a dataset assigned to exactly one workspace.

    **OpenAPI**

    This spec is **not** produced by `pnpm generate-api-specs` (that script only
    builds v2 → `docs/api-v2-reference/openapi.yml`). When the route contract
    changes, edit the files under `docs/api-v3-reference/src/`, then run `pnpm
    api:v3:bundle` to regenerate the committed single-file bundle. `pnpm
    api:v3:lint` lints the source tree; `pnpm api:v3:check` (CI) fails when the
    bundle is stale.


    **Workflows Scope 1**

    Workflows are a follow-up extension of the existing v3 Survey API work. The
    Survey API establishes API-first survey authoring; Workflows build on that
    same v3 style to automate actions around survey responses through
    workspace-scoped JSON definitions. Scope 1 models the existing Follow-ups
    capability as workflows: one `response.completed` trigger with an optional
    `endingCardIds` filter, and `send_email` actions with Follow-up email field
    parity. The definition and run shapes mirror the shared Zod schemas in
    `packages/workflows` (ENG-1100), which are the implementation source of
    truth for this contract.


    Workflows use `draft`, `enabled`, and `disabled` lifecycle states plus an
    `archived` soft-delete state. Status only changes through the lifecycle
    endpoints (`enable`, `disable`, `archive`, `unarchive`); it is not writable
    via `POST` or `PATCH`. Enabling validates that the definition is executable
    and snapshots an immutable workflow version; runs reference that snapshot
    via `workflowVersionId`. `POST .../test` is a dry run: it validates the
    definition and resolves the trigger's references synchronously, creating no
    run and sending no email, and returns `{ workflowId, ok, problems }`
    directly. Real runs execute asynchronously and are polled via `GET
    /api/v3/workflows/runs/{runId}`.


    **Overview migration note**

    The v3-backed survey overview page intentionally removes actions that are
    not yet exposed by this contract: `Created by` filtering, `Duplicate`,
    `Copy...`, `Preview`, and `Copy link`.


    **Next steps (out of scope for this spec)**

    Optional ETag/304, field selection, translation-specific writes, and survey
    version history.
  version: 0.2.0
  x-implementation-notes:
    route: apps/web/app/api/v3/surveys/route.ts
    generate-route: apps/web/app/api/v3/surveys/generate/route.ts
    query-parser: apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
    auth: apps/web/app/api/v3/lib/auth.ts
    workspace-resolution: apps/web/app/api/v3/lib/workspace-context.ts
    workflow-contract-ticket: ENG-1101
    workflow-shared-schemas: packages/workflows/src/types (feat/workflows-types)
    workflow-prisma-schema: packages/database/schema/workflows.prisma (feat/workflows-prisma-schema)
    openapi-generated: false
    spec-source: docs/api-v3-reference/src (regenerate bundle with `pnpm api:v3:bundle`)
    responses-contract-ticket: ENG-2868
    responses-rfc: v3 Responses API RFC sections 2b (resource) and 2c (endpoints)
    responses-stored-shape: packages/types/responses.ts (ZResponse, ZResponseData, ZResponseInput)
    responses-prisma-model: packages/database/schema/main.prisma (model Response)
    pagination-model: cursor
    cursor-pagination: supported
servers:
  - url: https://app.formbricks.com
    description: >-
      Formbricks Cloud. Paths in this spec already include the `/api/v3` prefix;
      self-hosted instances substitute their own origin.
security: []
paths:
  /api/v3/feedbackRecords:
    get:
      tags:
        - V3 Feedback Records
      summary: List feedback records
      description: >
        Lists the feedback records of one dataset, newest first, with optional
        filters and keyset

        pagination. Served by the API gateway, which authorizes the call against
        `tenant_id` and

        forwards it to the feedback store — see the introduction for how the two
        layers answer.


        Requires an API key with **read** permission (or higher) on a workspace
        the dataset is assigned

        to. The organization must hold the Unify Feedback entitlement.


        Multi-value filters are OR-ed within a parameter and AND-ed across
        parameters. Repeat the

        parameter to pass several values (`?field_type=text&field_type=rating`);
        comma-separated values

        are **not** split. Filters on enrichment members (`sentiment`,
        `emotions`, `sentiment_score_*`)

        never match records that have not been enriched — use
        `has_sentiment=false` / `has_emotions=false`

        to find those.


        **Incremental extraction.** To keep a copy of a dataset in sync, filter
        on `updated_since` and

        page with `sort=updated_at&order=asc`, bounded by `updated_until`: every
        page is then one index

        range, and a record that changes while you page normally comes back
        later in the run. Start each

        run with a `limit=1` request and read its `Date` header: that value is
        this run's `updated_until`

        and, minus a few minutes, the next run's `updated_since` — both bounds
        from the same value, or

        records between them are never read. Do not use the newest `updated_at`
        received instead: it

        skips a write that was still committing when you read past its
        timestamp.

        Upsert by `id` and let the most recent fetch win. Deleted records leave
        nothing for

        `updated_since` to match, so reconcile deletions separately:
        periodically list every record id

        with no `updated_since`, and remove the local records that were not
        returned and were created

        before that listing's start (its first response's `Date` header) minus
        the overlap. Run one

        sync at a time.
      operationId: listFeedbackRecordsV3
      parameters:
        - $ref: '#/components/parameters/FeedbackRecordsTenantIdQuery'
        - in: query
          name: submission_id
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
              maxLength: 255
          description: >-
            Records belonging to any of these logical submissions (e.g. response
            ids).
        - in: query
          name: source_type
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
              maxLength: 255
          description: Records from any of these source types, e.g. `survey`, `review`.
        - in: query
          name: source_id
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
              maxLength: 255
          description: Records from any of these sources (e.g. survey ids).
        - in: query
          name: source_name
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
              maxLength: 255
          description: >-
            Records whose source display name is any of these. Prefer
            `source_id` where records carry one: a name can be edited or
            translated, the id is stable.
        - in: query
          name: field_id
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
              maxLength: 255
          description: Every answer to any of these questions.
        - in: query
          name: field_group_id
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
              maxLength: 255
          description: Records in any of these field groups (ranking / matrix questions).
        - in: query
          name: field_type
          style: form
          explode: true
          schema:
            type: array
            maxItems: 9
            items:
              $ref: '#/components/schemas/FeedbackRecordFieldType'
          description: >-
            Records of any of these field types. An empty value is ignored, so
            `?field_type=` equals omitting the filter.
        - in: query
          name: value_id
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
              maxLength: 255
          description: >-
            Records whose selected option id is any of these (e.g. every pick of
            one survey choice).
        - in: query
          name: user_id
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
              maxLength: 255
          description: Everything any of these end users submitted.
        - in: query
          name: language
          style: form
          explode: true
          schema:
            type: array
            maxItems: 100
            items:
              type: string
              maxLength: 10
          description: Records given in any of these languages (ISO codes).
        - in: query
          name: since
          schema:
            type: string
            format: date-time
          description: >-
            `collected_at >= since` (ISO 8601, inclusive). Must be between
            1970-01-01 and 2080-12-31.
        - in: query
          name: until
          schema:
            type: string
            format: date-time
          description: '`collected_at <= until` (ISO 8601, inclusive).'
        - in: query
          name: created_since
          schema:
            type: string
            format: date-time
          description: >-
            `created_at >= created_since` (ISO 8601, inclusive). `created_at` is
            when the record was stored; `collected_at` (see `since`) is when the
            feedback was given. They diverge on a historical re-import, so this
            is the filter for "what did this import bring in".
        - in: query
          name: created_until
          schema:
            type: string
            format: date-time
          description: '`created_at <= created_until` (ISO 8601, inclusive).'
        - in: query
          name: updated_since
          schema:
            type: string
            format: date-time
          description: >-
            `updated_at >= updated_since` (ISO 8601, inclusive). `updated_at` is
            stamped when a record is created, corrected, or enriched
            (translation, sentiment, emotions), so this selects records created
            **or changed** since a point — the filter for incremental
            extraction. Deleted records are never returned; see "Incremental
            extraction" above for choosing the next value and for detecting
            deletions.
        - in: query
          name: updated_until
          schema:
            type: string
            format: date-time
          description: '`updated_at <= updated_until` (ISO 8601, inclusive).'
        - in: query
          name: value_number_min
          schema:
            type: number
            format: double
          description: >-
            `value_number >= value_number_min` (inclusive), e.g. NPS promoters
            with `value_number_min=9`. Records without a numeric answer are
            excluded. A max below the min answers **400**.
        - in: query
          name: value_number_max
          schema:
            type: number
            format: double
          description: >-
            `value_number <= value_number_max` (inclusive). Paired with the min
            it selects a band: `9..10` promoters, `0..6` detractors.
        - in: query
          name: value_date_min
          schema:
            type: string
            format: date-time
          description: >-
            `value_date >= value_date_min` (inclusive) — bounds the answer to a
            date question, not when the feedback was collected.
        - in: query
          name: value_date_max
          schema:
            type: string
            format: date-time
          description: '`value_date <= value_date_max` (inclusive).'
        - in: query
          name: sentiment
          style: form
          explode: true
          schema:
            type: array
            maxItems: 6
            items:
              $ref: '#/components/schemas/FeedbackRecordSentiment'
          description: >-
            Records carrying any of these sentiment labels. An empty value is
            ignored.
        - in: query
          name: emotions
          style: form
          explode: true
          schema:
            type: array
            maxItems: 6
            items:
              $ref: '#/components/schemas/FeedbackRecordEmotion'
          description: >-
            Records tagged with **any** of these emotions: a record tagged
            `{joy}` and one tagged `{joy, anger}` both match
            `?emotions=joy&emotions=anger`. There is no "all of them" form.
        - in: query
          name: sentiment_score_min
          schema:
            type: number
            format: double
            minimum: -1
            maximum: 1
          description: >-
            `sentiment_score >= sentiment_score_min` (inclusive). The score is
            continuous where the label is bucketed, so this is the filter for
            "the most negative feedback".
        - in: query
          name: sentiment_score_max
          schema:
            type: number
            format: double
            minimum: -1
            maximum: 1
          description: '`sentiment_score <= sentiment_score_max` (inclusive).'
        - in: query
          name: has_sentiment
          schema:
            type: boolean
          description: >-
            `true` selects enriched records (a sentiment label is present),
            `false` the not-yet-enriched ones. Omit for no constraint.
        - in: query
          name: has_emotions
          schema:
            type: boolean
          description: >-
            `true` selects records carrying emotion labels, `false` those
            without. Note `false` covers both "not yet classified" and
            "classified, no emotion detected".
        - in: query
          name: has_translation
          schema:
            type: boolean
          description: >-
            `true` selects translated records, `false` untranslated ones. Omit
            for no constraint.
        - in: query
          name: sort
          schema:
            type: string
            enum:
              - collected_at
              - created_at
              - updated_at
            default: collected_at
          description: >-
            Column to order by. `collected_at` and `created_at` never change.
            `updated_at` does, so it is accepted only with `order=asc`: a record
            that changes while you page then normally moves ahead of the cursor
            and is returned again (upsert it by `id`). Descending, or with no
            `order` (which defaults to `desc`), it answers **400**. Even
            ascending, a write that started before the cursor's position and
            commits after it is missed by that run and picked up by the next,
            whose `updated_since` is the previous run's start minus an overlap.
            Use `sort=updated_at&order=asc` with `updated_since` and
            `updated_until` for incremental extraction.
        - in: query
          name: order
          schema:
            type: string
            enum:
              - asc
              - desc
            default: desc
          description: >-
            Sort direction. Rows tied on the sort column are ordered by `id`
            ascending.
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 100
          description: >-
            Page size. Out-of-range and non-numeric values are rejected — `-5`
            and `1001` answer **400** — with one exception: `limit=0` is
            indistinguishable from omitting the parameter, so it is accepted and
            the default of `100` applies. Send no `limit` rather than `0` if you
            are computing it. The search operations behave differently again:
            they clamp instead of rejecting.
        - in: query
          name: cursor
          schema:
            type: string
          description: >-
            Opaque keyset cursor returned as `next_cursor` by the previous page.
            Omit on the first request. A cursor is a position within one
            specific ordering: presenting it with a different `sort` or `order`
            answers **400** — restart without a cursor instead.
      responses:
        '200':
          description: Feedback records retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FeedbackRecordList'
              examples:
                basic:
                  summary: One page of two records
                  value:
                    data:
                      - id: 018e1234-5678-7abc-8ef0-123456789abc
                        tenant_id: ph7zv3w2u1x5k9d8m4q6c0bn
                        collected_at: '2026-08-15T10:30:00Z'
                        created_at: '2026-08-15T10:30:02Z'
                        updated_at: '2026-08-15T10:30:02Z'
                        source_type: survey
                        source_id: cm2k7q9x00003v8h1a2b3c4d5
                        source_name: Post-match survey
                        submission_id: cm2k7qa1z0007v8h1e6f7g8h9
                        field_id: q1
                        field_label: How satisfied were you with the stadium experience?
                        field_type: rating
                        value_number: 4
                        user_id: fan-8813
                        language: en
                      - id: 018e1234-5678-7abc-8ef0-123456789abd
                        tenant_id: ph7zv3w2u1x5k9d8m4q6c0bn
                        collected_at: '2026-08-15T10:30:00Z'
                        created_at: '2026-08-15T10:30:02Z'
                        updated_at: '2026-08-15T10:31:40Z'
                        source_type: survey
                        source_id: cm2k7q9x00003v8h1a2b3c4d5
                        source_name: Post-match survey
                        submission_id: cm2k7qa1z0007v8h1e6f7g8h9
                        field_id: q2
                        field_label: What could we improve?
                        field_type: text
                        value_text: Queues at the north gate were far too long.
                        user_id: fan-8813
                        language: en
                        sentiment: negative
                        sentiment_score: -0.62
                        emotions:
                          - anger
                    limit: 100
                    next_cursor: >-
                      eyJ0IjoiMjAyNi0wOC0xNVQxMDozMDowMFoiLCJpIjoiMDE4ZTEyMzQtNTY3OC03YWJjLThlZjAtMTIzNDU2Nzg5YWJkIn0=
        '400':
          $ref: '#/components/responses/FeedbackRecordsBadRequest'
        '401':
          $ref: '#/components/responses/FeedbackRecordsUnauthorized'
        '403':
          $ref: '#/components/responses/FeedbackRecordsForbidden'
        '500':
          $ref: '#/components/responses/FeedbackRecordsInternalServerError'
        '503':
          $ref: '#/components/responses/FeedbackRecordsServiceUnavailable'
      security:
        - apiKeyAuth: []
components:
  parameters:
    FeedbackRecordsTenantIdQuery:
      in: query
      name: tenant_id
      required: true
      schema:
        type: string
        format: cuid2
      description: >-
        The feedback dataset to operate on (the dataset id, as shown by the MCP
        `list_feedback_datasets` tool). **Required by the gateway on this
        operation**: a missing or malformed value answers **400** before the
        request reaches the feedback store, and a dataset the API key's
        workspace is not assigned to answers **403**. The two `POST` operations
        take the same value in the request body instead, and the single-record
        operations derive it from the record.
  schemas:
    FeedbackRecordFieldType:
      type: string
      description: >-
        The type of a feedback field, which determines which `value_*` member
        carries the answer: `text` → `value_text` (the only enrichable type),
        `categorical` → `value_text` + `value_id`, `nps` / `csat` / `ces` /
        `rating` / `number` → `value_number`, `boolean` → `value_boolean`,
        `date` → `value_date`.
      enum:
        - text
        - categorical
        - nps
        - csat
        - ces
        - rating
        - number
        - boolean
        - date
    FeedbackRecordSentiment:
      type: string
      description: >-
        Sentiment polarity label inferred from `value_text` by the sentiment
        enrichment: five ordinal levels plus a distinct `mixed`.
        Server-generated and read-only.
      enum:
        - very_negative
        - negative
        - neutral
        - positive
        - very_positive
        - mixed
    FeedbackRecordEmotion:
      type: string
      description: >-
        A single emotion label inferred from `value_text` by the emotion
        enrichment (the six Ekman basic emotions). Emotions are multi-label, so
        a record carries zero or more of these; "mixed" is not a label, it is
        two or more present at once. Server-generated and read-only.
      enum:
        - joy
        - anger
        - sadness
        - fear
        - surprise
        - disgust
    FeedbackRecordList:
      type: object
      additionalProperties: false
      required:
        - data
        - limit
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/FeedbackRecord'
        limit:
          type: integer
          description: The page size that was applied.
        next_cursor:
          type: string
          description: >-
            Opaque keyset cursor for the next page. **Present only when there
            may be more results**; absent on the last page. Pass it back
            unchanged as `cursor`, with the same `sort` and `order`.
    FeedbackRecord:
      type: object
      description: >-
        One piece of feedback in the normalized Feedback Record shape. Field
        names are `snake_case`: the payload is the Hub's, passed through the API
        gateway unchanged. Optional members are **absent** (not `null`) when
        unset, except `taxonomy` on the get-one response, which is null if its
        lookup fails.
      additionalProperties: false
      required:
        - id
        - collected_at
        - created_at
        - updated_at
        - source_type
        - field_id
        - field_type
        - submission_id
        - tenant_id
      properties:
        taxonomy:
          description: >-
            Read-only active directory taxonomy assignment. Returned only by the
            get-one operation; null if the lookup is temporarily unavailable.
          oneOf:
            - $ref: '#/components/schemas/FeedbackRecordTaxonomy'
            - type: 'null'
        id:
          type: string
          format: uuid
          description: UUIDv7 primary key.
        tenant_id:
          type: string
          format: cuid2
          description: >-
            The feedback dataset this record belongs to (the dataset id, as
            shown by the MCP `list_feedback_datasets` tool).
        collected_at:
          type: string
          format: date-time
          description: When the feedback was given.
        created_at:
          type: string
          format: date-time
          description: >-
            When the record was stored. Diverges from `collected_at` on a
            historical re-import.
        updated_at:
          type: string
          format: date-time
          description: When the record was last changed.
        source_type:
          type: string
          description: Type of feedback source, e.g. `survey`, `review`, `feedback_form`.
        source_id:
          type: string
          description: Reference to the survey / form / ticket the record came from.
        source_name:
          type: string
          description: Human-readable name of the source.
        submission_id:
          type: string
          description: >-
            Identifier of the logical submission this record belongs to (e.g. a
            response id). Records are unique per (`tenant_id`, `submission_id`,
            `field_id`).
        field_id:
          type: string
          description: Identifier of the question / field.
        field_label:
          type: string
          description: The question text.
        field_group_id:
          type: string
          description: >-
            Stable identifier grouping related fields (ranking, matrix and grid
            questions).
        field_group_label:
          type: string
          description: Human-readable question text for the group.
        field_type:
          $ref: '#/components/schemas/FeedbackRecordFieldType'
        language:
          type: string
          description: ISO language code the feedback was given in.
        user_id:
          type: string
          description: End-user identifier (e.g. an anonymous id or an email hash).
        value_text:
          type: string
          description: Text answer.
        value_id:
          type: string
          description: >-
            Stable id of the selected option in the source system (e.g. a survey
            choice id), stored alongside `value_text` so choice answers keep
            their identity across label edits and languages. Opaque. Absent for
            free-text and non-choice answers.
        value_number:
          type: number
          format: double
          description: Numeric answer (ratings, NPS scores, numbers).
        value_boolean:
          type: boolean
          description: Yes / no answer.
        value_date:
          type: string
          format: date-time
          description: Date answer.
        metadata:
          type:
            - object
            - array
            - string
            - number
            - boolean
            - 'null'
          items: {}
          description: >-
            Arbitrary context stored with the record and returned with
            equivalent values (key order and number formatting are normalized).
            Omitted when the record has no metadata; a record explicitly stored
            with a JSON `null` returns `null`. An object is the supported shape
            and the only one the write operations accept; the wider union exists
            so records written before that convention still deserialize.
        sentiment:
          $ref: '#/components/schemas/FeedbackRecordSentiment'
        sentiment_score:
          type: number
          format: double
          minimum: -1
          maximum: 1
          description: >-
            Signed sentiment polarity from `-1.0` (very negative) to `1.0` (very
            positive). Read-only; absent until the record is enriched.
        emotions:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/FeedbackRecordEmotion'
          description: >-
            Emotions inferred from `value_text`; multi-label from a fixed set.
            Read-only, and **never an empty array** — the column is either
            absent or has at least one label. Absent covers two different
            states: not yet classified, and classified with no emotion detected.
            Do not read absence as "not enriched yet"; `has_emotions=false`
            selects both, and the enrichment-status endpoint is what
            distinguishes them.
        value_text_translated:
          type: string
          description: >-
            `value_text` translated into the dataset's configured target
            language. Read-only; absent until the record is enriched.
        translation_lang_key:
          type: string
          maxLength: 35
          description: >-
            BCP-47 locale that `value_text_translated` was produced in.
            Read-only; absent until the record is enriched.
    HubProblem:
      type: object
      additionalProperties: false
      description: >-
        RFC 9457 problem details (`application/problem+json`) as produced by the
        feedback store behind the API gateway. Branch on the stable `code`
        member rather than on `title` or `type`. Note the member names differ
        from the rest of v3 (`request_id`, not `requestId`) — the payload is
        passed through unchanged.
      required:
        - title
        - status
        - code
        - request_id
      properties:
        type:
          type: string
          format: uri
          description: A URI reference identifying the problem type.
        title:
          type: string
          description: >-
            Short, human-readable summary of the problem type; stable across
            occurrences.
        status:
          type: integer
          description: HTTP status code.
        code:
          type: string
          description: >-
            Stable, machine-readable error code. `service_unavailable` means a
            feature or dependency is temporarily unavailable and the request may
            be retried later; `tenant_write_conflict` means the write collided
            with an in-progress dataset purge and may be retried unchanged.
            Every other code is terminal until the request itself changes.
          enum:
            - validation
            - bad_request
            - unauthorized
            - forbidden
            - not_found
            - conflict
            - tenant_write_conflict
            - method_not_allowed
            - content_too_large
            - service_unavailable
            - internal_server_error
        detail:
          type: string
          description: Human-readable explanation specific to this occurrence.
        instance:
          type: string
          description: >-
            URI reference identifying this occurrence — the request path **as
            the feedback store saw it** (`/v1/feedback-records/...`), not the
            `/api/v3/feedbackRecords/...` path you called.
        request_id:
          type: string
          description: >-
            Identifier for this request, also returned in the `X-Request-ID`
            response header; include it when reporting a problem.
        details:
          type: object
          additionalProperties: true
          description: >-
            Optional structured context, e.g. `resource_type` on a not-found
            problem.
        invalid_params:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/HubInvalidParam'
          description: Field-level validation failures. Present on `validation` problems.
    FeedbackRecordTaxonomy:
      type: object
      description: >-
        Read-time view of the record's membership in the active directory
        taxonomy.
      additionalProperties: false
      required:
        - status
        - run_id
        - path
      properties:
        status:
          type: string
          enum:
            - classified
            - unclassified
            - no_active_taxonomy
        run_id:
          oneOf:
            - type: string
              format: uuid
            - type: 'null'
        path:
          type: array
          description: >-
            Currently visible non-root nodes in root-to-leaf order; empty unless
            classified.
          items:
            type: object
            additionalProperties: false
            required:
              - id
              - label
              - level
              - node_type
            properties:
              id:
                type: string
                format: uuid
              label:
                type: string
              level:
                type: integer
              node_type:
                type: string
                enum:
                  - branch
                  - leaf
    HubInvalidParam:
      type: object
      additionalProperties: false
      description: >-
        A single invalid request parameter (RFC 9457 `invalid_params` extension
        member).
      required:
        - name
        - reason
      properties:
        name:
          type: string
          description: >-
            Dotted path to the offending request field, e.g. `field_type` or
            `items[0].field_type`.
        reason:
          type: string
          description: >-
            Human- and agent-readable explanation of why the value is invalid,
            naming allowed values or constraints so the request can be corrected
            without guessing.
  responses:
    FeedbackRecordsBadRequest:
      description: >-
        Bad Request. From the **gateway** (`text/plain`): a missing or malformed
        `tenant_id`; a request body that is not a single JSON object; a body
        whose top-level keys can be read more than one way (a repeated key, a
        key with non-ASCII characters, or a key that differs from `tenant_id`
        only in letter case); a `tenant_id` query parameter given more than
        once; or a path under `/api/v3/feedbackRecords` that is not one of the
        documented operations. A record id that is not a UUID and a path such as
        `/count` both fall in that last case and share the one `Unsupported
        FeedbackRecords route` body, so the two cannot be told apart from the
        response. From the **feedback store** (`application/problem+json`):
        schema validation failures — an unknown enum value, an inverted range, a
        malformed or re-ordered cursor, or a string with NULL bytes or invalid
        UTF-8.
      content:
        text/plain:
          schema:
            type: string
          examples:
            missingTenant:
              summary: Gateway — tenant_id missing or malformed
              value: Invalid or missing tenant_id
            invalidBody:
              summary: >-
                Gateway — body is not a single JSON object, or has a repeated or
                non-ASCII key
              value: Invalid request body
            ambiguousTenant:
              summary: >-
                Gateway — tenant_id given in more than one spelling, or repeated
                in the query
              value: Ambiguous tenant_id
            unsupportedRoute:
              summary: Gateway — not a documented operation
              value: Unsupported FeedbackRecords route
        application/problem+json:
          schema:
            $ref: '#/components/schemas/HubProblem'
          examples:
            invertedRange:
              summary: Feedback store — a range filter supplied backwards
              value:
                type: https://hub.formbricks.com/problems/validation
                title: Validation Failed
                status: 400
                code: validation
                detail: One or more request parameters are invalid
                request_id: 0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b
                invalid_params:
                  - name: sentiment_score_min
                    reason: must be less than or equal to sentiment_score_max
    FeedbackRecordsUnauthorized:
      description: >-
        Unauthorized — no credentials, or an API key that does not exist or is
        revoked. Answered by the gateway as `text/plain`, before the request
        reaches the feedback store.
      content:
        text/plain:
          schema:
            type: string
          example: Unauthorized
    FeedbackRecordsForbidden:
      description: >-
        Forbidden. Answered by the gateway as `text/plain` whenever the request
        cannot be authorized: the dataset does not exist, is archived, or is not
        assigned to a workspace the API key has the required permission on; the
        organization lacks the Unify Feedback entitlement; a mutation was
        attempted with an API key on a dataset shared by more than one
        workspace; or the record id does not exist. Existence is not leaked: an
        unknown dataset or record is refused here rather than with a 404. The
        single-record operations are the one exception, and only for a minute
        after a delete — see their `404`.
      content:
        text/plain:
          schema:
            type: string
          example: Forbidden
    FeedbackRecordsInternalServerError:
      description: >-
        Internal Server Error from the feedback store
        (`application/problem+json`, `code: internal_server_error`).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/HubProblem'
    FeedbackRecordsServiceUnavailable:
      description: >-
        Service Unavailable. From the **gateway** (`text/plain`): the record's
        dataset could not be resolved because the feedback store did not answer,
        the authorization service itself was unreachable, or the record's stored
        `tenant_id` is not a dataset id this API can address. From the
        **feedback store** (`application/problem+json`, `code:
        service_unavailable`): the operation needs a capability that is not
        configured on this instance — semantic search and similar records
        require an embedding model.
      content:
        text/plain:
          schema:
            type: string
          example: Feedback record lookup failed
        application/problem+json:
          schema:
            $ref: '#/components/schemas/HubProblem'
  securitySchemes:
    apiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: >
        Management API key; must include **workspaceId** as an allowed workspace
        with read, write, or manage permission.

````

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