# GENERATED FILE — do not edit. Source of truth: docs/api-v3-reference/src/ (one file per path/schema). # Regenerate with `pnpm api:v3:bundle`; CI verifies freshness with `pnpm api:v3:check`. # V3 API — surveys, responses, workflows, action classes, contact attribute keys, tags and feedback records (hand-maintained source; not produced by generate-api-specs). # Zod schemas for the adopted tags are generated from this file: `pnpm api:v3:schemas` (`pnpm api:v3:sync` runs both steps). 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}**. **Creatives**: **GET /api/v3/creatives**, **POST /api/v3/creatives**, **GET**, **PATCH** and **DELETE /api/v3/creatives/{creativeId}**, **POST /api/v3/creatives/{creativeId}/archive**, **/restore** and **/duplicate**, **GET /api/v3/creatives/{creativeId}/stats**, and **POST /api/v3/creatives/pause**, which pauses every creative that opens a survey. A creative is a feedback button or a banner the web SDK renders on the customer's site; pressing it opens its Destination, a survey or a link. Writes set the author's `status`; reads add the derived `displayStatus`; `stats` returns its daily Impressions and Clicks. Creatives needs a self-hosted license that includes it; Formbricks Cloud does not offer it. With one, every operation is available. Without one, a workspace that still has creatives is in **wind-down**: its creatives are no longer shown on sites, and they can only be listed, read (stats included), paused (a PATCH that sets nothing but `status`, or **POST /api/v3/creatives/pause**), closed (the same PATCH) or deleted. A workspace with no creatives has no access at all. Anything not allowed answers **403** `creatives_not_entitled`. **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}**. **Charts and dashboards**: charts on a Feedback Dataset (**GET**, **POST /api/v3/charts**; **GET**, **PATCH**, **DELETE /api/v3/charts/{chartId}**; **POST /api/v3/charts/{chartId}/duplicate**), their numbers (**GET /api/v3/charts/{chartId}/data**, **POST /api/v3/charts/preview**), the field catalog (**GET /api/v3/charts/fields**, **GET /api/v3/charts/fields/{field}/values**), and dashboards (**GET**, **POST /api/v3/dashboards**; **GET**, **PATCH**, **PUT**, **DELETE /api/v3/dashboards/{dashboardId}**; **POST /api/v3/dashboards/{dashboardId}/duplicate**; **POST /api/v3/dashboards/{dashboardId}/widgets**; **GET**, **PATCH**, **DELETE /api/v3/dashboards/{dashboardId}/widgets/{widgetId}**). - **Field catalog.** A chart query names fields exactly as `GET /api/v3/charts/fields` lists them; the validator refuses anything else, and Cube options (`timezone`, `segments`, `ungrouped`, …), with **422** naming each problem. Counts, filter depth, `limit` and `offset` are bounded. - **Date ranges.** A preset from the catalog's `datePresets`, `all-time`, or two ISO dates, both inclusive, read in the organization's time zone. On the data operation a requested range replaces the chart's own range on `collectedAt`, the way a dashboard's date filter does. - **Data needs the dataset.** Reading a chart's rows, previewing one and looking up field values need read access to its Feedback Dataset as well as the workspace. They share one budget of **600 per minute** per user or key, sized for dashboards that load one query per widget, and at most **20 run at once** (429 with `Retry-After: 1` past that). - **Plan.** Every operation answers **403 `dashboards_plan_required`** for an organization without the Dashboards feature, after authorization. - **Shared charts.** A chart can sit on several dashboards; each chart says which in `dashboards`. Editing it changes all of them — duplicate it and repoint one dashboard's widget to change it there only. A chart still on a dashboard cannot be deleted: **409 `chart_in_use`** lists them in `details.dashboards`. - **Grid.** Widgets sit on a 12-column grid in whole units (`1 ≤ w ≤ 12`, `x + w ≤ 12`, `1 ≤ h ≤ 8`, `0 ≤ y ≤ 1000`). Gaps are allowed, overlaps answer **422** naming the widget, and a chart appears at most once per dashboard (**409**). A widget added without a layout takes the first open slot at its chart type's default size. - **Stale-check.** `PUT /api/v3/dashboards/{dashboardId}` saves the name and every widget at once and removes widgets left out. Send the `updatedAt` you read: if the dashboard changed since, nothing is saved and **409** carries `expectedUpdatedAt` and `currentUpdatedAt` in `details`. Every widget change moves `updatedAt`. 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`, `GET /api/v3/contacts`, `GET /api/v3/segments`) 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, contact and segment list responses also include `meta.totalCount`, the total number of items matching the current filters across all pages (`null` when `includeTotalCount=false`). The contact-attribute-key and workflow lists report it only when asked with `includeTotalCount=true`. There is no `offset` in this contract. The page-size **maximum is 250** for responses, surveys, action classes, contact attribute keys, contacts and segments; 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: - apiKeyAuth: [] - bearerAuth: [] - sessionAuth: [] paths: /api/v3/surveys: get: operationId: getSurveysV3 summary: List surveys description: | Returns the surveys in the workspace **that the caller may read**. A restricted survey is listed only to its owner and to the organisation's owners and managers; to everyone else, API keys included, it does not exist here. `meta.totalCount` and `meta.workspaceSurveyCount` count that same readable set, so neither number reveals a restricted survey to someone who cannot see it. The visibility predicate is applied in the query and the cursor, never after the fact: `limit` keeps its meaning and `meta.nextCursor` being `null` remains the only end-of-collection signal. Session cookie, `x-api-key`, or a Bearer token. tags: - V3 Surveys parameters: - in: query name: workspaceId required: true schema: type: string format: cuid2 description: | Workspace identifier. This is the canonical container ID for v3 APIs. - in: query name: limit schema: type: integer minimum: 1 maximum: 250 default: 20 description: Page size (max 250) - in: query name: cursor schema: type: string description: | Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request. - in: query name: includeTotalCount schema: type: boolean default: true description: | Whether to calculate `meta.totalCount` for this request. Set to `false` on cursor-pagination follow-up requests to skip the extra count query; in that case `meta.totalCount` is `null`. - in: query name: filter[name][contains] schema: type: string maxLength: 512 description: Case-insensitive substring match on survey name (same as in-app list filters). - in: query name: filter[status][in] schema: type: array items: type: string enum: - draft - inProgress - paused - completed - archived style: form explode: true description: | Survey status filter. Repeat the parameter (`filter[status][in]=draft&filter[status][in]=inProgress`) or use comma-separated values (`filter[status][in]=draft,inProgress`). Invalid values → **400**. `archived` is a pseudo-status: it does not match a real survey status but includes archived (soft-deleted) surveys in the result set. Archived surveys are otherwise excluded from the default list. - in: query name: filter[type][in] schema: type: array items: type: string enum: - link - app style: form explode: true description: Survey type filter (`link` / `app`). Same repeat-or-comma rules as `filter[status][in]`. - in: query name: filter[visibility][in] schema: type: array items: $ref: '#/components/schemas/SurveyVisibility' style: form explode: true description: 'Keep only surveys with one of these visibilities, as each item reports it (a survey with a change in flight counts as `restricted`). Same repeat-or-comma rules as `filter[status][in]`; unknown values → **400**. Filters within the readable set: a workspace member asking for `restricted` gets their own restricted surveys, an organisation manager gets everyone''s.' - in: query name: filter[owner][in] schema: type: array items: type: string enum: - me - others style: form explode: true description: '`me` keeps surveys whose `owner` is the caller; `others` keeps the rest, ownerless surveys included. Relative to the signed-in user, so an API key sending this parameter gets **400**.' - in: query name: filter[singleUse][eq] schema: type: boolean description: '`true` keeps only single-use surveys (single-use links turned on), `false` only the others. Omit it to list both. Applied in the query, so `limit` and the counts keep their meaning.' - in: query name: filter[pinProtected][eq] schema: type: boolean description: '`true` keeps only surveys that ask for a PIN before they open, `false` only the others. Omit it to list both. Applied in the query, so `limit` and the counts keep their meaning.' - in: query name: sortBy schema: type: string enum: - createdAt - updatedAt - name - relevance description: Sort order. Defaults to `updatedAt`. The `cursor` token is bound to the selected sort order. responses: '200': description: Surveys retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/SurveyListItem' meta: $ref: '#/components/schemas/SurveyListMeta' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] post: operationId: createSurveyV3 summary: Create a survey description: | Creates a block-based survey template from one strict survey document. The endpoint accepts multilingual authoring maps keyed by real locale codes and converts them to Formbricks' internal default-language representation. Non-default locale keys in translated content must be declared in `languages`; undeclared locale keys return `unsupported_locale` in `invalid_params` instead of silently mutating workspace languages. `blocks[].id` and `variables[].id` are stable public identifiers. They may be omitted on create, in which case the server generates cuid2 ids. If the same create request needs to reference a block or variable from logic, provide explicit valid ids and use those references consistently. For normal sequential surveys, omit `logic` and `logicFallback`. `logicFallback` is only valid when the same block has at least one `logic` rule; otherwise the API returns `invalid_reference`. `type` may be omitted or set to `link` or `app`. App surveys additionally accept the `distribution` (display options + triggers) and `targeting` (contact segment filters) objects; both are rejected for link surveys. Trigger ids must reference existing workspace action classes (discover them via `GET /api/v3/action-classes`), and `targeting.filters` references (contact-attribute keys and segments) must exist in the workspace. Unsupported fields are rejected instead of ignored. tags: - V3 Surveys requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateSurveyRequest' examples: productFeedback: summary: Create a multilingual block-based survey value: workspaceId: clxx1234567890123456789012 name: Product Feedback Survey status: draft defaultLanguage: en-US metadata: cx_operation: enterprise_onboarding title: en-US: Product Feedback de-DE: Produktfeedback languages: - code: de-DE enabled: true showLanguageSwitch: true autoSelectLanguage: true welcomeCard: enabled: true headline: en-US: Welcome de-DE: Willkommen blocks: - name: Main Block elements: - id: satisfaction type: openText headline: en-US: What should we improve? de-DE: Was sollen wir verbessern? required: true endings: [] hiddenFields: enabled: false variables: [] appSurvey: summary: Create an in-app (app) survey with distribution and triggers description: | App surveys are shown inside your web or mobile app. `distribution` controls display behavior and the action classes that trigger the survey (by id — discover ids via `GET /api/v3/action-classes`). `targeting.filters` scopes the audience; an empty array targets everyone. `type` is immutable after creation. value: workspaceId: clxx1234567890123456789012 name: In-App Onboarding Feedback type: app status: draft defaultLanguage: en-US blocks: - name: Onboarding elements: - id: onboarding_feedback type: openText headline: en-US: How was your setup experience? required: false endings: [] hiddenFields: enabled: false variables: [] distribution: displayOption: respondMultiple recontactDays: 7 delay: 5 triggers: - actionClassId: clyy1234567890123456789012 targeting: filters: - id: clf0aaaaaaaaaaaaaaaaaaaa01 connector: null resource: id: clf1aaaaaaaaaaaaaaaaaaaa01 root: type: attribute contactAttributeKey: plan qualifier: operator: equals value: pro - id: clf2aaaaaaaaaaaaaaaaaaaa01 connector: and resource: - id: clf3aaaaaaaaaaaaaaaaaaaa01 connector: null resource: id: clf4aaaaaaaaaaaaaaaaaaaa01 root: type: attribute contactAttributeKey: role qualifier: operator: equals value: admin - id: clf5aaaaaaaaaaaaaaaaaaaa01 connector: or resource: id: clf6aaaaaaaaaaaaaaaaaaaa01 root: type: attribute contactAttributeKey: role qualifier: operator: equals value: owner sequentialProductSurvey: summary: Create a richer sequential survey without logicFallback description: | Multi-block survey with generated block and variable ids. Sequential surveys should omit `logic` and `logicFallback`; blocks are shown in array order. value: workspaceId: clxx1234567890123456789012 name: Sequential Product Survey defaultLanguage: en-US languages: - code: de-DE enabled: true metadata: cx_operation: product_feedback title: en-US: Sequential Product Survey de-DE: Sequenzielle Produktumfrage welcomeCard: enabled: true headline: en-US: Help us improve the product de-DE: Hilf uns, das Produkt zu verbessern hiddenFields: enabled: true fieldIds: - account_id - plan variables: - name: feedback_score type: number value: 0 blocks: - name: Satisfaction elements: - id: overall_rating type: rating headline: en-US: How satisfied are you with the product? de-DE: Wie zufrieden bist du mit dem Produkt? required: true range: 5 scale: smiley - id: product_area type: multipleChoiceSingle headline: en-US: Which area should we improve first? de-DE: Welchen Bereich sollten wir zuerst verbessern? required: true choices: - id: onboarding label: en-US: Onboarding de-DE: Onboarding - id: analytics label: en-US: Analytics de-DE: Analysen - name: Feedback elements: - id: free_text_feedback type: openText headline: en-US: What is one thing we should change? de-DE: Was sollten wir ändern? required: false longAnswer: true endings: - id: cmpsequentialend0000000000 type: endScreen headline: en-US: Thanks for your feedback! de-DE: Danke für dein Feedback! responses: '201': description: Survey created successfully headers: Location: schema: type: string description: Relative URL of the created survey resource. example: /api/v3/surveys/clsv1234567890123456789012 X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/SurveyResource' warnings: $ref: '#/components/schemas/CustomCssWarnings' '400': description: | Bad Request — the document failed schema validation, i.e. any rule checkable from the request body alone, without consulting stored state. Covers: invalid JSON; unknown or unsupported fields (including `distribution`/`targeting` sent on a `link` survey, which are `app`-only); missing required fields; wrong types or out-of-range values; bad enum values; malformed multilingual maps; and intra-document field-combination rules — notably `displayLimit` is required (must be >= 1) when `displayOption` is `displaySome`. Cross-reference failures that need stored state to detect return **422** instead. Every offending field is itemized in `invalid_params`. Unknown or forbidden fields carry `code: unsupported_field` (e.g. `distribution` on a link survey); omissions carry `code: missing_required_field` (e.g. `displayLimit` when `displayOption` is `displaySome`). Bare type, range, enum, and malformed-locale-map violations report the field `name` with a human-readable `reason` and no machine `code`. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': description: 'Forbidden — no write access, missing external URL permission, missing Contacts entitlement for app-survey targeting, or workspace does not exist (404 not used; avoids existence leak). Survey custom CSS on an organization without the Scale plan (Formbricks Cloud) is refused with `code: custom_css_plan_required`.' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '422': description: |- Unprocessable Content — the document passed schema validation but failed a cross-reference check that requires stored state to detect: duplicate stable ids, dangling logic/jump references, undeclared locale keys used in content, invalid media URLs, a `distribution.triggers[].actionClassId` referencing an action class that does not exist in the workspace (discover valid ids via `GET /api/v3/action-classes`), or a `targeting.filters` entry that cannot resolve in the workspace — an unknown contact-attribute key or segment, an unsupported person identifier, or an unknown device value. Also returned when the document passes this endpoint's request schema but is rejected by the stricter validation the survey service applies on write — for example a CTA `buttonUrl` whose scheme the service does not accept. The `invalid_params` array pinpoints each issue (e.g. `code: invalid_reference` with the offending `identifier`). Also returned when `customCss` cannot be processed (a syntax error, the 20 000-byte survey budget, a complexity limit): `invalid_params` names `customCss.light` / `customCss.dark` and `details.errors` carries the processor's located errors. Nothing is created. Also returned with `code: workspace_survey_limit_reached` when the workspace already holds the maximum number of surveys. That problem carries no `invalid_params`; instead `details` is `{ count, limit }` — the workspace's current survey count and the limit it has reached — so a client can tell the user how many surveys to archive or delete before retrying. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: workspaceSurveyLimitReached: summary: The workspace has reached its survey limit value: title: Unprocessable Content status: 422 detail: This workspace has reached its survey limit code: workspace_survey_limit_reached requestId: req_01j9zq7w3k2m4n5p6r7s8t9v0w details: count: 10000 limit: 10000 '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/surveys/generate: post: operationId: generateSurveyV3 summary: Create a survey draft payload with AI description: | Creates a strict v3 `POST /api/v3/surveys` create payload from a creator prompt and returns validation metadata. This endpoint is single-purpose: it does **not** persist the survey. Clients should validate the returned payload with `/api/v3/surveys/validate` or use the embedded validation metadata, then create the draft through `POST /api/v3/surveys`. The generated payload always sets `status: draft`. Both `type: link` and `type: app` are supported; for `app`, the payload includes a default `distribution` (display once, no triggers, no targeting) that you finish configuring in the editor or via the create request. Prompt privacy: prompt text is sent to the configured AI provider to create the payload, but this endpoint does not store prompts and server logs should use request ids and failure categories instead of raw prompt content. tags: - V3 Surveys requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/GenerateSurveyRequest' examples: onboarding: summary: Create a link survey draft value: workspaceId: clxx1234567890123456789012 type: link language: en-US prompt: Understand why new users stop during onboarding and what would help them finish setup. requiredLanguage: summary: Create a Korean survey draft from an English prompt value: workspaceId: clxx1234567890123456789012 type: link surveyLanguage: ko-KR prompt: Ask customers in Seoul how satisfied they are with delivery times and what we should improve. responses: '200': description: Survey create payload generated successfully. The survey has not been created yet. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/GenerateSurveyResult' examples: generated: summary: Generated draft create payload value: data: language: en-US payload: workspaceId: clxx1234567890123456789012 type: link name: Onboarding Completion Survey status: draft defaultLanguage: en-US languages: - code: en-US default: true enabled: true metadata: title: en-US: Onboarding Completion Survey welcomeCard: enabled: false blocks: - id: clbk1234567890123456789012 name: Main elements: - id: q_1_example type: openText headline: en-US: What made onboarding hard to complete? required: false isDraft: true inputType: text longAnswer: false charLimit: enabled: false endings: - id: clen1234567890123456789012 type: endScreen headline: en-US: Thanks for your feedback hiddenFields: enabled: false variables: [] validation: valid: true invalid_params: [] languages: - code: en-US default: true enabled: true '400': description: Malformed body or a prompt that is too short or underspecified content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: shortPrompt: summary: Prompt needs more detail value: title: Bad Request status: 400 detail: Prompt needs more detail code: bad_request requestId: req_123 invalid_params: - name: prompt reason: Describe the survey goal, audience, or topic in a sentence so the AI can create a useful draft. '401': $ref: '#/components/responses/V3Unauthorized' '403': description: Forbidden — no write access, workspace does not exist, AI is not in plan, or AI smart tools are disabled content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: noWorkspaceWriteAccess: summary: No workspace write access value: title: Forbidden status: 403 detail: You are not authorized to access this resource code: forbidden requestId: req_123 aiDisabled: summary: AI smart tools disabled value: title: Forbidden status: 403 detail: AI smart tools are disabled for this organization. code: ai_smart_tools_disabled requestId: req_123 aiNotInPlan: summary: AI features not enabled for the plan value: title: Forbidden status: 403 detail: AI smart tools are not available for this organization. code: ai_features_not_enabled requestId: req_123 '422': description: AI generated an invalid payload after schema and v3 create validation, or the generation stopped because the output token limit was reached content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: invalidGeneratedPayload: summary: Invalid generated payload value: title: Unprocessable Content status: 422 detail: 'Generated survey payload is invalid: generatedSurvey.blocks: Too small' code: ai_generated_payload_invalid requestId: req_123 invalid_params: - name: generatedSurvey.blocks reason: Too small aiOutputTooLong: summary: Output token limit reached value: title: Unprocessable Content status: 422 detail: The generated survey exceeded the AI output token limit. Simplify the prompt or split it into smaller surveys. code: ai_output_too_long requestId: req_123 '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' '502': description: The configured AI provider failed or could not produce a valid draft content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: providerFailed: summary: AI provider failed value: title: Bad Gateway status: 502 detail: The AI provider could not generate a valid survey draft. Try again or add more detail. code: bad_gateway requestId: req_123 providerAuthFailed: summary: AI provider rejected the instance's credentials value: title: Bad Gateway status: 502 detail: The AI provider rejected this instance's credentials. Ask your administrator to check the AI provider configuration. code: ai_provider_auth_failed requestId: req_123 '503': description: AI is not configured on this Formbricks instance content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: instanceNotConfigured: summary: AI instance configuration missing value: title: Service Unavailable status: 503 detail: AI is not configured for this Formbricks instance. code: ai_instance_not_configured requestId: req_123 security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/surveys/validate: post: operationId: validateSurveyV3 summary: Validate a survey document description: | Validates a future create or patch survey payload without writing data, creating languages, or creating survey versions. Invalid survey documents return `200` with `data.valid=false` so agents and MCP clients can repair payloads from structured `invalid_params`. Use `400` only for malformed validation envelopes, unsupported operations, invalid `surveyId`, or unsupported query parameters. **Custom CSS.** A create or patch payload that carries `customCss` is also run through the custom CSS processor; the result adds the compiled `customCss` and `warnings`, or `errors` and `valid=false`. `operation: customCss` validates CSS alone for a live preview — with `scope: workspace | survey` and, optionally, an existing `surveyId` in `workspaceId`, or a `creativeType` for a creative's own CSS — and returns the compiled CSS and warnings. Validation never saves, versions, touches timestamps or invalidates caches, needs only read access, and grants nothing: the write itself checks permissions and the plan again. Requests that process custom CSS also count against a separate budget of 60 per minute per user or API key, shared with the workspace custom CSS endpoint, with survey creates and updates that add or edit custom CSS, and with the MCP tools; beyond it they return **429**. tags: - V3 Surveys requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ValidateSurveyRequest' examples: createValidation: summary: Validate a create payload value: operation: create data: workspaceId: clxx1234567890123456789012 name: Product Feedback Survey defaultLanguage: en-US blocks: - id: clbk1234567890123456789012 name: Main Block elements: - id: satisfaction type: openText headline: en-US: What should we improve? required: true customCssValidation: summary: Validate custom CSS for a live preview value: operation: customCss workspaceId: clxx1234567890123456789012 scope: survey surveyId: clsv1234567890123456789012 data: customCss: light: '[data-fb-part="headline"] { font-weight: 600; }' dark: null patchValidation: summary: Validate a patch payload value: operation: patch surveyId: clsv1234567890123456789012 data: blocks: - id: clbk1234567890123456789012 name: Main Block elements: - id: satisfaction type: openText headline: en-US: What should we improve? required: true responses: '200': description: Validation completed. The survey document may still be invalid. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: anyOf: - $ref: '#/components/schemas/SurveyValidationResult' - $ref: '#/components/schemas/CustomCssValidationResult' examples: invalid: summary: Invalid survey document value: data: valid: false operation: create invalid_params: - name: blocks.0.elements.0.buttonUrl reason: Unsupported field 'buttonUrl' for element type 'openText'. Allowed fields... customCssValid: summary: Custom CSS processed, with a removed construct value: data: valid: true operation: customCss invalid_params: [] customCss: light: '@layer fb-survey { #fbjs [data-fb-part="headline"] { font-weight: 600 !important; } }' dark: null warnings: - code: import_removed scope: survey appearance: light line: 1 column: 1 reason: '@import is not supported and was removed' customCssInvalid: summary: Custom CSS rejected value: data: valid: false operation: customCss invalid_params: - name: customCss.light reason: Unexpected end of input (line 3, column 1) errors: - code: syntax_error scope: survey appearance: light line: 3 column: 1 reason: Unexpected end of input valid: summary: Valid survey document value: data: valid: true operation: create invalid_params: [] languages: - code: en-US default: true enabled: true writeBehavior: connect_or_create '400': description: Malformed validation envelope, invalid route/query data, or unsupported operation content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/surveys/{surveyId}: get: operationId: getSurveyV3 summary: Retrieve a survey description: | Returns the public v3 survey management resource for one survey. By default, translatable fields are returned as stable multilingual maps keyed by the language codes emitted in `languages[].code`. Use `lang` to filter those maps to one or more requested language selectors. tags: - V3 Surveys parameters: - in: path name: surveyId required: true schema: type: string format: cuid2 description: Survey identifier. - in: query name: lang required: false style: form explode: false schema: type: array items: type: string examples: - - de-DE - - de-DE - pt-PT - - de - - zh-Hans - - zh-Hans-CN description: | Comma-separated language selector filter for translatable fields, for example `?lang=de-DE,pt-PT`. The response shape stays stable: translatable fields are always maps, never strings, and response keys match the emitted `languages[].code` values for this survey. For compatibility with existing Formbricks surveys, GET accepts language tags such as `de`, `de-DE`, `zh-Hans`, and `zh-Hans-CN`, accepts `_` or `-` separators, is case-insensitive, and accepts configured workspace aliases such as `english`. Bare language selectors are resolved against the survey's configured languages and return `400` if ambiguous, for example if both `en-US` and `en-GB` are configured. Disabled-but-configured languages are readable in the management API so unfinished translations can be completed. responses: '200': description: Survey retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/SurveyResource' examples: multilingual: summary: Multilingual authoring resource value: data: id: clseedsurveycsat000000 workspaceId: clseedworkspace000000000 createdAt: '2026-05-18T09:24:54.014Z' updatedAt: '2026-05-18T09:24:54.014Z' archivedAt: null visibility: workspace owner: name: Alice Example access: via: workspace canManageVisibility: false name: CSAT Survey type: link status: inProgress metadata: {} defaultLanguage: en-US languages: - code: en-US default: true enabled: true - code: de-DE alias: german default: false enabled: false showLanguageSwitch: false autoSelectLanguage: null welcomeCard: enabled: false blocks: - id: e0tfwzqk63op37y14z95qq3k name: Main Block elements: - id: nzte4cm8836hgjw63pesziht type: rating range: 5 scale: smiley headline: en-US: How satisfied are you with our product? de-DE: Wie zufrieden sind Sie mit unserem Produkt? required: true endings: [] hiddenFields: enabled: false variables: [] customCss: null filtered: summary: Language-filtered projection with ?lang=de-DE value: data: id: clseedsurveycsat000000 workspaceId: clseedworkspace000000000 createdAt: '2026-05-18T09:24:54.014Z' updatedAt: '2026-05-18T09:24:54.014Z' archivedAt: null visibility: workspace owner: name: Alice Example access: via: workspace canManageVisibility: false name: CSAT Survey type: link status: inProgress metadata: {} defaultLanguage: en-US languages: - code: en-US default: true enabled: true - code: de-DE alias: german default: false enabled: false showLanguageSwitch: false autoSelectLanguage: null welcomeCard: enabled: false blocks: - id: e0tfwzqk63op37y14z95qq3k name: Main Block elements: - id: nzte4cm8836hgjw63pesziht type: rating range: 5 scale: smiley headline: de-DE: Wie zufrieden sind Sie mit unserem Produkt? required: true endings: [] hiddenFields: enabled: false variables: [] customCss: null '400': description: Invalid survey id, unsupported query parameter, unknown language, or unsupported legacy survey shape content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] patch: operationId: patchSurveyV3 summary: Update a survey description: | Updates a survey through a strict top-level partial document. Omitted top-level fields are preserved. Provided top-level objects and arrays replace that whole subtree; this endpoint does not deep-merge nested objects and does not implement JSON Patch. This write endpoint accepts the canonical multilingual authoring document only. Translatable fields must be locale-code maps, not strings. `PATCH ?lang=...` and `?version=...` are not supported in Scope 1. For MCP and AI-agent workflows, fetch the current survey first, modify only the intended top-level fields, call `/api/v3/surveys/validate` with `operation: patch` for preflight, then submit the same patch here. Missing ids in replaced arrays are treated as deletions. Variable name renames are allowed but may be destructive for downstream exports or integrations. Element id changes are rejected once the survey and element are no longer drafts. Server-owned fields that `GET` returns (`id`, `workspaceId`, `type`, `createdAt`, `archivedAt`, `defaultLanguage`, `languages[].alias`) are accepted and verified, so the output of a `GET` can be sent straight back. Echoing the value you were given is a no-op; changing one is a **422** `read_only_field`. A round-tripped `updatedAt` acts as an optimistic-concurrency precondition: the write is applied only if the survey has not changed since your read, and is otherwise rejected with **409**. Omit it and overlapping writes remain last-write-wins. Header-based preconditions (`ETag` / `If-Match`) are still future work. tags: - V3 Surveys parameters: - in: path name: surveyId required: true schema: type: string format: cuid2 description: Survey identifier. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PatchSurveyRequest' examples: renameSurvey: summary: Rename a survey value: name: Updated Product Feedback replaceMetadata: summary: Replace metadata value: metadata: title: en-US: Product Feedback de-DE: Produktfeedback cx_operation: product_feedback setCustomCss: summary: Replace the survey's custom CSS value: customCss: light: '[data-fb-part="button-primary"] { border-radius: 999px; }' dark: '#fbjs[data-appearance="dark"] { --acme-ink: #e6eef5; }' clearCustomCss: summary: Clear the survey's custom CSS value: customCss: null replaceBlocks: summary: Replace survey blocks value: blocks: - id: clbk1234567890123456789012 name: Main Block elements: - id: satisfaction type: rating headline: en-US: How satisfied are you? de-DE: Wie zufrieden sind Sie? required: true range: 5 scale: smiley responses: '200': description: Survey updated successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/SurveyResource' warnings: $ref: '#/components/schemas/CustomCssWarnings' '400': description: | Bad Request — the patch failed schema validation, i.e. any rule checkable from the request body alone, without consulting stored state. Covers: malformed JSON; an unsupported query parameter; unknown or unsupported fields (`distribution`/`targeting` are `app`-only — sending them when patching a `link` survey is rejected); wrong types or out-of-range values; bad enum values; malformed multilingual maps; and intra-document field-combination rules — notably `displayLimit` is required (must be >= 1) when `displayOption` is `displaySome`. Cross-reference failures that need stored state to detect return **422** instead. Every offending field is itemized in `invalid_params`. Unknown, forbidden, immutable, or wrongly-combined fields carry `code: unsupported_field` (e.g. `distribution`/`targeting` on a link survey; a changed server-owned field such as `type` is **422** `read_only_field`, not this); omissions carry `code: missing_required_field`. Bare type, range, enum, and malformed-locale-map violations report the field `name` with a human-readable `reason` and no machine `code`. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': description: 'Forbidden — no write access, missing external URL permission, missing Contacts entitlement for app-survey targeting, or survey does not exist (404 not used; avoids existence leak). Adding or editing `customCss` on an organization without the Scale plan (Formbricks Cloud) is refused with `code: custom_css_plan_required`; clearing it, or a patch that leaves it unchanged, is not.' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '409': description: Conflict — a round-tripped `updatedAt` did not match the stored survey, so it changed since your read. Nothing was written. `details` carries `expectedUpdatedAt` and the live `currentUpdatedAt`, so a client can re-read and retry without guessing. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: title: Conflict status: 409 detail: Survey was modified since it was last read; updatedAt is now 2026-04-21T11:30:00.000Z. Re-read the survey and retry. code: conflict requestId: 6f1f7f3e-3b0e-4a1a-9f0a-7c9a1d2b3c4d details: expectedUpdatedAt: '2026-04-21T10:00:00.000Z' currentUpdatedAt: '2026-04-21T11:30:00.000Z' '422': description: |- Unprocessable Content — the request cannot be processed against stored state. Five cases: 1. The target survey is archived (soft-deleted) and therefore read-only; restore it before editing. The `invalid_params` entry reports `name: archivedAt` with the reason `This survey is archived. Restore it before editing.` 2. The patched document passed schema validation but failed a cross-reference check that requires stored state to detect: an undeclared locale key used in content, a duplicate stable id, an immutable element-id change on a published survey, a dangling logic/jump reference, an invalid media URL, a `distribution.triggers[].actionClassId` referencing an action class that does not exist in the workspace (discover valid ids via `GET /api/v3/action-classes`), or a `targeting.filters` entry that cannot resolve in the workspace — an unknown contact-attribute key or segment, an unsupported person identifier, or an unknown device value. An app survey set to any status but `draft` needs a trigger of its own or a non-archived creative that opens it; with neither it is refused at `name: triggers`. The `invalid_params` array pinpoints each issue (e.g. `code: invalid_reference` with the offending `identifier`). A patch that newly *introduces* a forward reference — a recall of an answer not yet given, a logic condition reading a later block, or a `requireAnswer` target that is not in a later block — is refused with `code: misordered_reference`; a survey that already contains one stays patchable, only newly created violations are rejected. A patch that newly *introduces* a loop in the conditional logic — jumps, fallbacks and next blocks leading back on themselves, so a respondent could never reach an ending — is refused the same way, reported at the `logic` of each block a new looping step leaves ("Cyclic logic detected in block N"). A survey that already loops stays patchable, and blocks that only lead into a loop are fine. 3. A server-owned field was echoed back with a different value — `code: read_only_field`, with the submitted value in `identifier`. Sending the value `GET` returned is always a no-op. 4. The *stored* survey cannot be read as a v3 document at all — `code: stored_survey_invalid`. The reported paths are into the survey, not into the request; repair it in the editor. A stored survey that reads fine but already fails validation (a missing translation, a dangling recall) stays patchable: only issues a patch newly introduces are refused. The exception is publishing: moving a `draft` out of `draft` still requires a survey that passes, and is refused as `stored_survey_invalid` while it does not. 5. `customCss` could not be processed (a syntax error, the 20 000-byte survey budget, a complexity limit). `invalid_params` names `customCss.light` / `customCss.dark`; `details.errors` carries the processor's located errors. Nothing is written, so the saved CSS stays in effect. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: archived: summary: Survey is archived (restore before editing) value: type: about:blank title: Unprocessable Content status: 422 detail: Survey is archived code: unprocessable_content requestId: req_clsv1234567890123456789012 invalid_params: - name: archivedAt reason: This survey is archived. Restore it before editing. invalidReference: summary: Cross-reference validation failed value: type: about:blank title: Unprocessable Content status: 422 detail: Survey document failed validation code: unprocessable_content requestId: req_clsv1234567890123456789012 invalid_params: - name: blocks[0].logic[0].target reason: References a block that does not exist in the survey. code: invalid_reference identifier: e0tfwzqk63op37y14z95qq3k '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] delete: operationId: deleteSurveyV3 summary: Delete a survey description: | Deletes a survey by id. Session cookie, `x-api-key`, or a Bearer token. Refused with **409** (`survey_has_linked_creatives`) while non-archived creatives open the survey; `details.creatives` names them. Archived creatives don't block the delete: they are unlinked with it, and each unlink is audited as an update of that creative. tags: - V3 Surveys parameters: - in: path name: surveyId required: true schema: type: string format: cuid2 description: Survey identifier. responses: '204': description: Survey deleted successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '409': $ref: '#/components/responses/V3SurveyHasLinkedCreatives' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/surveys/{surveyId}/blocks: patch: operationId: editSurveyBlocksV3 summary: Edit survey blocks description: | Edits a survey's blocks in place — update, insert or remove whole blocks without resending the ones you are not changing. Operations apply in order against a single snapshot and are applied atomically: either all of them land or none do. Blocks are addressed by id, never by position, so a concurrent edit cannot silently redirect an operation onto a different block. An `update` replaces the target block entirely, the same whole-subtree rule `PATCH /api/v3/surveys/{surveyId}` uses, and the replacement must carry every language the survey has configured. Pass `expectedUpdatedAt` (the `updatedAt` from your last read) to make the write conditional; see the **409** response. Session cookie or x-api-key. tags: - V3 Surveys parameters: - in: path name: surveyId required: true schema: type: string format: cuid2 description: Survey identifier. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EditSurveyBlocksRequest' examples: updateOneBlock: summary: Replace a single block value: ops: - op: update id: k1p9wq2m4x7c3v8b5n6t0j2r block: id: k1p9wq2m4x7c3v8b5n6t0j2r name: Satisfaction elements: - id: satisfaction type: openText headline: en-US: How satisfied were you? required: true inputType: text expectedUpdatedAt: '2026-04-21T10:00:00.000Z' insertAndRemove: summary: Add a block after another and drop a third value: ops: - op: insert position: type: after blockId: k1p9wq2m4x7c3v8b5n6t0j2r block: id: b4h8s2d6f0g3j5k7l9z1x3c5 name: Follow-up elements: - id: followup type: openText headline: en-US: Anything else? required: false inputType: text - op: remove id: n7m4q8w2e6r0t3y5u1i9o2p4 responses: '200': description: Blocks updated; returns the full survey resource. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/SurveyResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '409': description: | The survey changed since the `expectedUpdatedAt` you supplied. Nothing was written. The problem's `details` carries `expectedUpdatedAt` and the live `currentUpdatedAt`, so a client can re-read and retry without guessing. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: title: Conflict status: 409 detail: Survey was modified since it was last read; updatedAt is now 2026-04-21T11:30:00.000Z. Re-read the survey and retry. code: conflict requestId: 6f1f7f3e-3b0e-4a1a-9f0a-7c9a1d2b3c4d details: expectedUpdatedAt: '2026-04-21T10:00:00.000Z' currentUpdatedAt: '2026-04-21T11:30:00.000Z' '422': description: | The request is well-formed but could not be applied. `invalid_params[].name` points at the operation that failed — `ops..…` — including for problems found while validating the resulting document. The one exception is a block the request did not touch: those are reported at `blocks.`, where `` is the block's index in the stored survey — the same index `GET` returns, even when an earlier operation inserted or removed blocks before it — because the problem is in the stored survey rather than in any operation. A `stored_survey_invalid` response uses stored-survey paths throughout for the same reason. Common codes: `dangling_reference` (an id that is not on the survey), `duplicate_identifier` (inserting an id that already exists), `missing_required_field` (an inserted block whose `id` is present but empty — omitting `id` entirely is fine, the server generates one), `immutable_identifier` (`block.id` contradicting the op's `id`), and `stored_survey_invalid` when the stored survey itself fails the document contract. The edit is also refused when it newly *introduces* a loop in the conditional logic — jumps, fallbacks and next blocks leading back on themselves, so a respondent could never reach an ending — reported at the `logic` of each block a new looping step leaves. A survey that already loops stays editable. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: unknownBlock: summary: An operation names a block that is not on the survey value: title: Unprocessable Content status: 422 detail: Block operations could not be applied code: unprocessable_content requestId: 6f1f7f3e-3b0e-4a1a-9f0a-7c9a1d2b3c4d invalid_params: - name: ops.1.id reason: Block 'n7m4q8w2e6r0t3y5u1i9o2p4' does not exist on this survey code: dangling_reference identifier: n7m4q8w2e6r0t3y5u1i9o2p4 referenceType: block missingId: n7m4q8w2e6r0t3y5u1i9o2p4 storedSurveyInvalid: summary: The stored survey fails the document contract value: title: Unprocessable Content status: 422 detail: The stored survey does not satisfy the v3 survey document contract. The reported paths are into the stored survey, not your request; a patch that includes corrected values for those fields is accepted, or repair them in the editor. code: stored_survey_invalid requestId: 6f1f7f3e-3b0e-4a1a-9f0a-7c9a1d2b3c4d invalid_params: - name: blocks.0.elements.0.headline reason: Translatable field is missing configured language 'de-DE' code: missing_translation '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/surveys/{surveyId}/blocks/order: put: operationId: setSurveyBlockOrderV3 summary: Set survey block order description: | Replaces the order of a survey's blocks. The body lists every current block id exactly once, in the order you want; block content is untouched. Idempotent: the same `order` always yields the same survey, and an order that already matches performs no write at all, so `updatedAt` does not move and other callers' preconditions stay valid. A stale `expectedUpdatedAt` is still rejected with **409** on that path — the precondition is evaluated before the method, so a no-op outcome never reports success to a stale caller. Because the list must be a permutation, a dropped or duplicated block is rejected rather than silently applied — the failure mode that resending the whole `blocks` array cannot catch. Pass `expectedUpdatedAt` (the `updatedAt` from your last read) to make the write conditional; see the **409** response. Session cookie or x-api-key. tags: - V3 Surveys parameters: - in: path name: surveyId required: true schema: type: string format: cuid2 description: Survey identifier. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SetSurveyBlockOrderRequest' examples: reorder: summary: Move the third block to the front value: order: - b4h8s2d6f0g3j5k7l9z1x3c5 - k1p9wq2m4x7c3v8b5n6t0j2r - n7m4q8w2e6r0t3y5u1i9o2p4 expectedUpdatedAt: '2026-04-21T10:00:00.000Z' responses: '200': description: Order applied (or already matching); returns the full survey resource. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/SurveyResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '409': description: | The survey changed since the `expectedUpdatedAt` you supplied. Nothing was written; `details` carries `expectedUpdatedAt` and the live `currentUpdatedAt`. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: title: Conflict status: 409 detail: Survey was modified since it was last read; updatedAt is now 2026-04-21T11:30:00.000Z. Re-read the survey and retry. code: conflict requestId: 6f1f7f3e-3b0e-4a1a-9f0a-7c9a1d2b3c4d details: expectedUpdatedAt: '2026-04-21T10:00:00.000Z' currentUpdatedAt: '2026-04-21T11:30:00.000Z' '422': description: | The request is well-formed but could not be applied. Three distinct cases share this status; switch on the Problem-level `code`. `unprocessable_content` — the order is not a permutation of the survey's blocks. Unknown ids (`dangling_reference`), repeated ids (`duplicate_identifier`) and omitted blocks (`missing_required_field`, reported at `order`) are returned together, capped at 50 with a final entry saying how many were left out. Also used when the new order would *introduce* a forward reference — a recall of an answer not yet given, or a logic condition reading a later block — reported as `misordered_reference` at the stored-document path. An order that already contains such a reference stays reorderable; only newly created ones are refused. Likewise when the new order would *introduce* a loop in the conditional logic — a jump that now points back at a block whose next step leads to it again, so a respondent could never reach an ending — reported at the `logic` of each block a new looping step leaves. Moving a block in front of an existing loop is fine; reordering blocks inside one changes the loop and is refused. `stored_survey_invalid` — the stored survey cannot be reordered by id: it is a legacy question-based survey, exposes no v3 block list, or a block id occurs more than once. The request was not evaluated; `invalid_params[].name` points into the stored survey. `archivedAt` in `invalid_params` (no `code`) — the survey is archived; restore it first. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: title: Unprocessable Content status: 422 detail: Block order must list every block exactly once code: unprocessable_content requestId: 6f1f7f3e-3b0e-4a1a-9f0a-7c9a1d2b3c4d invalid_params: - name: order reason: Block 'n7m4q8w2e6r0t3y5u1i9o2p4' is missing from the order; list every block exactly once code: missing_required_field identifier: n7m4q8w2e6r0t3y5u1i9o2p4 referenceType: block missingId: n7m4q8w2e6r0t3y5u1i9o2p4 '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/surveys/{surveyId}/archive: post: operationId: archiveSurveyV3 summary: Archive a survey description: | Archives (soft-deletes) a survey by id. Archived surveys are hidden from the default list, stop collecting responses, and become read-only until restored — a subsequent `PATCH` returns **422**. Works on any survey, including legacy question-based surveys, so this endpoint returns a minimal lifecycle acknowledgement rather than the full survey resource. Session cookie, `x-api-key`, or a Bearer token. tags: - V3 Surveys parameters: - in: path name: surveyId required: true schema: type: string format: cuid2 description: Survey identifier. responses: '200': description: Survey archived successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: type: object required: - id - status - archivedAt properties: id: type: string description: Survey identifier. status: type: string enum: - draft - inProgress - paused - completed description: Current survey status. archivedAt: type: - string - 'null' format: date-time description: Soft-delete/archive marker (ISO 8601). A timestamp after archiving; `null` when the survey is active. examples: archived: summary: Survey archived value: data: id: clsv1234567890123456789012 status: paused archivedAt: '2026-05-18T09:24:54.014Z' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/surveys/{surveyId}/restore: post: operationId: restoreSurveyV3 summary: Restore a survey description: | Restores a previously archived (soft-deleted) survey by id, clearing its archive marker so it reappears in the default list and becomes editable again. Works on any survey, including legacy question-based surveys, so this endpoint returns a minimal lifecycle acknowledgement rather than the full survey resource. Session cookie, `x-api-key`, or a Bearer token. tags: - V3 Surveys parameters: - in: path name: surveyId required: true schema: type: string format: cuid2 description: Survey identifier. responses: '200': description: Survey restored successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: type: object required: - id - status - archivedAt properties: id: type: string description: Survey identifier. status: type: string enum: - draft - inProgress - paused - completed description: Current survey status. archivedAt: type: - string - 'null' format: date-time description: Soft-delete/archive marker (ISO 8601). `null` after restoring; a timestamp while the survey is archived. examples: restored: summary: Survey restored value: data: id: clsv1234567890123456789012 status: paused archivedAt: null '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/surveys/{surveyId}/visibility: get: operationId: getSurveyVisibilityV3 summary: Read a survey's visibility and the impact of changing it description: | Returns the survey's enforced visibility, its owner, the caller's access, and what a change would do: `blockers[]` lists the outbound connections that currently make `restricted` refusable (webhooks, integrations, active feedback sources, draft or enabled workflows, and non-archived creatives that open it), `impact` carries the numbers the confirmation dialogues show, `allowedTargets[]` the values a `POST` would act on right now, and `pending` / `version` describe a change still in flight. Read this before offering "Make restricted"; the `POST` answers **409** while `blockers[]` is non-empty. Requires `survey.change_visibility`, the same permission as the `POST`: the survey's owner while they still hold read access to the workspace, or an organisation owner/manager, with the feature enabled. Everyone else — read or write members, team-level managers, every API key, and anyone who cannot see the survey at all — answers **403** with the same body as an unknown id. Connection names and ids are configuration, not survey content, and only the people who may act on them get to read them. While `pending` is non-null the survey is restricted for everyone but its owner (within their workspace access: read to view, write to edit) and the organisation's owners and managers, whichever direction is queued — the application fails closed until the graph acknowledges the change — so `visibility` reads `restricted` in that state and `allowedTargets` includes the value that cancels the pending change. tags: - V3 Surveys parameters: - in: path name: surveyId required: true schema: type: string format: cuid2 description: Survey identifier. responses: '200': description: Visibility state retrieved headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/SurveyVisibilityState' examples: visibleWithBlockers: summary: Workspace-visible survey that a webhook and a workflow depend on value: data: id: clsv1234567890123456789012 visibility: workspace owner: name: Alice Example access: via: workspace canManageVisibility: true blockers: - type: webhook id: clwh1234567890123456789012 name: Zapier — new responses - type: workflow id: clwf1234567890123456789012 name: 'Notify #product on completion' impact: memberCount: 7 responseCount: 212 pending: null version: 2 allowedTargets: [] privateDraft: summary: Restricted draft seen by its owner value: data: id: clsv2234567890123456789012 visibility: restricted owner: name: Bob Example access: via: owner canManageVisibility: true blockers: [] impact: memberCount: 7 responseCount: 0 pending: null version: 2 allowedTargets: - workspace pendingGrant: summary: Owner asked for workspace; the in-request projection failed, the outbox will finish it value: data: id: clsv2234567890123456789012 visibility: restricted owner: name: Bob Example access: via: owner canManageVisibility: true blockers: [] impact: memberCount: 7 responseCount: 0 pending: workspace version: 3 allowedTargets: - workspace - restricted ownerlessPrivateSeenByManager: summary: Departed author's restricted survey, read by an organisation manager value: data: id: clsv3234567890123456789012 visibility: restricted owner: null access: via: organizationRole canManageVisibility: true blockers: [] impact: memberCount: 7 responseCount: 41 pending: null version: 2 allowedTargets: - workspace '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3VisibilityForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] post: operationId: changeSurveyVisibilityV3 summary: Change a survey's visibility description: | Sets the survey to `restricted` or `workspace`. **Restrictions take effect at once.** A `workspace → restricted` request stores the flag, a new `version` and a pending marker in one transaction. From that instant the survey is restricted on every path — single reads, lists and counts, responses, exports — for everyone but the owner and the organisation's owners and managers, whether or not the graph has caught up (fail closed). The owner keeps only the access their workspace role gives them — read to view, write to edit; an owner who loses workspace access loses the survey too. The relationships are then projected; the answer is **200** either way, with `pending: null` when the graph acknowledged this `version` inside the request and `pending: "restricted"` when the outbox will finish it. **Grants take effect when enforced.** A `restricted → workspace` request answers **200** only once the graph has acknowledged this exact `version`; until then the survey stays restricted. If the in-request projection fails, the answer is **503** `projection_pending`: the change is stored and queued, `GET …/visibility` shows `pending: "workspace"`, and retrying is safe. **Fencing.** Every accepted change increments the survey's `version`. Projection work carries the version it was issued for, and a projector holding an older version never writes, so a worker leased before a newer change cannot overtake it — including one that crashed before its verification pass. A **200** for a grant means the graph holds this exact version. **No-ops and cancellation.** Requesting the value the survey already enforces, with nothing pending, is a **200** no-op: nothing is written or audited and `changedAt` / `changedBy` are unchanged. Requesting the enforced value while the *opposite* is pending **cancels** the pending change: a new version is stored, the queued work is superseded, and the answer is **200** with `pending: null`. Requesting the pending value again re-attempts the projection (200 or 503). When `restricted` is refused for two reasons at once — no owner **and** outbound connections — the **422** wins: the missing owner cannot be fixed, so listing connections to remove would send the caller down a dead end. Allowed for the survey's owner (while they still hold read access to the workspace) and for the organisation's owners and managers. **Not** for workspace members with write or team-level manage rights, and never for API keys. Every successful change is audit-logged with the actor. Going `workspace → restricted` is refused with **409** while any outbound connection depends on the survey; resolve them first. Archived creatives don't block it: a restriction unlinks them (each unlink is audited as an update of that creative), so restoring one later needs a new Destination. It is refused with **422** when the survey has no owner, because "restricted" would then mean "administrators only" — duplicate the survey for a restricted copy. Going `restricted → workspace` has no preconditions. This is the only way to change visibility: `PATCH /api/v3/surveys/{surveyId}` accepts `visibility`, `owner` and `access` only as the exact values `GET` returned (so a fetched survey round-trips), and rejects any other value as an unsupported field. Publishing does not change visibility; the publish dialogue chains this call before the status change. tags: - V3 Surveys parameters: - in: path name: surveyId required: true schema: type: string format: cuid2 description: Survey identifier. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SurveyVisibilityChangeRequest' examples: makeVisible: summary: Share with the workspace value: visibility: workspace makePrivate: summary: Hide from the workspace value: visibility: restricted responses: '200': description: Visibility changed, or already at the requested value headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/SurveyVisibilityChangeResult' examples: madeVisibleByOwner: summary: Owner shares a draft with the workspace before publishing value: data: id: clsv2234567890123456789012 visibility: workspace owner: name: Bob Example access: via: workspace canManageVisibility: true version: 3 pending: null changedAt: '2026-09-21T10:15:00.000Z' changedBy: type: user id: clus2234567890123456789012 name: Bob Example madePrivateByManager: summary: An organisation manager hides a departed colleague's survey value: data: id: clsv1234567890123456789012 visibility: restricted owner: name: Alice Example access: via: organizationRole canManageVisibility: true version: 3 pending: null changedAt: '2026-09-21T10:20:00.000Z' changedBy: type: user id: clus3234567890123456789012 name: Carol Manager '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3VisibilityForbidden' '409': $ref: '#/components/responses/V3VisibilityBlocked' '422': $ref: '#/components/responses/V3VisibilityNotAllowed' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' '503': $ref: '#/components/responses/V3ProjectionPending' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/responses: get: operationId: getResponsesV3 summary: List responses description: | Returns responses for a workspace, newest first. Session cookie, `x-api-key`, or a Bearer token, with read access. **Scoping.** `workspaceId` is required, as on every v3 collection read. Add `surveyId` to narrow to one survey — the common case, and the one the storage layer is indexed for (`(surveyId, createdAt)`). A workspace-wide list has to reach responses through their surveys and is correspondingly slower, so pass `surveyId` whenever you know it. `contactId` narrows to one respondent. **Pagination is keyset**, on `(createdAt, id)`. The `cursor` is opaque and bound to the sort order and the filter set it was issued for; changing either invalidates it. `meta.nextCursor` being `null` is the only end-of-collection signal — do not infer the end from a short page. `id` is there purely as a tie-breaker, and it makes the order **total**, so no row is ever skipped or repeated across pages. What it is not is a finer-grained clock: `createdAt` is stored to millisecond precision, so responses sharing a millisecond — everything written in one bulk insert, for instance — come back in an order that is stable and repeatable but is not their true creation order. Do not read the sequence within a millisecond as meaningful. **Filters are allow-listed**, and each one on its own is either served by an index or bounded by one. `filter[finished][eq]` and `filter[language][in]` additionally require `surveyId` or `contactId`, because both are heap residuals that are only bounded when the scan is anchored to one survey or one contact; sending either without such a scope returns **400**. Be aware that **combining** a `filter[createdAt]` bound with `filter[finished][eq]` is the one case no single index covers: the ordered scan uses `(surveyId, createdAt)` and `finished` stays a heap residual, so a page whose matches are sparse reads far more rows than it returns. Prefer narrowing by `surveyId` and a tight `createdAt` window, and treat that combination as the slow path until a covering index exists. **What this endpoint deliberately does not filter on:** individual answers, hidden-field values, submission metadata (`source`, `url`, `country`, `browser`, `os`, `deviceType`), contact attributes, quotas, and tags. Each is a JSON path or an unindexed join on the largest table in the database, where the honest outcome is a timeout rather than a slow query. The in-app dashboard does offer these, so this endpoint is knowingly behind it; closing the gap needs index work and is tracked separately rather than quietly omitted. tags: - V3 Responses parameters: - $ref: '#/components/parameters/WorkspaceIdQuery' - $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/CursorQuery' - in: query name: surveyId schema: type: string format: cuid2 description: 'Optional survey scoping. Single-valued deliberately: an `[in]` form would defeat the ordered index scan that makes this the fast path.' - in: query name: contactId schema: type: string format: cuid2 description: Optional contact scoping. Returns only responses submitted by this contact. - in: query name: includeTotalCount schema: type: boolean default: false description: | Whether to compute `meta.totalCount`. Defaults to **`false`** here, unlike the survey list, because responses are the highest-cardinality table in the product and the extra count is not free at that size. When `false`, both `meta.totalCount` and `meta.totalCountRelation` are `null`. When `true`, the count is capped at 10 000 and `meta.totalCountRelation` says whether the figure is exact (`eq`) or a lower bound (`gte`). If all you need is the count, use `GET /api/v3/responses/count` rather than fetching a page to get it — and if you need an exact figure beyond the cap, that endpoint takes an explicit `precision=exact`. - $ref: '#/components/parameters/CreatedAtGteQuery' - $ref: '#/components/parameters/CreatedAtGtQuery' - $ref: '#/components/parameters/CreatedAtLteQuery' - $ref: '#/components/parameters/CreatedAtLtQuery' - in: query name: filter[finished][eq] schema: type: boolean description: Completed (`true`) or partial (`false`) responses only. Omit for both. Requires `surveyId` or `contactId`. - in: query name: filter[language][in] schema: type: array items: type: string style: form explode: true description: | Survey language codes to include. Repeat the parameter or use comma-separated values. Values are survey language codes rather than BCP-47 locales — `default` is legitimate. Requires `surveyId` or `contactId`. - in: query name: filter[id][in] schema: type: array maxItems: 100 items: type: string format: cuid2 style: form explode: true description: Fetch a specific set of responses by id, at most 100 per call. Ids outside the requested scope are simply absent from the result rather than erroring, so this cannot be used to probe whether an id exists elsewhere. - in: query name: sortBy schema: type: string enum: - '-createdAt' - createdAt default: '-createdAt' description: | Sort order. `-createdAt` (newest first) is the default; `createdAt` sorts oldest first, which is what a consumer doing an incremental backfill wants. The leading `-` marks descending. `updatedAt` is not offered: it has no index, no product usage, and being mutable it cannot anchor a keyset cursor. responses: '200': description: Responses retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/ResponseListItem' meta: $ref: '#/components/schemas/ResponseListMeta' examples: page: summary: One page of responses, exercising several answer types value: data: - id: clrs1234567890123456789012 surveyId: clsv1234567890123456789012 surveyName: Q3 Product Feedback workspaceId: clws1234567890123456789012 createdAt: '2026-08-27T09:14:03.417Z' updatedAt: '2026-08-27T09:17:41.902Z' finished: true endingId: cmpthankyou00000000000000 language: de durationSeconds: 107.9 resolution: labelPolicy: currentSurveyDefinition labelsLanguage: de surveyUpdatedAt: '2026-08-20T11:02:55.000Z' answers: - position: 1 elementId: nps_score elementType: nps elementLabel: Wie wahrscheinlich ist es, dass Sie uns weiterempfehlen? valueNumber: 9 range: min: 0 max: 10 durationSeconds: 12.1 - position: 2 elementId: what_could_be_better elementType: openText elementLabel: Was koennten wir besser machen? valueText: Die Suche ist zu langsam. inputType: text durationSeconds: 64.8 - position: 3 elementId: channels elementType: multipleChoiceMulti elementLabel: Wie haben Sie von uns erfahren? selections: - optionId: ch_2f8a optionLabel: Ein Freund rawValue: Ein Freund match: label - optionId: other optionLabel: Andere rawValue: Podcast match: other - optionId: null optionLabel: null rawValue: Twitter match: unmatched durationSeconds: 31 - position: 4 elementId: consent_terms elementType: consent elementLabel: Ich stimme den Bedingungen zu valueBoolean: true rawValue: accepted embeddedData: - key: plan kind: ingested type: string label: Plan value: enterprise - key: score kind: computed type: number label: Score value: 12 - key: country kind: reserved type: string label: Country value: DE unresolved: - key: old_question_id rawValue: some answer reason: elementNotInSurvey tags: - id: cltg1234567890123456789012 name: vip meta: limit: 20 totalCountRelation: null nextCursor: eyJ2ZXJzaW9uIjoxLCJraW5kIjoicmVzcG9uc2VzIiwic29ydEJ5IjoiLWNyZWF0ZWRBdCIsImZwIjoiZTNpT0pQNkN4V0p0TEZTOSIsInZhbHVlIjoiMjAyNi0wOC0yN1QwOToxNDowMy40MTdaIiwiaWQiOiJjbHJzMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMiJ9 totalCount: null '400': description: | Bad Request — invalid or unrecognized query parameters. Covers an unknown `filter[...]` key, a filter value of the wrong type, `filter[id][in]` over 100 ids, a `cursor` that does not match the requested sort order or filter set, and `filter[finished][eq]` or `filter[language][in]` sent without `surveyId` or `contactId`. Date bounds are also validated as a pair: at most **one lower bound** (`gte` xor `gt`) and at most **one upper bound** (`lte` xor `lt`), and the lower bound must not fall after the upper one. Sending both forms of the same bound is rejected rather than resolved by a precedence rule nobody can guess. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '413': description: Payload Too Large — the request body exceeds the 2 MB limit. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] post: operationId: createResponseV3 summary: Create a response description: | Records a survey response. Requires read-write access to the workspace, which is resolved from `surveyId` rather than supplied by the caller. **This performs a real submission.** It runs the full response pipeline — webhooks, integrations, follow-ups, quota evaluation, and Hub ingestion once the response is finished — and it consumes one metered monthly response. That is the same power a v1/v2 management create has today, and the same power an API key with write access already carries; it is called out because an agent holding that key can create genuine submissions. Use `POST /api/v3/responses/validate` to check a payload without any of that happening. `createdAt` cannot be supplied. Bulk historical loading is therefore not what this endpoint is for — see `ResponseListItem.createdAt` for why, and use v2 while a dedicated import path is outstanding. tags: - V3 Responses requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateResponseRequest' examples: finishedResponse: summary: A complete response with answers and Embedded Data value: surveyId: clsv1234567890123456789012 finished: true endingId: cmpthankyou00000000000000 language: de data: nps_score: 9 what_could_be_better: Die Suche ist zu langsam. channels: - Ein Freund - Podcast embeddedData: plan: enterprise partialResponse: summary: A partial response, still open value: surveyId: clsv1234567890123456789012 finished: false data: nps_score: 7 integrationResponse: summary: A response created by an integration, with timing, tags and source description: | `ttc` carries per-element timing in milliseconds and reads back as `durationSeconds` in seconds. `meta.source` is worth setting on any API-created response — nothing else can distinguish it from an organic submission. `tags` are applied in the same transaction, so no follow-up patch is needed. value: surveyId: clsv1234567890123456789012 finished: true language: de data: nps_score: 9 what_could_be_better: Die Suche ist zu langsam. ttc: nps_score: 12100 what_could_be_better: 64800 meta: source: zendesk-integration url: https://support.example.com/tickets/4821 tags: - cltg1234567890123456789012 identifiedResponse: summary: A response attributed to a known contact description: | `contactId` is create-only — a response cannot later be re-attributed to a different person. The contact must already exist in the same workspace. value: surveyId: clsv1234567890123456789012 finished: true contactId: clct1234567890123456789012 data: nps_score: 10 responses: '201': description: Response created successfully headers: Location: schema: type: string description: Relative URL of the created response. example: /api/v3/responses/clrs1234567890123456789012 X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ResponseResource' '400': description: | Bad Request — the body failed schema validation: invalid JSON, a missing required field, a value of the wrong type, or an unsupported field. Sending a server-owned or create-forbidden field (`createdAt`, `updatedAt`, `contactAttributes`, `userId`, or a `meta` member outside the accepted `source` / `url` / `action` — notably `meta.ipAddress` and the derived `userAgent` / `country` / `device` family) is rejected here with `code: unsupported_field` rather than being silently dropped. Failures that need stored state to detect return **422** instead. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '413': description: Payload Too Large — the request body exceeds the 2 MB limit. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '422': description: | Unprocessable Content — the body is well-formed but conflicts with stored state: `endingId` is not an ending of that survey, `language` is not enabled on it, `contactId` is not a contact in the same workspace, `displayId` does not exist for that survey or already backs another response, `singleUseId` is already used for this survey, a tag id does not resolve in the same workspace, an `embeddedData` name is not declared on the survey, is ambiguous, or is addressed twice by one payload, a file-upload answer references a file outside this survey's upload element, or an answer violates the survey's own validation rules. Each is itemized in `invalid_params`. **`surveyId` is not in that list, and answers 403 instead** — whether it names no survey at all or one in another workspace. Every other id here is checked *after* authorization, inside a workspace the caller already holds, so a distinct answer for each discloses nothing. `surveyId` is the scope anchor and is necessarily checked *before* authorization, so a separate "no such survey" would let any valid API key probe the id space of every other tenant. The two cases are therefore byte-identical, as they are on `GET`, `PATCH` and `DELETE` of a response id. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/responses/count: get: operationId: countResponsesV3 summary: Count responses description: | Returns how many responses match a scope and filter set, without fetching any of them. This exists as its own operation because "how many match this?" is the question asked most often and it should not require paging through data to answer — particularly for agent clients, where a page of payloads to obtain a single integer is pure waste. It takes the same scope and filter parameters as `GET /api/v3/responses`, minus pagination and sorting, so a count and a page are always answering the same question. The list endpoint's `meta.totalCount` covers the other case: a caller already paging that also wants the total. That one is opt-in; this one is the direct route. tags: - V3 Responses parameters: - $ref: '#/components/parameters/WorkspaceIdQuery' - in: query name: surveyId schema: type: string format: cuid2 description: Optional survey scoping. - in: query name: contactId schema: type: string format: cuid2 description: Optional contact scoping. - $ref: '#/components/parameters/CreatedAtGteQuery' - $ref: '#/components/parameters/CreatedAtGtQuery' - $ref: '#/components/parameters/CreatedAtLteQuery' - $ref: '#/components/parameters/CreatedAtLtQuery' - in: query name: filter[finished][eq] schema: type: boolean description: Completed (`true`) or partial (`false`) responses only. Requires `surveyId` or `contactId`, for the same reason as on the list endpoint. - in: query name: filter[language][in] schema: type: array items: type: string style: form explode: true description: Survey language codes to include. Requires `surveyId` or `contactId`. - in: query name: filter[id][in] schema: type: array maxItems: 100 items: type: string format: cuid2 style: form explode: true description: Count a specific set of responses by id, at most 100 per call. Ids outside the requested scope are simply not counted rather than erroring, so this cannot be used to probe whether an id exists elsewhere. - in: query name: precision schema: type: string enum: - capped - exact default: capped description: | How hard to count. `capped` (the default) stops at 10 000 and reports `relation: gte` at the cap — flat cost, safe to call on anything. `exact` counts every matching row and reports `relation: eq` always. `exact` is the deliberate slow path: cost grows superlinearly with the matching set, so on a large or loosely-filtered scope it is markedly more expensive than a page of data. Ask for it when a person needs a precise figure, not on a dashboard poll. It exists so that the capped default is a sensible default rather than a limitation, and so the cap cannot be sidestepped by accident. **`exact` requires `surveyId` or `contactId`**, and answers **400** naming `precision` without one — the same anchor rule `filter[finished][eq]` and `filter[language][in]` carry. `capped` stops at the cap whatever the scope, so its cost is flat; an unanchored `exact` is a full scan of every response in the workspace, and the contract refuses a query it cannot bound rather than serving it slowly. For a workspace-wide figure use the capped default, which reports `relation: gte` once it reaches the cap. responses: '200': description: Count retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: type: object required: - count - relation properties: count: type: integer minimum: 0 description: Responses matching the scope and filters. With `precision=capped` this stops at 10 000; read `relation` before presenting it as a total. relation: type: string enum: - eq - gte description: '`eq` when `count` is exact, `gte` when the 10 000 cap was reached and `count` is a lower bound. Always `eq` when `precision=exact`. Same convention as `meta.totalCountRelation` on the list endpoint.' additionalProperties: false '400': description: | Bad Request — invalid or unrecognized query parameters, including `filter[finished][eq]` or `filter[language][in]` without `surveyId` or `contactId`. Date bounds follow the same pairing rule as the list endpoint: at most one lower bound (`gte` xor `gt`), at most one upper bound (`lte` xor `lt`), and the lower bound must not fall after the upper one. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/responses/validate: post: operationId: validateResponseV3 summary: Validate a response payload description: | Checks a create or patch payload and reports what a real write would do — **without writing anything**. Never has side effects: no response is created, no pipeline fires, nothing is metered. Deliberately the same shape as `POST /api/v3/surveys/validate`: same dual auth, a request discriminated on `operation` carrying the body the real call would take, and a result of `valid`, `operation` and `invalid_params` with document-relative paths. The value is that the two endpoints behave alike, so a client can validate uniformly. Answers are checked against the survey's own validation rules, so a payload this endpoint accepts is one the real write accepts. **It reports effects, not just a verdict.** When `valid` is `true`, `effects` names the language that would be stamped, the contact and display that would be linked, the quotas that would count, whether the pipeline would fire, and whether the write would be metered. The language is the field that motivated this endpoint: an importer can see that a payload produces language-less responses *before* writing thousands of them. Authorization is the same as the operation being validated — read-write for `create` and `patch` — so this cannot be used as a cheaper cross-tenant existence probe than the write itself. An inaccessible `responseId` on a patch validation answers **403**, as the patch would. **What it does not do:** an endpoint lets a caller check; it cannot stop a caller writing a bad record anyway. Consistency across the write paths comes from all of them calling the same validator — this endpoint is that validator's public face, not a gate in front of it. tags: - V3 Responses requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ValidateResponseRequest' examples: validateCreate: summary: Validate an intended create value: operation: create data: surveyId: clsv1234567890123456789012 finished: true language: de data: nps_score: 9 what_could_be_better: Die Suche ist zu langsam. validatePatch: summary: Validate an intended patch value: operation: patch responseId: clrs1234567890123456789012 data: finished: true endingId: cmpthankyou00000000000000 responses: '200': description: Validation completed. A `200` means the check ran, **not** that the payload is valid — read `valid`. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ResponseValidationResult' examples: valid: summary: Payload is valid, with the effects a write would have value: data: valid: true operation: create invalid_params: [] effects: language: de contactId: null displayId: null firesPipeline: true countsTowardMeteredResponses: true quotas: [] invalid: summary: Payload is invalid, with repairable errors value: data: valid: false operation: create invalid_params: - name: data.nps_score reason: Value 12 is outside the element's 0–10 range. - name: language reason: Language "fr" is not enabled on this survey. code: unsupported_locale identifier: fr referenceType: language '400': description: 'Bad Request — the envelope itself is malformed: missing or unknown `operation`, missing `data`, or a `patch` without `responseId`; or the body breaks the request limits (an array past its budget, or nesting past 256 levels). Other problems *inside* `data` are the point of this endpoint and come back as `200` with `valid: false`, not as a 400.' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/responses/batch-delete: post: operationId: batchDeleteResponsesV3 summary: Delete responses in bulk description: | Deletes up to 100 responses in one transaction. Requires `manage` access to the workspace. **A POST custom method, not a `DELETE` on the collection.** A collection-level `DELETE` that loses its filters — through a client bug, a proxy stripping a query string, a copied curl command — degrades into erasing every response in scope. A distinct path cannot be reached by accident. AIP-165 blesses `batchDelete` for exactly this reason. **It scope-filters rather than rejecting.** Ids belonging to another workspace, and ids that no longer exist, are ignored; the ids in the caller's own workspace are deleted and `deleted` reports how many that was. Rejecting the whole call on a foreign id would do two bad things at once: leak that the id exists somewhere, and break retries — after a partially-applied call, an already-deleted id is indistinguishable from a foreign one. Scope-filtering is idempotent and leaks nothing, so a client can safely retry the same batch. Because `deleted` can therefore be lower than the number of ids sent — or zero — do not treat a shortfall as an error. Deletion also removes the responses' uploaded files and their Hub copies: both are queued in the delete's transaction and cleaned up after it commits, files at once and Hub feedback records within minutes, retried until done. This differs from the single-response delete, which does not yet remove the Hub copy (see that operation). **`workspaceId` is the scope, and it is required.** Unlike the single-response delete — which resolves the workspace *from* the response so a caller cannot pair someone else's id with a scope it happens to hold — a batch has no single response to resolve from, and scope-filtering is only meaningful against a known workspace. Supplying it is safe here because the same value is both what `manage` is checked against and what the delete is filtered by: the two cannot diverge, so a foreign id matches nothing rather than being deleted under a scope the caller does hold. tags: - V3 Responses parameters: - $ref: '#/components/parameters/WorkspaceIdQuery' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BatchDeleteResponsesRequest' examples: deleteThree: summary: Delete three responses value: ids: - clrs1234567890123456789012 - clrs2234567890123456789012 - clrs3234567890123456789012 responses: '200': description: Batch processed. `deleted` counts the responses actually removed, which may be fewer than the ids submitted. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: type: object required: - deleted properties: deleted: type: integer minimum: 0 description: How many responses were deleted. Lower than `ids.length` when some ids were already gone or belong to another workspace. additionalProperties: false '400': description: Bad Request — malformed body, an empty `ids` array, more than 100 ids, duplicate ids, or an unsupported field. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/responses/{responseId}: get: operationId: getResponseV3 summary: Get a response description: | Returns one response in the detailed view — everything the list view carries, plus `contact`, `displayId`, `singleUseId`, and the stored answer map `data`. The workspace is resolved from the response itself, so the caller cannot choose which scope it is authorized against and no `workspaceId` parameter is accepted. An unknown or inaccessible `responseId` answers **403**, not **404**, so response ids cannot be probed for existence. **Round-tripping.** This read returns `data` in the stored, write-shaped form, so the simplest correction is to take it, change one entry and `PATCH` it back. Embedded Data round-trips through `embeddedData[]` instead: the `key` you read is the `key` a write accepts. `answers[]` is the typed view of the same information, offered alongside. Independently of that, a client working from a *list* row — which omits the maps — can still rebuild the write-shaped payload: `answers[]`, `embeddedData[]` and `unresolved[]` together carry every stored key and value **verbatim**, with no need to refetch the survey. Two rules make that guarantee hold, and both are contractual: `rawKey`, `rawValue`, `valueText`, `valueDate`, `fileUrls` and composite slot values are always the stored bytes, never resolved or reformatted — the resolved forms live in the separate `elementLabel`, `optionId`, `optionLabel`, `rowId` and `columnId` fields. And **empty-but-present values are returned, not omitted**: the `""` of an unchecked consent, the `""` of a selected but unfilled Other, a blank composite slot. That second rule matters because `PATCH` replaces `data` wholesale, so an omitted key is a deleted answer rather than an unchanged one. tags: - V3 Responses parameters: - $ref: '#/components/parameters/ResponseIdPath' responses: '200': description: Response retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ResponseResource' examples: identified: summary: An identified response in the detailed view value: data: id: clrs1234567890123456789012 surveyId: clsv1234567890123456789012 surveyName: Q3 Product Feedback workspaceId: clws1234567890123456789012 createdAt: '2026-08-27T09:14:03.417Z' updatedAt: '2026-08-27T09:17:41.902Z' finished: true endingId: cmpthankyou00000000000000 language: de durationSeconds: 8.2 resolution: labelPolicy: currentSurveyDefinition labelsLanguage: de surveyUpdatedAt: '2026-08-20T11:02:55.000Z' answers: - position: 1 elementId: satisfaction elementType: rating elementLabel: Wie zufrieden sind Sie? valueNumber: 4 range: min: 1 max: 5 scale: star durationSeconds: 8.2 - position: 2 elementId: signup_date elementType: date elementLabel: Wann haben Sie sich registriert? valueDate: '2026-05-14' - position: 3 elementId: your_address elementType: address elementLabel: Ihre Adresse fields: - slot: 0 fieldId: addressLine1 fieldLabel: Adresszeile 1 valueText: Hauptstrasse 1 - slot: 1 fieldId: addressLine2 fieldLabel: Adresszeile 2 valueText: '' - slot: 2 fieldId: city fieldLabel: Stadt valueText: Berlin - slot: 3 fieldId: state fieldLabel: Bundesland valueText: '' - slot: 4 fieldId: zip fieldLabel: PLZ valueText: '10115' - slot: 5 fieldId: country fieldLabel: Land valueText: Deutschland - position: 4 elementId: screenshots elementType: fileUpload elementLabel: Screenshots fileUrls: - /storage/clws1234567890123456789012/private/surveys/clsv1234567890123456789012/elements/screenshots/shot.png fileCount: 1 - position: 5 elementId: priorities elementType: ranking elementLabel: Bitte sortieren Sie nach Wichtigkeit selections: - optionId: opt_speed optionLabel: Geschwindigkeit rawValue: Geschwindigkeit match: label rank: 1 - optionId: opt_price optionLabel: Preis rawValue: Preis match: label rank: 2 - position: 6 elementId: nps_matrix elementType: matrix elementLabel: Bewerten Sie die Bereiche rows: - rawKey: Support rowId: row_support rowLabel: Support columnId: col_good columnLabel: Gut rawValue: Gut match: label embeddedData: - key: plan kind: ingested type: string label: Plan value: enterprise - key: deviceType kind: reserved type: string label: Device Type value: desktop unresolved: [] tags: - id: cltg1234567890123456789012 name: vip contact: id: clct1234567890123456789012 userId: u_123 displayId: cldp1234567890123456789012 singleUseId: null data: satisfaction: 4 signup_date: '2026-05-14' your_address: - Hauptstrasse 1 - '' - Berlin - '' - '10115' - Deutschland screenshots: - /storage/clws1234567890123456789012/private/surveys/clsv1234567890123456789012/elements/screenshots/shot.png priorities: - Geschwindigkeit - Preis nps_matrix: Support: Gut anonymous: summary: An anonymous, unfinished response value: data: id: clrs2234567890123456789012 surveyId: clsv1234567890123456789012 surveyName: Q3 Product Feedback workspaceId: clws1234567890123456789012 createdAt: '2026-08-27T10:01:00.000Z' updatedAt: '2026-08-27T10:01:30.000Z' finished: false endingId: null language: null resolution: labelPolicy: currentSurveyDefinition labelsLanguage: null surveyUpdatedAt: '2026-08-20T11:02:55.000Z' answers: - position: 1 elementId: cta_intro elementType: cta elementLabel: Jetzt starten valueBoolean: true rawValue: clicked embeddedData: [] unresolved: [] tags: [] contact: null displayId: null singleUseId: null data: cta_intro: clicked '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] patch: operationId: updateResponseV3 summary: Update a response description: | Partially updates a response. Requires read-write access; the workspace is resolved from the response. An unknown or inaccessible `responseId` answers **403**. `PATCH` is the only update verb — there is no `PUT`. Within it, `data` is replaced wholesale rather than deep-merged, so send the complete answer map; `embeddedData` merges by field name. Unknown top-level keys are rejected with **400** rather than ignored. Every patch emits `responseUpdated` and runs the response pipeline, including Hub ingestion. Transitioning `finished` to `true` additionally emits `responseFinished`; patching a response that is already finished does not re-emit it. tags: - V3 Responses parameters: - $ref: '#/components/parameters/ResponseIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PatchResponseRequest' examples: finishResponse: summary: Mark a partial response finished value: finished: true endingId: cmpthankyou00000000000000 replaceAnswers: summary: Replace the stored answers wholesale description: | `data` is not merged. Anything omitted from this map is removed from the response. value: data: nps_score: 8 what_could_be_better: Etwas schneller wäre gut. retagResponse: summary: Replace the response's tags value: tags: - cltg1234567890123456789012 responses: '200': description: Response updated successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ResponseResource' '400': description: | Bad Request — the body failed schema validation, or it carried a field that is not patchable. `createdAt`, `updatedAt`, `surveyId`, `contactId`, `displayId`, `singleUseId`, `contactAttributes` and `meta` are all rejected here with `code: unsupported_field`. An empty body is also a 400. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '413': description: Payload Too Large — the request body exceeds the 2 MB limit. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '422': description: 'Unprocessable Content — the body is well-formed but conflicts with stored state: `endingId` is not an ending of the response''s survey, `language` is not enabled on it, a tag id does not resolve in the same workspace, an `embeddedData` name is not declared on the survey, is ambiguous, or is addressed twice by one payload, a file-upload answer references a file outside this survey''s upload element, or an answer violates the survey''s validation rules.' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] delete: operationId: deleteResponseV3 summary: Delete a response description: | Permanently deletes a response. Requires `manage` access; the workspace is resolved from the response, and an unknown or inaccessible `responseId` answers **403**. Deletion cascades to any files uploaded with the response, which are removed from storage after the row is committed. No `responseDeleted` webhook is emitted — no such trigger exists. **The Hub copy is not removed.** A submission forwarded to the Hub stays there and stays searchable through the feedback-record endpoints after this returns 204. That cascade is ENG-2855 and lands after the Hub release; until then, an erasure workflow has to delete the feedback record itself. Stated because a caller cannot cheaply verify deletion semantics on a destructive endpoint. tags: - V3 Responses parameters: - $ref: '#/components/parameters/ResponseIdPath' responses: '204': description: Response deleted successfully. No body. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/workflows: get: operationId: listWorkflowsV3 summary: List workflows description: | Returns workflow list items for a workspace, sorted by `sortBy` (default `updatedAt`, newest first). Workflows are workspace-scoped and exposed through the same v3 session-or-API-key authorization model as surveys. List items are intentionally slim: they include the derived `triggerType` and `surveyId` plus a `lastRun` summary, but not the full `definition`. Use `GET /api/v3/workflows/{workflowId}` to read the definition. `archived` workflows are excluded unless explicitly requested with `filter[status][in]=archived`. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkspaceIdQuery' - in: query name: limit schema: type: integer minimum: 1 maximum: 100 default: 20 description: 'Page size (max 100). v3 has three list maxima rather than one norm: 250 on responses, surveys and the reference lists, 1000 on `feedbackRecords`, and 100 here. The workflows bound lives in the shared `packages/workflows` contracts, owned alongside the feature rather than by this spec, which is why it did not move with the others. Documented at the value actually enforced; tracked separately.' - $ref: '#/components/parameters/CursorQuery' - in: query name: filter[name][contains] schema: type: string maxLength: 512 description: Case-insensitive substring match on workflow name (same semantics as the survey list filter). - in: query name: filter[status][in] schema: type: array items: $ref: '#/components/schemas/WorkflowStatus' style: form explode: true description: | Workflow lifecycle-state filter. Repeat the parameter (`filter[status][in]=draft&filter[status][in]=disabled`) or use comma-separated values (`filter[status][in]=draft,disabled`). Omitting the filter returns every status except `archived`. Invalid values → **400**. - in: query name: sortBy schema: type: string enum: - createdAt - updatedAt - name description: Sort order. Defaults to `updatedAt` (newest first). The `cursor` token is bound to the selected sort order. - in: query name: includeTotalCount schema: type: boolean default: false description: When `true`, `meta.totalCount` is the number of workflows matching the same filters across all pages; otherwise it is `null`. Values other than `true`/`false` → **400**. responses: '200': description: Workflows retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/WorkflowListItem' meta: $ref: '#/components/schemas/CountedListMeta' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] post: operationId: createWorkflowV3 summary: Create a workflow description: | Creates a `draft` workflow. The workflow definition is the public source of truth used by the dashboard and external clients, following the document-first pattern established by the v3 Survey API. Runtime persistence, revision creation, and version snapshotting are implementation details behind this contract. Workflows are always created as drafts; use `POST /api/v3/workflows/{workflowId}/enable` to make them respond to trigger events. Graph reference failures (duplicate node ids, edges pointing at missing nodes, `entryNodeId` not referencing the trigger, unknown `surveyId` or `endingCardIds` in the workspace) are rejected with **400** and structured `invalid_params`. tags: - V3 Workflows requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateWorkflowRequest' examples: responseCompletedEmail: summary: Create a response-completed email workflow value: workspaceId: clworkspace000000000000000 name: Notify team after CSAT completion description: Sends an internal email when a respondent reaches the negative CSAT ending. status: draft definition: schemaVersion: 1 trigger: id: trigger type: trigger triggerType: response.completed label: CSAT survey completed config: surveyId: clsurvey000000000000000000 endingCardIds: - clending000000000000000000 nodes: - id: send-email type: action actionType: send_email label: Notify support team config: to: support@example.com from: noreply@example.com replyTo: - support@example.com subject: Thanks for your feedback body: We saw your feedback and will follow up soon. attachResponseData: true includeVariables: true includeHiddenFields: false ui: position: x: 260 'y': 120 edges: - id: edge-trigger-email source: trigger target: send-email entryNodeId: trigger responses: '201': description: Workflow created successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store Location: schema: type: string example: /api/v3/workflows/clworkflow000000000000000 content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/WorkflowResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/workflows/runs: get: operationId: listWorkflowRunsV3 summary: List workflow runs description: | Returns workflow run summaries for a workspace, newest first (`createdAt` descending). Use `workflowId` to narrow the list to one workflow, or `responseId` to find the runs a specific survey response produced. List items are summaries without `triggerPayload`, `data`, or `logs`; fetch `GET /api/v3/workflows/runs/{runId}` for the full debug-oriented payload. This keeps response-level data out of list reads by default. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkspaceIdQuery' - in: query name: limit schema: type: integer minimum: 1 maximum: 100 default: 20 description: Page size (max 100). The workflows endpoints have not yet moved to the v3-wide maximum of 250 — their bound lives in the shared `packages/workflows` contracts, which are owned alongside the workflows feature rather than by this spec. Documented at the value actually enforced rather than at the platform norm; tracked separately. - $ref: '#/components/parameters/CursorQuery' - in: query name: workflowId schema: type: string format: cuid2 description: Optional workflow scoping. Returns only runs of this workflow. - in: query name: responseId schema: type: string format: cuid2 description: Optional response scoping. Returns only runs triggered by this survey response. - in: query name: filter[status][in] schema: type: array items: $ref: '#/components/schemas/WorkflowRunStatus' style: form explode: true description: | Run lifecycle-state filter. Repeat the parameter or use comma-separated values (`filter[status][in]=failed,canceled`). Invalid values → **400**. - in: query name: filter[isDryRun][eq] schema: type: boolean description: | Filter for dry runs created by `POST /api/v3/workflows/{workflowId}/test` (`true`) or for real trigger-initiated runs only (`false`). Omit to return both. responses: '200': description: Workflow runs retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/WorkflowRunListItem' meta: $ref: '#/components/schemas/CursorPaginationMeta' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/workflows/runs/{runId}: get: operationId: getWorkflowRunV3 summary: Retrieve a workflow run description: | Returns one workflow run by globally unique run id, including the full debug payload: the captured `triggerPayload`, the run `data` (per-step results), the persisted `logs` trace, and retry metadata (`attempt`, `nextAttemptAt`, `lastErrorAt`). Scope 1 deliberately exposes these as JSON dumps so users and agents can understand exactly what happened in a run. Poll this endpoint after `POST /api/v3/workflows/{workflowId}/test`: runs execute asynchronously and transition `queued` → `running` → `completed`/`failed`/`canceled`. Unknown or inaccessible run ids return `403` rather than `404` to avoid leaking cross-workspace resource existence. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkflowRunIdPath' responses: '200': description: Workflow run retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/WorkflowRunResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/workflows/{workflowId}: get: operationId: getWorkflowV3 summary: Retrieve a workflow description: | Returns one workflow definition by id. Unknown or inaccessible workflow ids return `403` rather than `404` to avoid leaking cross-workspace resource existence. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkflowIdPath' responses: '200': description: Workflow retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/WorkflowResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] patch: operationId: patchWorkflowV3 summary: Update a workflow description: | Updates human-facing workflow metadata and/or the workflow definition. Status changes are handled by lifecycle endpoints (`enable`, `disable`, `archive`, `unarchive`) so the state machine remains explicit. Provided fields replace the corresponding stored fields; this endpoint does not implement JSON Patch. `name` and `description` can be updated in any state except `archived`. The `definition` can only be updated while the workflow is `draft` or `disabled`: the live version of an `enabled` workflow is an immutable snapshot, so definition changes require disabling (or moving to draft) first and re-enabling afterwards. Violations return **422** with code `invalid_workflow_state`. A structurally invalid definition returns **400** with structured `invalid_params`; the referenced survey and ending cards are not verified here — that existence check runs when the workflow is enabled. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkflowIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PatchWorkflowRequest' responses: '200': description: Workflow updated successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/WorkflowResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] delete: operationId: deleteWorkflowV3 summary: Delete a workflow description: | Permanently deletes a workflow by id. Deletion cascades to the workflow's versions, runs, and run logs so no response-derived data is left behind (GDPR requirement). This is irreversible; use `POST /api/v3/workflows/{workflowId}/archive` for a reversible soft-delete that keeps history. Returns `204 No Content`. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkflowIdPath' responses: '204': description: Workflow deleted successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/workflows/{workflowId}/duplicate: post: operationId: duplicateWorkflowV3 summary: Duplicate a workflow description: | Creates a new `draft` workflow by copying an existing workflow's definition, name (or the provided override), and description. The duplicate receives its own id and timestamps and starts with empty run and version history; the source workflow is unchanged. Archived workflows can be duplicated (the copy starts as a normal draft). tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkflowIdPath' requestBody: required: false content: application/json: schema: $ref: '#/components/schemas/DuplicateWorkflowRequest' responses: '201': description: Workflow duplicated successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store Location: schema: type: string example: /api/v3/workflows/clworkflowcopy000000000 content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/WorkflowResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/workflows/{workflowId}/enable: post: operationId: enableWorkflowV3 summary: Enable a workflow description: | Moves a workflow from `draft` or `disabled` to `enabled`. Only enabled workflows respond to trigger events. Enabling validates that the definition is executable — the trigger has exactly one outgoing edge (trigger-only drafts cannot be enabled), the graph is acyclic, every node is reachable from the trigger, only Scope 1 node types are present, the referenced survey and ending cards still exist, and every `send_email` action addressing a *literal* email address targets someone who can access this workspace (an element-id `to` resolves to the respondent's own address and is exempt) — and then snapshots an immutable workflow version. Runs reference that snapshot via `workflowVersionId`, so later draft edits never change what an enabled workflow executes. Validation failures return **422** with code `workflow_not_executable` and structured `invalid_params`; calling enable in a state other than `draft` or `disabled` returns **422** with code `invalid_workflow_state`. Two concurrent enable requests race to publish the next version; the loser is rejected with **409** code `conflict` and can safely retry. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkflowIdPath' responses: '200': $ref: '#/components/responses/WorkflowResourceResponse' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '409': $ref: '#/components/responses/V3Conflict' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/workflows/{workflowId}/disable: post: operationId: disableWorkflowV3 summary: Disable a workflow description: | Moves an `enabled` workflow to `disabled`. Disabled workflows keep their configuration but do not respond to trigger events; runs that are already queued or running are not canceled. Calling disable in any other state returns **422** with code `invalid_workflow_state`. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkflowIdPath' responses: '200': $ref: '#/components/responses/WorkflowResourceResponse' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/workflows/{workflowId}/archive: post: operationId: archiveWorkflowV3 summary: Archive a workflow description: | Soft-deletes a workflow by moving it from any non-archived state to `archived`. Archived workflows stop responding to trigger events immediately, become read-only, and are excluded from list responses unless requested with `filter[status][in]=archived`. Their runs, version history, and definition are preserved, and the workflow can be restored with `POST /api/v3/workflows/{workflowId}/unarchive`. Archiving an already archived workflow returns **422** with code `invalid_workflow_state`. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkflowIdPath' responses: '200': $ref: '#/components/responses/WorkflowResourceResponse' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/workflows/{workflowId}/unarchive: post: operationId: unarchiveWorkflowV3 summary: Unarchive a workflow description: | Restores an `archived` workflow to `draft`. Unarchiving never re-enables a workflow automatically: re-enable explicitly with `POST /api/v3/workflows/{workflowId}/enable`, which re-validates the definition (the referenced survey or ending cards may have changed while the workflow was archived). Calling unarchive on a non-archived workflow returns **422** with code `invalid_workflow_state`. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkflowIdPath' responses: '200': $ref: '#/components/responses/WorkflowResourceResponse' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/workflows/{workflowId}/test: post: operationId: testWorkflowV3 summary: Dry-run (validate) a workflow description: | Validates a workflow without running it or causing any side effect. It checks that the workflow's current definition is executable, that the trigger's referenced survey and ending cards still exist in the workspace, and that every `send_email` action addressing a *literal* email address targets someone who can access this workspace. **No** workflow run is created and **no** email or webhook is sent. The response is always `200` with `{ data: { workflowId, ok, problems } }`. `data.ok` is `true` when the workflow would execute; otherwise `data.problems` lists every issue found — each with a machine-readable `code` and the offending `field` — so they can be fixed in a single pass. Drafts are testable — validating the setup before going live is the point of a dry run. Only an `archived` workflow is rejected, with **422** and code `invalid_workflow_state`. tags: - V3 Workflows parameters: - $ref: '#/components/parameters/WorkflowIdPath' responses: '200': description: | Validation result. The request succeeded; inspect `data.ok` to see whether the workflow is ready, and `data.problems` for any issues. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/WorkflowTestResult' examples: ok: summary: Workflow is valid and ready to enable value: data: workflowId: clworkflow0000000000000000 ok: true problems: [] problems: summary: Workflow has unresolved trigger references value: data: workflowId: clworkflow0000000000000000 ok: false problems: - code: survey_not_found field: definition.trigger.config.surveyId message: The referenced survey does not exist in this workspace. - code: ending_card_not_found field: definition.trigger.config.endingCardIds message: Ending card clending000000000000000000 does not exist on the survey. recipientNotAllowed: summary: A send_email action addresses a literal recipient without workspace access value: data: workflowId: clworkflow0000000000000000 ok: false problems: - code: recipient_not_allowed field: definition.nodes.config.to message: Recipient outsider@external.example cannot access this workspace. A send_email action may only address someone with access to this workspace or a respondent field. '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/charts: get: operationId: listChartsV3 summary: List charts description: | Lists a workspace's charts, newest first, page by page. Each chart embeds the dashboards that hold it. Needs read access to the workspace. Session cookie, `x-api-key`, or a Bearer token. tags: - V3 Charts parameters: - $ref: '#/components/parameters/WorkspaceIdQuery' - in: query name: limit schema: type: integer minimum: 1 maximum: 250 default: 50 description: Page size (max 250). Treat `meta.nextCursor` as the only end-of-collection signal. - $ref: '#/components/parameters/CursorQuery' - in: query name: filter[name][contains] schema: type: string minLength: 1 maxLength: 255 description: Only charts whose name contains this text, ignoring case. `%` and `_` match themselves. - in: query name: filter[datasetId][eq] schema: type: string format: cuid2 description: Only charts reading this Feedback Dataset. responses: '200': description: A page of charts headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/ChartResource' meta: $ref: '#/components/schemas/CursorPaginationMeta' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] post: operationId: createChartV3 summary: Create a chart description: | Creates a chart on a Feedback Dataset. The chart query goes through the chart query validator (422 naming each problem); write it against `GET /api/v3/charts/fields` and check it with `POST /api/v3/charts/preview` first. Needs write access to the workspace and to the Feedback Dataset. A name already used in the workspace answers 409. tags: - V3 Charts requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ChartCreateRequest' responses: '201': description: Chart created headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store Location: schema: type: string example: /api/v3/charts/clchart000000000000000001 content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ChartResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '409': $ref: '#/components/responses/V3DashboardsConflict' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/charts/fields: get: operationId: listChartFieldsV3 summary: List chart fields description: | Returns the field catalog for a Feedback Dataset: every field a chart query may use, with its kind, type, label, description, filter operators and granularities, plus the date presets. Write chart queries against this list — the chart query validator refuses any field that is not in it, so the catalog cannot drift from what the API accepts. Needs read access to the workspace and to the Feedback Dataset. Session cookie, `x-api-key`, or a Bearer token. tags: - V3 Charts parameters: - $ref: '#/components/parameters/WorkspaceIdQuery' - $ref: '#/components/parameters/DatasetIdQuery' responses: '200': description: Field catalog headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ChartFieldCatalog' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/charts/fields/{field}/values: get: operationId: listChartFieldValuesV3 summary: List a field's values description: | Returns up to 100 distinct stored values of a field, so a filter can use a value that really occurs — an `equals` filter on a picked value cannot miss on casing or spacing. Only fields the catalog marks `valueLookup: true` qualify; any other field, or one outside the catalog, answers 422. Narrow a field with more values using `search`. The list is not paged. Needs read access to the workspace and the Feedback Dataset. Counts against the chart-query budget (600 per minute, 20 at once, per user or API key), not the v3 default. tags: - V3 Charts parameters: - in: path name: field required: true schema: type: string maxLength: 128 description: A field name from the catalog, prefix included. example: FeedbackRecords.sourceName - $ref: '#/components/parameters/WorkspaceIdQuery' - $ref: '#/components/parameters/DatasetIdQuery' - in: query name: search schema: type: string minLength: 1 maxLength: 255 description: Only values containing this text, ignoring case. responses: '200': description: The field's values, in ascending order headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: type: array maxItems: 100 items: type: object required: - value properties: value: type: string fieldType: type: string description: The question type, present when the field is `FeedbackRecords.fieldLabel`. additionalProperties: false '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3ChartQueryTooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' '502': $ref: '#/components/responses/V3ChartQueryFailed' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/charts/preview: post: operationId: previewChartV3 summary: Preview a chart description: | Runs an unsaved chart and returns its rows, so a caller can check it shows what was meant before saving it. Saves nothing. The chart query is checked by the chart query validator first: a field outside the catalog, a refused Cube option, a `timezone`, a bound exceeded or an invalid date range answers 422 naming each problem in `invalid_params`. On a chart grouped by time alone, a date range wider than 10,000 buckets of its granularity is one such bound (`limit_exceeded`). Needs read access to the workspace and the Feedback Dataset. Counts against the chart-query budget (600 per minute, 20 at once, per user or API key), not the v3 default. tags: - V3 Charts requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ChartPreviewRequest' responses: '200': description: The chart's rows headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ChartData' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3ChartQueryTooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' '502': $ref: '#/components/responses/V3ChartQueryFailed' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/charts/{chartId}: get: operationId: getChartV3 summary: Get a chart description: | Returns one chart with the dashboards that hold it. The workspace is resolved from the chart, so the operation takes no query parameters — a `workspaceId` is refused with 400. Needs read access to the chart's workspace; a chart the caller cannot see, or that does not exist, answers 403. tags: - V3 Charts parameters: - $ref: '#/components/parameters/ChartIdPath' responses: '200': description: The chart headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ChartResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] patch: operationId: updateChartV3 summary: Update a chart description: | Edits a chart's name, type, chart query or display settings. The chart may sit on several dashboards (`dashboards` in the response), and the edit shows on every one of them — duplicate the chart and repoint one dashboard's widget to change it there only. A chart's Feedback Dataset cannot change. Needs write access to the chart's workspace and, while its Feedback Dataset is assigned to that workspace and not archived, to the dataset, as a delete does. Once the dataset is unassigned or archived, the chart can still be renamed or restyled, but its `query` and `type` can no longer change. tags: - V3 Charts parameters: - $ref: '#/components/parameters/ChartIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ChartUpdateRequest' responses: '200': description: The updated chart headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ChartResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '409': $ref: '#/components/responses/V3DashboardsConflict' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] delete: operationId: deleteChartV3 summary: Delete a chart description: | Deletes a chart that sits on no dashboard. A chart still on one is refused with 409 `chart_in_use`, naming those dashboards in `details.dashboards` — remove it from them first (`DELETE /api/v3/dashboards/{dashboardId}/widgets/{widgetId}`); there is no force option. The check and the delete are one transaction, so a widget added at the same moment is never removed with the chart. Needs write access to the chart's workspace and, while its Feedback Dataset is assigned to that workspace and not archived, to the dataset; a chart whose dataset was unassigned or archived can still be deleted. tags: - V3 Charts parameters: - $ref: '#/components/parameters/ChartIdPath' responses: '204': description: Chart deleted '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '409': description: 'Conflict (`code: chart_in_use`) — the chart is on at least one dashboard, so nothing was deleted. `details.dashboards` lists them as `{ id, name }`.' headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: title: Conflict status: 409 detail: This chart is on 1 dashboard(s), so it was not deleted. Remove it from them first. code: chart_in_use requestId: 6f1f7f3e-3b0e-4a1a-9f0a-7c9a1d2b3c4d details: dashboards: - id: cldash00000000000000000001 name: Matchday 1 '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/charts/{chartId}/data: get: operationId: getChartDataV3 summary: Get a chart's data description: | Runs a saved chart and returns its rows — the endpoint every dashboard widget loads its data from. A date range given here replaces the chart's own range on `FeedbackRecords.collectedAt`, exactly as a dashboard's date filter does, so the numbers match what a person sees on a dashboard filtered the same way. `dateRange=all-time` removes the range; without a date range the chart's saved range applies. Every range is read in the organization's display time zone; a `timezone` parameter is refused with 400. For a chart grouped by time alone, a requested range that spans more than 10,000 buckets of its granularity answers 422 `limit_exceeded` on `dateRange`: request a shorter range, or use a chart with a coarser granularity. Needs read access to the chart's workspace and to its Feedback Dataset. Counts against the chart-query budget (600 per minute, 20 at once, per user or API key), not the v3 default. tags: - V3 Charts parameters: - $ref: '#/components/parameters/ChartIdPath' - in: query name: dateRange schema: type: string maxLength: 64 description: A date preset from the field catalog's `datePresets`, or `all-time`. Not together with `from`/`to`. example: last 7 days - in: query name: from schema: type: string format: date description: First day of a custom range (inclusive). Requires `to`. - in: query name: to schema: type: string format: date description: Last day of a custom range (inclusive). Requires `from`. responses: '200': description: The chart's rows headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ChartData' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3ChartQueryTooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' '502': $ref: '#/components/responses/V3ChartQueryFailed' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/charts/{chartId}/duplicate: post: operationId: duplicateChartV3 summary: Duplicate a chart description: | Copies a chart — same Feedback Dataset, type, chart query and display settings — so the copy can change without touching the dashboards that show the original. With `dashboardId`, the copy is also put on that dashboard (first open slot, or `layout`) in the same transaction, and the dashboard's `updatedAt` moves; a dashboard already holding 250 widgets, or with no open slot above the grid's last row, answers 409 and no copy is made. Needs write access to the chart's workspace and to its Feedback Dataset. tags: - V3 Charts parameters: - $ref: '#/components/parameters/ChartIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ChartDuplicateRequest' responses: '201': description: The copy headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store Location: schema: type: string example: /api/v3/charts/clchart000000000000000002 content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ChartResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '409': $ref: '#/components/responses/V3DashboardsConflict' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/dashboards: get: operationId: listDashboardsV3 summary: List dashboards description: | Lists a workspace's dashboards, newest first, page by page, each with its widget count. `filter[chartId][eq]` lists the dashboards that hold a chart. Needs read access to the workspace. tags: - V3 Dashboards parameters: - $ref: '#/components/parameters/WorkspaceIdQuery' - in: query name: limit schema: type: integer minimum: 1 maximum: 250 default: 50 description: Page size (max 250). Treat `meta.nextCursor` as the only end-of-collection signal. - $ref: '#/components/parameters/CursorQuery' - in: query name: filter[name][contains] schema: type: string minLength: 1 maxLength: 255 description: Only dashboards whose name contains this text, ignoring case. - in: query name: filter[chartId][eq] schema: type: string format: cuid2 description: Only dashboards that hold this chart. responses: '200': description: A page of dashboards headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/DashboardListItem' meta: $ref: '#/components/schemas/CursorPaginationMeta' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] post: operationId: createDashboardV3 summary: Create a dashboard description: | Creates an empty dashboard in a workspace. Needs write access to the workspace. A name already used in the workspace answers 409. tags: - V3 Dashboards requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DashboardCreateRequest' responses: '201': description: Dashboard created headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store Location: schema: type: string example: /api/v3/dashboards/cldash00000000000000000001 content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DashboardResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '409': $ref: '#/components/responses/V3DashboardsConflict' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/dashboards/{dashboardId}: get: operationId: getDashboardV3 summary: Get a dashboard description: | Returns a dashboard with every widget and its full chart, so one call tells a caller what is on it. Read each widget's numbers from `GET /api/v3/charts/{chartId}/data`. The workspace is resolved from the dashboard, so the operation takes no query parameters. Needs read access to the dashboard's workspace; a dashboard the caller cannot see, or that does not exist, answers 403. tags: - V3 Dashboards parameters: - $ref: '#/components/parameters/DashboardIdPath' responses: '200': description: The dashboard headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DashboardResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] patch: operationId: renameDashboardV3 summary: Rename a dashboard description: | Renames a dashboard and moves its `updatedAt`, so an editor holding the dashboard from before is told when saving. Needs write access to the dashboard's workspace. A name already used in the workspace answers 409. tags: - V3 Dashboards parameters: - $ref: '#/components/parameters/DashboardIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DashboardRenameRequest' responses: '200': description: The renamed dashboard headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DashboardResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '409': $ref: '#/components/responses/V3DashboardsConflict' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] put: operationId: replaceDashboardV3 summary: Save a whole dashboard description: | Replaces a dashboard's name and every widget's place in one transaction — all of it or none of it. A widget left out is removed; its chart is kept. Widgets may leave gaps but not overlap (422). Send the `updatedAt` from your read as `expectedUpdatedAt`: if the dashboard changed since, the save is refused with 409 and nothing is written. Needs write access to the dashboard's workspace. tags: - V3 Dashboards parameters: - $ref: '#/components/parameters/DashboardIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DashboardReplaceRequest' responses: '200': description: The saved dashboard headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DashboardResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '409': description: 'Conflict — the dashboard changed since your read, or the name is already used in the workspace. Nothing was written. For a changed dashboard, `details` carries `expectedUpdatedAt` and the live `currentUpdatedAt`: read it again and retry.' headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: title: Conflict status: 409 detail: The dashboard changed since your read, so nothing was saved. Read it again, then retry. code: conflict requestId: 6f1f7f3e-3b0e-4a1a-9f0a-7c9a1d2b3c4d details: expectedUpdatedAt: '2026-10-22T10:00:00.000Z' currentUpdatedAt: '2026-10-22T11:30:00.000Z' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] delete: operationId: deleteDashboardV3 summary: Delete a dashboard description: | Deletes a dashboard and its widgets. Every chart stays, including charts no other dashboard holds; delete those with `DELETE /api/v3/charts/{chartId}`. Needs write access to the dashboard's workspace. tags: - V3 Dashboards parameters: - $ref: '#/components/parameters/DashboardIdPath' responses: '204': description: Dashboard deleted '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/dashboards/{dashboardId}/duplicate: post: operationId: duplicateDashboardV3 summary: Duplicate a dashboard description: | Copies a dashboard, in one transaction, with its layout. The copy **shares** the original's charts: its widgets point at the same charts, so editing one of them changes both dashboards. To change a chart on the copy only, duplicate the chart and repoint the copy's widget. Needs write access to the dashboard's workspace. A `name` already used in the workspace answers 409. tags: - V3 Dashboards parameters: - $ref: '#/components/parameters/DashboardIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DashboardDuplicateRequest' responses: '201': description: The copy headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store Location: schema: type: string example: /api/v3/dashboards/cldash00000000000000000002 content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DashboardResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '409': $ref: '#/components/responses/V3DashboardsConflict' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/dashboards/{dashboardId}/widgets: post: operationId: addDashboardWidgetV3 summary: Add a chart to a dashboard description: | Puts an existing chart on a dashboard. Without a `layout` the chart lands in the first open slot at its chart type's default size; with one, exactly there. Widgets may leave gaps but not overlap: a layout covering another widget answers 422 naming it. A chart appears at most once per dashboard (409), and must live in the dashboard's workspace. A dashboard holds at most 250 widgets, and a new widget takes the first open slot: an add past the cap, or with no open slot above the grid's last row, answers 409. Moves the dashboard's `updatedAt`. Needs write access to the dashboard's workspace. tags: - V3 Dashboards parameters: - $ref: '#/components/parameters/DashboardIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WidgetCreateRequest' responses: '201': description: The new widget headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store Location: schema: type: string example: /api/v3/dashboards/cldash00000000000000000001/widgets/clwidget000000000000000001 content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DashboardWidget' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '409': $ref: '#/components/responses/V3DashboardsConflict' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/dashboards/{dashboardId}/widgets/{widgetId}: get: operationId: getDashboardWidgetV3 summary: Get a widget description: | Returns one widget on a dashboard with its full chart: the resource an add's `Location` names. Needs read access to the dashboard's workspace; a widget that is not on the dashboard, or a dashboard the caller cannot see, answers 403. tags: - V3 Dashboards parameters: - $ref: '#/components/parameters/DashboardIdPath' - $ref: '#/components/parameters/WidgetIdPath' responses: '200': description: The widget headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DashboardWidget' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] patch: operationId: updateDashboardWidgetV3 summary: Move a widget or show another chart in it description: | Moves or resizes a widget, points it at another chart, or both. A layout covering another widget answers 422 naming it; a chart already on the dashboard answers 409. Moves the dashboard's `updatedAt`. Needs write access to the dashboard's workspace. tags: - V3 Dashboards parameters: - $ref: '#/components/parameters/DashboardIdPath' - $ref: '#/components/parameters/WidgetIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WidgetUpdateRequest' responses: '200': description: The updated widget headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DashboardWidget' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '409': $ref: '#/components/responses/V3DashboardsConflict' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] delete: operationId: removeDashboardWidgetV3 summary: Remove a chart from a dashboard description: | Takes a widget off its dashboard. The chart is kept, with every other dashboard it sits on. Moves the dashboard's `updatedAt`. Needs write access to the dashboard's workspace. tags: - V3 Dashboards parameters: - $ref: '#/components/parameters/DashboardIdPath' - $ref: '#/components/parameters/WidgetIdPath' responses: '204': description: Widget removed; the chart is kept '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3DashboardsForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/creatives: get: operationId: getCreativesV3 summary: List creatives description: | Returns a workspace's creatives, most recently updated first. Session cookie, `x-api-key`, or a Bearer token, with read access to the workspace. **Archived creatives are left out** unless `filter[displayStatus][in]` asks for `archived`. `filter[displayStatus][in]` filters on the derived `displayStatus`, so it matches what each row shows: a creative whose survey is paused matches `paused`, one whose schedule has ended matches `closed`. `surveyId` keeps the creatives that open that survey. **Pagination is keyset** on the sort field and `id`. The `cursor` is opaque and bound to the sort order and the filters it was issued for; changing either answers **400**. `meta.nextCursor` being `null` is the only end-of-collection signal. tags: - V3 Creatives parameters: - $ref: '#/components/parameters/WorkspaceIdQuery' - $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/CursorQuery' - in: query name: filter[name][contains] schema: type: string maxLength: 512 description: Case-insensitive substring match on the creative's name. - in: query name: filter[type][in] schema: type: array items: $ref: '#/components/schemas/CreativeType' style: form explode: true description: Creative types to include. Repeat the parameter or use comma-separated values. - in: query name: filter[displayStatus][in] schema: type: array items: $ref: '#/components/schemas/CreativeDisplayStatus' style: form explode: true description: Derived statuses to include. Repeat the parameter or use comma-separated values. Omit for every status but `archived`. - in: query name: surveyId schema: type: string format: cuid2 description: Only creatives whose Destination is this survey. - in: query name: sortBy schema: type: string enum: - updatedAt - createdAt - name default: updatedAt description: 'Sort order: `updatedAt` and `createdAt` newest first, `name` alphabetically. The `cursor` is bound to it.' responses: '200': description: Creatives retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/CreativeResource' meta: $ref: '#/components/schemas/CreativeListMeta' examples: page: summary: One page with a feedback button value: data: - id: clcr1234567890123456789012 workspaceId: clws1234567890123456789012 name: Site feedback tab type: feedbackButton status: inProgress displayStatus: active displayStatusReason: null createdAt: '2026-10-16T09:00:00.000Z' updatedAt: '2026-10-16T09:30:00.000Z' publishedAt: '2026-10-16T09:30:00.000Z' archivedAt: null creator: name: Jane Doe defaultLanguage: en-US languages: - code: en-US default: true enabled: true autoSelectLanguage: null content: label: en-US: Feedback icon: messageSquare styling: position: desktop: edge: right offset: 50 phone: null theme: customStyles: false backgroundColor: null textColor: null borderColor: null borderRadius: null fontWeight: null fontSize: desktop: null phone: null paddingX: desktop: null phone: null paddingY: desktop: null phone: null destination: type: survey surveyId: clsv1234567890123456789012 surveyName: Site feedback opensAs: popup distribution: showOn: pageView triggers: [] conditions: null displayPercentage: null schedule: startAt: null endAt: null warnings: [] meta: limit: 20 nextCursor: null '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3CreativesForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] post: operationId: createCreativeV3 summary: Create a creative description: | Creates a feedback button or a banner. Requires read/write access to `workspaceId`. The creative is a **draft** unless `status` says otherwise. **Destination.** A survey Destination must be in the same workspace, visible to the whole workspace (never Restricted, nor with a visibility change pending), and neither single-use nor PIN-protected; an `http(s)` link is the alternative. Anything else answers **422** naming `destination`. **Publish rules**, checked whenever the resulting `status` isn't `draft`: a feedback button, and a banner with a button, need a `destination`; a banner without a button can't have one; the default language's label or message can't be empty; `showOn: action` needs at least one trigger. A breach answers **422** with one `invalid_params` entry per problem. **Languages.** Every text map must hold `defaultLanguage`, and any other locale key must be declared in `languages` (**422** `unsupported_locale` otherwise). A missing translation is fine: visitors see the default language. **Custom CSS** (`styling.theme.customCss`). Adding it needs the Scale plan on Formbricks Cloud (**403** `custom_css_plan_required`), and CSS that cannot be processed answers **422** naming `styling.theme.customCss`. Processing counts against the custom CSS budget shared with surveys; beyond it the request is a **429** and nothing is written. tags: - V3 Creatives requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateCreativeRequest' examples: feedbackButton: summary: A draft feedback button that opens a survey value: workspaceId: clws1234567890123456789012 name: Site feedback tab type: feedbackButton content: label: en-US: Feedback icon: messageSquare destination: type: survey surveyId: clsv1234567890123456789012 banner: summary: A scheduled maintenance banner, published straight away value: workspaceId: clws1234567890123456789012 name: Maintenance notice type: banner status: inProgress content: message: en-US: - text: 'Scheduled maintenance tonight from 02:00 to 04:00. ' - text: Details href: /maintenance underline: true button: null dismissible: true accessibleName: null schedule: startAt: '2026-11-01T01:00:00.000Z' endAt: '2026-11-01T04:00:00.000Z' responses: '201': description: Creative created headers: X-Request-Id: schema: type: string description: Request correlation ID Location: schema: type: string description: URL of the new creative. Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/CreativeResource' examples: banner: summary: The banner created above value: data: id: clcr2234567890123456789012 workspaceId: clws1234567890123456789012 name: Maintenance notice type: banner status: inProgress displayStatus: scheduled displayStatusReason: null createdAt: '2026-10-16T09:00:00.000Z' updatedAt: '2026-10-16T09:00:00.000Z' publishedAt: '2026-10-16T09:00:00.000Z' archivedAt: null creator: null defaultLanguage: en-US languages: - code: en-US default: true enabled: true autoSelectLanguage: null content: message: en-US: - text: 'Scheduled maintenance tonight from 02:00 to 04:00. ' - text: Details href: /maintenance underline: true button: null dismissible: true accessibleName: null styling: position: desktop: placement: top layout: inPage phone: null theme: customStyles: false backgroundColor: null textColor: null borderColor: null fontWeight: null fontSize: desktop: null phone: null paddingX: desktop: null phone: null paddingY: desktop: null phone: null destination: null distribution: showOn: pageView triggers: [] conditions: null displayPercentage: null schedule: startAt: '2026-11-01T01:00:00.000Z' endAt: '2026-11-01T04:00:00.000Z' warnings: [] '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3CreativesForbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/creatives/pause: post: operationId: pauseCreativesV3 summary: Pause every creative that opens a survey description: | Pauses, in one request, every creative whose Destination is `surveyId` and whose `status` is `inProgress`, however many there are. Each one's `status` becomes `paused`, set by its author, so it stays paused whatever the survey does until it is resumed; nothing else about it changes. Drafts, creatives already paused or closed, archived creatives and the creatives of other surveys are left as they are, and so is a creative whose schedule has ended (it shows as closed, and nothing brings it back). The survey screens call it before resuming a survey whose creatives the author keeps paused. Read/write access to `workspaceId`, the same access pausing one creative needs; an unknown `surveyId`, or one in another workspace, answers **403**. Allowed in wind-down, like pausing one creative. A survey with nothing to pause answers **200** with `paused: 0`. Each paused creative is audited as an update of its `status`. tags: - V3 Creatives requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PauseCreativesRequest' examples: pauseSurveyCreatives: summary: Pause the creatives that open a survey value: workspaceId: clws1234567890123456789012 surveyId: clsv1234567890123456789012 responses: '200': description: The survey's In progress creatives are paused. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/PausedCreatives' examples: paused: summary: Two creatives paused value: data: paused: 2 creativeIds: - clcr1234567890123456789012 - clcr2234567890123456789012 '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3CreativesForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/creatives/{creativeId}: get: operationId: getCreativeV3 summary: Get a creative description: Returns one creative, with its derived `displayStatus`, `displayStatusReason`, `warnings` and `destination.opensAs`. Read access to the creative's workspace, which is resolved from the creative, so no `workspaceId` is accepted. An unknown or inaccessible `creativeId` answers **403**, not **404**, so creative ids cannot be probed for existence. tags: - V3 Creatives parameters: - $ref: '#/components/parameters/CreativeIdPath' responses: '200': description: Creative retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/CreativeResource' examples: pausedWithItsSurvey: summary: A feedback button whose survey is paused value: data: id: clcr1234567890123456789012 workspaceId: clws1234567890123456789012 name: Site feedback tab type: feedbackButton status: inProgress displayStatus: paused displayStatusReason: surveyNotActive createdAt: '2026-10-16T09:00:00.000Z' updatedAt: '2026-10-16T09:30:00.000Z' publishedAt: '2026-10-16T09:30:00.000Z' archivedAt: null creator: name: Jane Doe defaultLanguage: en-US languages: - code: en-US default: true enabled: true - code: es-US default: false enabled: true autoSelectLanguage: true content: label: en-US: Feedback es-US: Comentarios icon: null styling: position: desktop: edge: right offset: 60 phone: edge: bottom offset: 90 theme: customStyles: true backgroundColor: light: '#112e51' dark: '#bfdbfe' textColor: light: '#ffffff' dark: '#0f172a' borderColor: null borderRadius: 4 fontWeight: 600 fontSize: desktop: 16 phone: 14 paddingX: desktop: 16 phone: null paddingY: desktop: 8 phone: null destination: type: survey surveyId: clsv1234567890123456789012 surveyName: Site feedback opensAs: popup distribution: showOn: pageView triggers: [] conditions: id: k3m9q2w7e5r1t8y4u6i0o2p5 connector: and conditions: - id: x8c2v6b4n1m7q9w3e5r0t2y6 leftOperand: type: page value: currentUrl operator: startsWith rightOperand: type: static value: https://www.medicare.gov/ displayPercentage: null schedule: startAt: null endAt: null warnings: [] '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3CreativesForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] patch: operationId: patchCreativeV3 summary: Update a creative description: | Changes a creative. Read/write access to its workspace; an unknown or inaccessible `creativeId` answers **403**. Each group sent replaces the stored one as a whole (see `PatchCreativeRequest`); an empty body changes nothing. **Status.** Publish with `status: inProgress`, pause with `paused`, close with `completed`, and reopen or resume with `inProgress`. A creative that was ever published can't go back to `draft`. Any status but `draft` must meet the publish rules of `POST /api/v3/creatives`, checked against the creative as it would be after the change, except for pausing or closing a published creative with a body that changes nothing else: taking it down works even when it no longer meets them (its last action was deleted, or its survey unlinked). In wind-down (the license does not include Creatives), only a body that sets nothing but `status` to `paused` or `completed` (with `expectedUpdatedAt` or without) is accepted; any other body, an empty one included, answers **403** `creatives_not_entitled`. **Custom CSS.** Adding or editing `styling.theme.customCss` follows the rules of `POST /api/v3/creatives`; removing it, or sending it back unchanged, needs no plan and is not processed again. An archived creative is read-only: restore it first, or the request answers **422**. **Concurrent changes.** The change applies to the creative as it is stored when the request runs, so a pause, close or archive made meanwhile is never undone by a request that doesn't mention it. Send `expectedUpdatedAt` (the `updatedAt` you last read) to have the change refused with **409** instead when the creative changed since your read. **Its survey.** Changing or removing `destination` succeeds even when this creative is the last one that opens a live (`inProgress`) app survey with no actions of its own; that survey then shows nowhere until a creative or an action opens it again. Check before relinking. tags: - V3 Creatives parameters: - $ref: '#/components/parameters/CreativeIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PatchCreativeRequest' examples: pause: summary: Pause the creative value: status: paused reschedule: summary: Replace the schedule value: schedule: startAt: '2026-11-01T02:00:00.000Z' endAt: '2026-11-01T04:00:00.000Z' responses: '200': description: Creative updated headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/CreativeResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3CreativesForbidden' '409': description: Conflict — `expectedUpdatedAt` did not match the stored creative, so it changed since your read. Nothing was written. `details` carries `expectedUpdatedAt` and the live `currentUpdatedAt`, so a client can re-read and retry without guessing. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: title: Conflict status: 409 detail: The creative changed since it was last read; updatedAt is now 2026-10-16T09:45:00.000Z. Re-read the creative and retry. code: conflict requestId: 6f1f7f3e-3b0e-4a1a-9f0a-7c9a1d2b3c4d details: expectedUpdatedAt: '2026-10-16T09:30:00.000Z' currentUpdatedAt: '2026-10-16T09:45:00.000Z' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] delete: operationId: deleteCreativeV3 summary: Delete a creative description: Deletes a creative and its Impression and Click counts for good. Survey displays and responses it opened keep the creative they recorded. Read/write access to its workspace; an unknown or inaccessible `creativeId` answers **403**. To set a creative aside and keep its counts, archive it instead. Deleting the last creative that opens a live app survey with no actions of its own succeeds; that survey then shows nowhere until a creative or an action opens it again. tags: - V3 Creatives parameters: - $ref: '#/components/parameters/CreativeIdPath' responses: '204': description: Creative deleted headers: X-Request-Id: schema: type: string description: Request correlation ID '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3CreativesForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/creatives/{creativeId}/archive: post: operationId: archiveCreativeV3 summary: Archive a creative description: 'Sets a creative aside: it never shows, it no longer blocks changes to its survey, it leaves the default list, and it becomes read-only until restored. Its status and counts are kept while it is archived; restoring it brings it back as a draft. Archiving an archived creative changes nothing. Read/write access to its workspace; an unknown or inaccessible `creativeId` answers **403**. Archiving the last creative that opens a live app survey with no actions of its own succeeds; that survey then shows nowhere until a creative or an action opens it again.' tags: - V3 Creatives parameters: - $ref: '#/components/parameters/CreativeIdPath' responses: '200': description: Creative archived headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/CreativeResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3CreativesForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/creatives/{creativeId}/restore: post: operationId: restoreCreativeV3 summary: Restore a creative description: 'Brings an archived creative back, always as a **draft**, whatever status it had: it never reappears on a site by surprise, and showing it again means publishing it again. Its content, styling and counts come back as they were, and so does its Destination, unless its survey can no longer be one (it became Restricted, single-use or PIN-protected), in which case it is unlinked now. `publishedAt` keeps when it was first published. Restoring a creative that isn''t archived changes nothing. Read/write access to its workspace; an unknown or inaccessible `creativeId` answers **403**.' tags: - V3 Creatives parameters: - $ref: '#/components/parameters/CreativeIdPath' responses: '200': description: Creative restored headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/CreativeResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3CreativesForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/creatives/{creativeId}/duplicate: post: operationId: duplicateCreativeV3 summary: Duplicate a creative description: 'Copies a creative into a new **draft** named " (copy)": same type, content, styling, languages, Destination, distribution and schedule, with no counts. A survey Destination that can no longer be one is left out of the copy, which then carries a `destinationMissing` warning. Works on archived creatives too; the copy isn''t archived. Read/write access to the creative''s workspace; an unknown or inaccessible `creativeId` answers **403**.' tags: - V3 Creatives parameters: - $ref: '#/components/parameters/CreativeIdPath' responses: '201': description: Copy created headers: X-Request-Id: schema: type: string description: Request correlation ID Location: schema: type: string description: URL of the copy. Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/CreativeResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3CreativesForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/creatives/{creativeId}/stats: get: operationId: getCreativeStatsV3 summary: Get a creative's counts description: | Returns a creative's Impressions and Clicks per UTC day from `from` to `to` (both inclusive), their totals and Click rate, and, with `groupBy`, the same counts by page or by device type. Read access to the creative's workspace; an unknown or inaccessible `creativeId` answers **403**. Counts are kept per day, not per event. The web SDK reports what visitors see, and a background job adds it here about once a minute, so a count can lag by up to a minute. An instance running without its BullMQ worker never adds counts. Archived creatives keep their counts, deleting a creative deletes them, and a duplicate starts with none. **400** when `from` is after `to`, or the range spans more than 366 days. tags: - V3 Creatives parameters: - $ref: '#/components/parameters/CreativeIdPath' - in: query name: from schema: type: string format: date description: First UTC day, inclusive (`YYYY-MM-DD`). Defaults to 29 days before `to`. - in: query name: to schema: type: string format: date description: Last UTC day, inclusive (`YYYY-MM-DD`). Defaults to today, in UTC. - in: query name: groupBy schema: type: string enum: - page - device description: 'Adds a breakdown: `page` adds `pages` (host + path), `device` adds `devices` (phone, tablet, desktop). Omit for totals and days only.' responses: '200': description: Counts retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/CreativeStats' examples: byPage: summary: Two days by page value: data: creativeId: clcr1234567890123456789012 from: '2026-10-15' to: '2026-10-16' groupBy: page totals: impressions: 1200 clicks: 30 clickRate: 0.025 days: - date: '2026-10-15' impressions: 500 clicks: 10 clickRate: 0.02 - date: '2026-10-16' impressions: 700 clicks: 20 clickRate: 0.02857142857142857 pages: - page: example.com/pricing impressions: 800 clicks: 24 clickRate: 0.03 - page: example.com/ impressions: 390 clicks: 6 clickRate: 0.015384615384615385 - page: (other) impressions: 10 clicks: 0 clickRate: 0 byDevice: summary: One day by device type value: data: creativeId: clcr1234567890123456789012 from: '2026-10-16' to: '2026-10-16' groupBy: device totals: impressions: 700 clicks: 20 clickRate: 0.02857142857142857 days: - date: '2026-10-16' impressions: 700 clicks: 20 clickRate: 0.02857142857142857 devices: - deviceType: phone impressions: 300 clicks: 12 clickRate: 0.04 - deviceType: tablet impressions: 0 clicks: 0 clickRate: null - deviceType: desktop impressions: 400 clicks: 8 clickRate: 0.02 '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3CreativesForbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/action-classes: get: operationId: listActionClassesV3 summary: List action classes description: | Returns the action classes (user actions that can trigger an app survey) for a workspace. Use the returned ids as `distribution.triggers[].actionClassId` when creating or updating app surveys. Read-only: this v3 endpoint lists action classes but does not create them — action classes are managed in the Formbricks app (Actions settings) or via the v1/v2 management API. An empty `data` array means the workspace has no action classes yet. Triggers are optional, so you can still create an app survey with `distribution.triggers: []` (or omit `distribution` entirely) and attach triggers later via `PATCH` once an action class exists. Session cookie, `x-api-key`, or a Bearer token. tags: - V3 Action Classes parameters: - in: query name: workspaceId required: true schema: type: string format: cuid2 description: | Workspace identifier. This is the canonical container ID for v3 APIs. - in: query name: limit schema: type: integer minimum: 1 maximum: 250 default: 50 description: Page size (max 250). - in: query name: cursor schema: type: string description: | Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request. responses: '200': description: Action classes retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/ActionClassResource' meta: $ref: '#/components/schemas/CursorPaginationMeta' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/contact-attribute-keys: get: operationId: listContactAttributeKeysV3 summary: List contact attribute keys description: | Returns the contact attribute keys for a workspace. Use the returned `key` values as `targeting.filters[].root.contactAttributeKey` when targeting app surveys; `dataType` hints which operators apply. Requires the Contacts entitlement (**403** without it). Session cookie, `x-api-key`, or a Bearer token. Optional `search`, `filter[dataType][in]`, `filter[type][in]`, `sortBy` and `includeTotalCount` narrow, order and count the list on the server. Without them the endpoint behaves as before: ordered by `id`, `meta.totalCount` is `null`. The cursor is bound to `sortBy` and the search/filter set; changing any of them while paging → **400** naming `cursor`. Unknown query parameters → **400**. tags: - V3 Contact Attribute Keys parameters: - in: query name: workspaceId required: true schema: type: string format: cuid2 description: | Workspace identifier. This is the canonical container ID for v3 APIs. - in: query name: limit schema: type: integer minimum: 1 maximum: 250 default: 50 description: Page size (max 250). - in: query name: cursor schema: type: string description: | Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request. - in: query name: includeTotalCount schema: type: boolean default: false description: | When `true`, `meta.totalCount` is the number of keys matching the search and filters across all pages; otherwise it is `null`. - in: query name: search schema: type: string maxLength: 512 description: Case-insensitive substring match over `key`, `name` and `description`. - in: query name: filter[dataType][in] schema: type: array items: type: string enum: - string - number - date style: form explode: true description: | Keep only keys with one of these data types. Repeat the parameter or send comma-separated values. Unknown values → **400**. - in: query name: filter[type][in] schema: type: array items: type: string enum: - default - custom style: form explode: true description: | Keep only built-in (`default`) or workspace-defined (`custom`) keys. Repeat or comma-separate. - in: query name: sortBy schema: type: string enum: - createdAt - '-createdAt' - name - updatedAt - '-updatedAt' description: | Sort order. `name` sorts by the display name (falling back to `key`), case-insensitively. A leading `-` sorts newest first. Omit for the original order (by `id`). responses: '200': description: Contact attribute keys retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/ContactAttributeKeyResource' meta: $ref: '#/components/schemas/CountedListMeta' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/contacts: get: operationId: listContactsV3 summary: List contacts description: | Returns a workspace's contacts, filtered, searched, sorted and counted on the server. Requires the Contacts entitlement (**403** without it). Session cookie, `x-api-key`, or a Bearer token. **Attribute filters** use `filter[attribute.][]=`, where `` is a `ContactAttributeKeyResource.key` of the workspace (an unknown key → **400**). The operators depend on the key's `dataType`; an operator the type does not support → **400**: | dataType | operators | | -------- | --------- | | `string` | `in`, `eq`, `ne`, `contains`, `notContains`, `startsWith`, `endsWith`, `isSet`, `isNotSet` | | `number` | `in`, `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `isSet`, `isNotSet` | | `date` | `gte`, `lte` (ISO 8601 date-time with offset, inclusive), `isSet`, `isNotSet` | Text comparisons are case-insensitive. `in` matches any of its values: repeat the key once per value (`filter[attribute.plan][in]=Pro&filter[attribute.plan][in]=Team`). String values are taken verbatim — never split on commas, since a value can contain one; a `number` `in` also accepts comma-separated values. `ne`, `contains`, `notContains`, `startsWith` and `endsWith` may also be repeated, and then every value must hold (AND). Every other operator takes exactly one value (repeating it → **400**). `isSet` / `isNotSet` ignore their value (send `true`). A value is *set* when the attribute row exists and carries a value (non-empty text, or a non-null number/date). `ne` and `notContains` match only contacts whose value is set and differs — use `isNotSet` for contacts without one. All filters combine with AND. At most 50 distinct filter parameters per request and at most 100 values per `in`. The cursor is bound to `sortBy` and to the search and filter set; changing any of them while paging → **400** naming `cursor`. Unknown query parameters → **400**. tags: - V3 Contacts parameters: - in: query name: workspaceId required: true schema: type: string format: cuid2 description: Workspace identifier. - in: query name: limit schema: type: integer minimum: 1 maximum: 250 default: 50 description: Page size (max 250). - in: query name: cursor schema: type: string description: Opaque cursor returned as `meta.nextCursor` from the previous page. - in: query name: includeTotalCount schema: type: boolean default: false description: | Whether to calculate `meta.totalCount`. Send `true` on the first page to get the count; when omitted or `false` the count query is skipped and `meta.totalCount` is `null`. - in: query name: search schema: type: string maxLength: 512 description: | Case-insensitive substring match over any attribute value of the contact, or its id. - in: query name: sortBy schema: type: string enum: - '-createdAt' - createdAt default: '-createdAt' description: '`-createdAt` (newest first, default) or `createdAt` (oldest first).' - in: query name: filter[createdAt][gte] schema: type: string format: date-time description: Only contacts created at or after this instant (ISO 8601 with offset). - in: query name: filter[createdAt][lte] schema: type: string format: date-time description: Only contacts created at or before this instant (ISO 8601 with offset). responses: '200': description: Contacts retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/ContactResource' meta: $ref: '#/components/schemas/CountedListMeta' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/contacts/attribute-values: get: operationId: listContactAttributeValuesV3 summary: List distinct values of a contact attribute description: | Returns the distinct, non-empty values one contact attribute takes in a workspace, in ascending order and bounded by `limit` — for quick-filter value pickers. Not paginated: `meta.truncated` says whether more values match than were returned, so a picker can ask the user to narrow the search. Number and date attributes return their stored text `value`. Requires the Contacts entitlement (**403** without it). An unknown `key` → **400**. Session cookie, `x-api-key`, or a Bearer token. tags: - V3 Contacts parameters: - in: query name: workspaceId required: true schema: type: string format: cuid2 description: Workspace identifier. - in: query name: key required: true schema: type: string minLength: 1 maxLength: 255 description: The attribute key (`ContactAttributeKeyResource.key`). - in: query name: filter[value][contains] schema: type: string maxLength: 512 description: Case-insensitive substring match on the value. - in: query name: limit schema: type: integer minimum: 1 maximum: 100 default: 50 description: Maximum number of values returned (max 100). Not paginated. responses: '200': description: Values retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: meta: type: object required: - limit - truncated properties: limit: type: integer minimum: 1 description: The limit actually applied. truncated: type: boolean description: True when more values match than `limit` returned. additionalProperties: false data: type: array items: type: object required: - value properties: value: type: string additionalProperties: false additionalProperties: false '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/segments: get: operationId: listSegmentsV3 summary: List segments description: | Returns a workspace's segments, searched, sorted and counted on the server. Private segments (survey-local targeting drafts) are never listed. A segment's survey references — including the `surveyIds` of its survey-interaction filters — only name surveys the caller may see. Requires the Contacts entitlement (**403** without it). The cursor is bound to `sortBy` and `filter[title][contains]`; changing either while paging → **400** naming `cursor`. Unknown query parameters → **400**. Session cookie, `x-api-key`, or a Bearer token. tags: - V3 Segments parameters: - in: query name: workspaceId required: true schema: type: string format: cuid2 description: Workspace identifier. - in: query name: limit schema: type: integer minimum: 1 maximum: 250 default: 50 description: Page size (max 250). - in: query name: cursor schema: type: string description: Opaque cursor returned as `meta.nextCursor` from the previous page. - in: query name: includeTotalCount schema: type: boolean default: false description: | Whether to calculate `meta.totalCount`. Send `true` on the first page to get the count; when omitted or `false` the count query is skipped and `meta.totalCount` is `null`. - in: query name: filter[title][contains] schema: type: string maxLength: 512 description: Case-insensitive substring match on the segment title. - in: query name: sortBy schema: type: string enum: - '-updatedAt' - '-createdAt' - title default: '-updatedAt' description: '`-updatedAt` (default), `-createdAt` (both newest first) or `title` (A–Z).' responses: '200': description: Segments retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data - meta properties: data: type: array items: $ref: '#/components/schemas/SegmentResource' meta: $ref: '#/components/schemas/CountedListMeta' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/tags: get: operationId: listTagsV3 x-excluded: true summary: List tags description: | Returns a workspace's response tags, each with the number of responses currently carrying it. Session cookie only. Tags are managed from the Formbricks app (Workspace settings → Tags); these endpoints back that screen, so they do not accept `x-api-key`. tags: - V3 Tags parameters: - in: query name: workspaceId required: true schema: type: string format: cuid2 description: | Workspace identifier. This is the canonical container ID for v3 APIs. responses: '200': description: Tags retrieved successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: type: array items: $ref: '#/components/schemas/TagResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3UnauthorizedSession' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] /api/v3/tags/{tagId}: patch: operationId: renameTagV3 x-excluded: true summary: Rename a tag description: | Renames a tag. The workspace is resolved from the tag itself, so the caller cannot choose which scope it is authorized against. Requires organization owner/manager, or a workspace team membership with read-write permission. An unknown `tagId` answers `403`, not `404`, so tag ids cannot be probed for existence. A name that collides with another tag in the same workspace answers `422`. tags: - V3 Tags parameters: - in: path name: tagId required: true schema: type: string format: cuid2 requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: - name properties: name: type: string minLength: 1 maxLength: 255 description: New label. Trimmed; must not be empty. responses: '200': description: Tag renamed successfully content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/TagResource' '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3UnauthorizedSession' '403': $ref: '#/components/responses/V3Forbidden' '422': $ref: '#/components/responses/V3UnprocessableContent' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] delete: operationId: deleteTagV3 x-excluded: true summary: Delete a tag description: | Deletes a tag and removes it from every response carrying it. Same authorization as rename. tags: - V3 Tags parameters: - in: path name: tagId required: true schema: type: string format: cuid2 responses: '200': description: Tag deleted successfully content: application/json: schema: type: object required: - data properties: data: type: object required: - id properties: id: type: string format: cuid2 description: The deleted tag's id. '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3UnauthorizedSession' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] /api/v3/tags/{tagId}/merge: post: operationId: mergeTagsV3 x-excluded: true summary: Merge a tag into another description: | Moves every response tagged `{tagId}` onto `newTagId`, then deletes `{tagId}`. Both tags must belong to the same workspace — a cross-workspace merge answers `403`, since it would move responses across a tenant boundary. Same authorization as rename. Merging a tag into itself answers `400`. tags: - V3 Tags parameters: - in: path name: tagId required: true schema: type: string format: cuid2 description: The tag being merged away. requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: - newTagId properties: newTagId: type: string format: cuid2 description: The surviving tag. responses: '200': description: Tags merged successfully content: application/json: schema: type: object required: - data properties: data: type: object required: - id properties: id: type: string format: cuid2 description: The surviving tag's id. '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3UnauthorizedSession' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] /api/v3/workspaces/{workspaceId}/custom-css: get: operationId: getWorkspaceCustomCssV3 summary: Read workspace custom CSS description: | Returns the workspace's shared survey CSS as editable **source** (light and dark), the one recoverable previous revision, whether the stored CSS is being delivered to respondents (`status`), and whether this caller may change it (`canEdit`, `planAllowed`). Compiled output is never returned here. Requires read access to the workspace. Session cookie, `x-api-key`, or a Bearer token. tags: - V3 Workspaces parameters: - $ref: '#/components/parameters/WorkspaceIdPath' responses: '200': description: Workspace custom CSS headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/WorkspaceCustomCssResource' example: data: workspaceId: clxx1234567890123456789012 customCss: light: '[data-fb-part="button-primary"] { border-radius: 999px; }' dark: null previous: null status: ok canEdit: true planAllowed: true '400': $ref: '#/components/responses/V3BadRequest' '401': $ref: '#/components/responses/V3Unauthorized' '403': $ref: '#/components/responses/V3Forbidden' '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] patch: operationId: patchWorkspaceCustomCssV3 summary: Save or clear workspace custom CSS description: | Saves or clears the workspace's shared survey CSS. `customCss` replaces both fields; `null` clears all of it. The source is processed by the same processor every save path uses: constructs it removes come back as top-level `warnings`, and CSS it rejects is a **422** that leaves the saved CSS in effect. The replaced value becomes `previous`, the one recoverable revision — restore it by sending its source back. Unchanged source writes nothing. Workspace CSS reaches every survey in the workspace, so writing it is narrower than writing a survey: **organization owners and managers** for signed-in users (session or OAuth), and a **`manage`** grant on this workspace for API keys — a Read & write grant is refused with **403**. On Formbricks Cloud, adding or editing needs the Scale plan (**403** `custom_css_plan_required`); clearing never does. Successful changes refresh the cached configuration the SDKs receive (they pick it up on their next configuration fetch). Saves that add or edit CSS count against a custom CSS budget of 60 per minute per user or API key, shared with custom CSS validation and with survey writes that add or edit custom CSS; beyond it they return **429**. Re-saving the same CSS and clearing it are never counted. tags: - V3 Workspaces parameters: - $ref: '#/components/parameters/WorkspaceIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PatchWorkspaceCustomCssRequest' examples: save: summary: Save light and dark CSS value: customCss: light: '[data-fb-part="button-primary"] { border-radius: 999px; }' dark: '#fbjs[data-appearance="dark"] { --acme-ink: #e6eef5; }' clear: summary: Clear all workspace CSS value: customCss: null responses: '200': description: Workspace custom CSS saved (or unchanged) headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/WorkspaceCustomCssResource' warnings: $ref: '#/components/schemas/CustomCssWarnings' example: data: workspaceId: clxx1234567890123456789012 customCss: light: '[data-fb-part="button-primary"] { border-radius: 999px; }' dark: null previous: null status: ok canEdit: true planAllowed: true warnings: [] '400': description: Bad Request — malformed body, a missing `customCss` key or a missing `light` / `dark` key, an unsupported field (including `compiled` or `processorVersion`), or a field that is neither a string nor null. Every offending field is itemized in `invalid_params`. CSS over the size budget is a **422** `source_too_large`. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/V3Unauthorized' '403': description: 'Forbidden — no access to the workspace (404 not used; avoids existence leak), a principal that may not write workspace CSS (`code: forbidden`), or an addition or edit on an organization without the Scale plan (`code: custom_css_plan_required`).' content: application/problem+json: schema: $ref: '#/components/schemas/Problem' '422': description: Unprocessable Content — the CSS could not be processed (a syntax error, the 100 000-byte workspace budget, a complexity limit). `invalid_params` names `customCss.light` / `customCss.dark`; `details.errors` carries the processor's located errors. Nothing was saved. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: title: Unprocessable Content status: 422 detail: Custom CSS failed validation code: unprocessable_content requestId: req_01j9zq7w3k2m4n5p6r7s8t9v0w invalid_params: - name: customCss.light reason: Unexpected end of input (line 3, column 1) details: errors: - code: syntax_error scope: workspace appearance: light line: 3 column: 1 reason: Unexpected end of input '429': $ref: '#/components/responses/V3TooManyRequests' '500': $ref: '#/components/responses/V3InternalServerError' security: - sessionAuth: [] - apiKeyAuth: [] - bearerAuth: [] /api/v3/feedbackRecords: get: operationId: listFeedbackRecordsV3 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. tags: - V3 Feedback Records 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: [] post: operationId: createFeedbackRecordV3 summary: Create a feedback record description: | Stores one feedback record in the dataset named by `tenant_id` in the body. The gateway reads `tenant_id` from the JSON body to authorize the call — a body that is not JSON, or has no `tenant_id`, answers **400** before anything is stored. Requires an API key with **write** permission (or higher) on a workspace the dataset is assigned to. Creating is allowed on shared datasets (it is ordinary workspace work, like a CSV import); only changing and deleting existing records is restricted to unshared ones. Ingestion is idempotent per (`tenant_id`, `submission_id`, `field_id`): a second record with the same triple answers **409** rather than creating a duplicate. Send exactly one `value_*` member, matching `field_type`. A `text` record is enriched asynchronously — `sentiment`, `emotions` and the translation members appear on the record once enrichment completes, and it becomes searchable once embedded. tags: - V3 Feedback Records requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateFeedbackRecordRequest' examples: rating: summary: A rating answer value: tenant_id: ph7zv3w2u1x5k9d8m4q6c0bn 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 text: summary: A free-text answer (enriched and embedded) value: tenant_id: ph7zv3w2u1x5k9d8m4q6c0bn source_type: call_notes source_id: crm-account-4471 source_name: Quarterly review call submission_id: call-2026-08-15-4471 field_id: verbatim field_label: Customer verbatim field_type: text value_text: The new reporting module saved us a full day each week. user_id: contact-9a1f language: en metadata: channel: call plan: enterprise categorical: summary: A choice answer with a stable option id value: tenant_id: ph7zv3w2u1x5k9d8m4q6c0bn source_type: survey source_id: cm2k7q9x00003v8h1a2b3c4d5 submission_id: cm2k7qa1z0007v8h1e6f7g8h9 field_id: q3 field_label: Which stand were you in? field_type: categorical value_text: North stand value_id: opt_north user_id: fan-8813 responses: '201': description: Feedback record created content: application/json: schema: $ref: '#/components/schemas/FeedbackRecord' examples: created: summary: The stored record value: 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 '400': $ref: '#/components/responses/FeedbackRecordsBadRequest' '401': $ref: '#/components/responses/FeedbackRecordsUnauthorized' '403': $ref: '#/components/responses/FeedbackRecordsForbidden' '409': $ref: '#/components/responses/FeedbackRecordsConflict' '413': $ref: '#/components/responses/FeedbackRecordsPayloadTooLarge' '500': $ref: '#/components/responses/FeedbackRecordsInternalServerError' '503': $ref: '#/components/responses/FeedbackRecordsServiceUnavailable' security: - apiKeyAuth: [] delete: operationId: deleteFeedbackRecordsByUserV3 summary: Delete a user's feedback records description: | Permanently deletes every record in the dataset whose `user_id` matches — the erasure operation for GDPR Article 17 requests. Derived embeddings are removed with the records. Idempotent: repeating the call answers `deleted_count: 0`. `tenant_id` is **required**: the gateway scopes erasure to one dataset. (The underlying store can erase a user across every dataset when `tenant_id` is omitted; that form is not reachable through this API.) Records written without a `user_id` cannot be reached this way — delete them individually. Deletion is unrecoverable, so it requires an API key with **manage** permission on a workspace the dataset is assigned to, and the dataset must be assigned to **exactly one** workspace: on a shared dataset no workspace permission can say whose records these are, so API-key mutations are refused with **403**. tags: - V3 Feedback Records parameters: - in: query name: user_id required: true schema: type: string minLength: 1 maxLength: 255 description: The end-user identifier whose records to delete. - $ref: '#/components/parameters/FeedbackRecordsTenantIdQuery' responses: '200': description: Records deleted content: application/json: schema: $ref: '#/components/schemas/DeleteFeedbackRecordsByUserResult' examples: deleted: summary: A user with records value: deleted_count: 42 message: Successfully deleted 42 feedback records nothingLeft: summary: Repeated call value: deleted_count: 0 message: Successfully deleted 0 feedback records '400': $ref: '#/components/responses/FeedbackRecordsBadRequest' '401': $ref: '#/components/responses/FeedbackRecordsUnauthorized' '403': $ref: '#/components/responses/FeedbackRecordsForbidden' '409': $ref: '#/components/responses/FeedbackRecordsWriteConflict' '500': $ref: '#/components/responses/FeedbackRecordsInternalServerError' '503': $ref: '#/components/responses/FeedbackRecordsServiceUnavailable' security: - apiKeyAuth: [] /api/v3/feedbackRecords/search/semantic: post: operationId: searchFeedbackRecordsV3 summary: Search feedback records semantically description: | Embeds `query` and returns the ids of the records in the dataset whose text is closest to it, with cosine similarity scores. Only records with a non-empty `value_text` are embedded, so only those can match. The gateway reads `tenant_id` from the JSON body to authorize the call. Requires an API key with **read** permission (or higher) on a workspace the dataset is assigned to. Results carry ids and the embedded text only — fetch a match with `GET /api/v3/feedbackRecords/{id}` for the full record. Answers **503** on instances without an embedding model configured. tags: - V3 Feedback Records parameters: - $ref: '#/components/parameters/SimilarityLimitQuery' - $ref: '#/components/parameters/SimilarityCursorQuery' - $ref: '#/components/parameters/MinScoreQuery' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/FeedbackRecordSemanticSearchRequest' example: query: complaints about waiting times at the gates tenant_id: ph7zv3w2u1x5k9d8m4q6c0bn responses: '200': description: Matches retrieved content: application/json: schema: $ref: '#/components/schemas/FeedbackRecordSimilarityResult' examples: matches: summary: Two matches above the threshold value: data: - feedback_record_id: 018e1234-5678-7abc-8ef0-123456789abd score: 0.71 field_label: What could we improve? value_text: Queues at the north gate were far too long. - feedback_record_id: 018e1234-5678-7abc-8ef0-1234567890aa score: 0.64 field_label: Anything else? value_text: Took 40 minutes to get in. Please open more turnstiles. limit: 10 '400': $ref: '#/components/responses/FeedbackRecordsBadRequest' '401': $ref: '#/components/responses/FeedbackRecordsUnauthorized' '403': $ref: '#/components/responses/FeedbackRecordsForbidden' '413': $ref: '#/components/responses/FeedbackRecordsPayloadTooLarge' '500': $ref: '#/components/responses/FeedbackRecordsInternalServerError' '503': $ref: '#/components/responses/FeedbackRecordsServiceUnavailable' security: - apiKeyAuth: [] /api/v3/feedbackRecords/{id}: get: operationId: getFeedbackRecordV3 summary: Get a feedback record description: | Returns one feedback record by id, with its read-only assignment in the active directory taxonomy. The assignment reflects the current visible path in the active run; editing a record does not reclassify it until another run. `taxonomy` is null if its lookup is unavailable. The gateway looks the record up to learn its dataset and authorizes against that, so no `tenant_id` is sent. Requires an API key with **read** permission (or higher) on a workspace the record's dataset is assigned to. An unknown id, or one in a dataset you cannot read, answers **403** rather than 404, so ids cannot be probed. The exception is the minute after a delete: the gateway still has the record's dataset cached, authorizes the call, and the store answers **404**. tags: - V3 Feedback Records parameters: - $ref: '#/components/parameters/FeedbackRecordIdPath' responses: '200': description: Feedback record retrieved content: application/json: schema: $ref: '#/components/schemas/FeedbackRecordDetail' examples: enriched: summary: A text record after enrichment value: 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 value_text_translated: Die Warteschlangen am Nordtor waren viel zu lang. translation_lang_key: de taxonomy: status: classified run_id: 018e1234-5678-7abc-8ef0-123456789abe path: - id: 018e1234-5678-7abc-8ef0-123456789abf label: Queueing level: 1 node_type: branch - id: 018e1234-5678-7abc-8ef0-123456789ac0 label: North gate level: 2 node_type: leaf '400': $ref: '#/components/responses/FeedbackRecordsBadRequest' '401': $ref: '#/components/responses/FeedbackRecordsUnauthorized' '403': $ref: '#/components/responses/FeedbackRecordsForbidden' '404': $ref: '#/components/responses/FeedbackRecordsRecordNotFound' '500': $ref: '#/components/responses/FeedbackRecordsInternalServerError' '503': $ref: '#/components/responses/FeedbackRecordsServiceUnavailable' security: - apiKeyAuth: [] patch: operationId: updateFeedbackRecordV3 summary: Update a feedback record description: | Changes the answer, `language`, `user_id` or `metadata` of one record. Only the members sent are changed; provenance (`tenant_id`, `source_*`, `field_*`, `submission_id`, `collected_at`) is immutable. Requires an API key with **write** permission (or higher) on a workspace the record's dataset is assigned to, and the dataset must be assigned to **exactly one** workspace — see the delete operation for why. Two behaviours are silent and worth knowing: `metadata` is **replaced**, not merged; and changing `value_text` clears the enrichment members (`sentiment`, `sentiment_score`, `emotions`, `value_text_translated`, `translation_lang_key`) and queues re-enrichment and re-embedding — the response shows them absent until that completes. Changing `language` clears and re-queues the translation pair only. tags: - V3 Feedback Records parameters: - $ref: '#/components/parameters/FeedbackRecordIdPath' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdateFeedbackRecordRequest' examples: correctText: summary: Correct a text answer value: value_text: Queues at the north gate were far too long on match day. replaceMetadata: summary: Replace the metadata object value: metadata: channel: survey stand: north priority: high responses: '200': description: Feedback record updated content: application/json: schema: $ref: '#/components/schemas/FeedbackRecord' examples: updated: summary: After correcting the text — enrichment members are cleared pending re-enrichment value: 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-16T09:02:11Z' 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 on match day. user_id: fan-8813 language: en '400': $ref: '#/components/responses/FeedbackRecordsBadRequest' '401': $ref: '#/components/responses/FeedbackRecordsUnauthorized' '403': $ref: '#/components/responses/FeedbackRecordsForbidden' '404': $ref: '#/components/responses/FeedbackRecordsRecordNotFound' '409': $ref: '#/components/responses/FeedbackRecordsWriteConflict' '413': $ref: '#/components/responses/FeedbackRecordsPayloadTooLarge' '500': $ref: '#/components/responses/FeedbackRecordsInternalServerError' '503': $ref: '#/components/responses/FeedbackRecordsServiceUnavailable' security: - apiKeyAuth: [] delete: operationId: deleteFeedbackRecordV3 summary: Delete a feedback record description: | Permanently deletes one record and its derived embedding. Unrecoverable, so it requires an API key with **manage** permission on a workspace the record's dataset is assigned to, and the dataset must be assigned to **exactly one** workspace: on a shared dataset no workspace permission can say whose record this is, so API-key mutations are refused with **403**. tags: - V3 Feedback Records parameters: - $ref: '#/components/parameters/FeedbackRecordIdPath' responses: '204': description: Feedback record deleted '400': $ref: '#/components/responses/FeedbackRecordsBadRequest' '401': $ref: '#/components/responses/FeedbackRecordsUnauthorized' '403': $ref: '#/components/responses/FeedbackRecordsForbidden' '404': $ref: '#/components/responses/FeedbackRecordsRecordNotFound' '409': $ref: '#/components/responses/FeedbackRecordsWriteConflict' '500': $ref: '#/components/responses/FeedbackRecordsInternalServerError' '503': $ref: '#/components/responses/FeedbackRecordsServiceUnavailable' security: - apiKeyAuth: [] /api/v3/feedbackRecords/{id}/similar: get: operationId: getSimilarFeedbackRecordsV3 summary: Find similar feedback records description: | Returns the records in the same dataset whose embedded text is closest to the given record's, with cosine similarity scores. The anchor record itself is excluded. The gateway looks the record up to learn its dataset and authorizes against that. Requires an API key with **read** permission (or higher) on a workspace the record's dataset is assigned to. Answers **403** for an unknown or inaccessible id. Answers **404** when the record exists but has no embedding — it has no text, or was created moments ago and is not embedded yet. Answers **503** on instances without an embedding model configured. tags: - V3 Feedback Records parameters: - $ref: '#/components/parameters/FeedbackRecordIdPath' - $ref: '#/components/parameters/SimilarityLimitQuery' - $ref: '#/components/parameters/SimilarityCursorQuery' - $ref: '#/components/parameters/MinScoreQuery' responses: '200': description: Similar records retrieved content: application/json: schema: $ref: '#/components/schemas/FeedbackRecordSimilarityResult' examples: matches: summary: One neighbour above the threshold value: data: - feedback_record_id: 018e1234-5678-7abc-8ef0-1234567890aa score: 0.83 field_label: Anything else? value_text: Took 40 minutes to get in. Please open more turnstiles. limit: 10 '400': $ref: '#/components/responses/FeedbackRecordsBadRequest' '401': $ref: '#/components/responses/FeedbackRecordsUnauthorized' '403': $ref: '#/components/responses/FeedbackRecordsForbidden' '404': $ref: '#/components/responses/FeedbackRecordsNotFound' '500': $ref: '#/components/responses/FeedbackRecordsInternalServerError' '503': $ref: '#/components/responses/FeedbackRecordsServiceUnavailable' security: - apiKeyAuth: [] components: securitySchemes: sessionAuth: type: apiKey in: cookie name: next-auth.session-token description: | NextAuth session JWT cookie. **Development:** often `next-auth.session-token`. **Production (HTTPS):** often `__Secure-next-auth.session-token`. Send the cookie your browser receives after sign-in. 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. bearerAuth: type: http scheme: bearer description: | The same management API key, presented as `Authorization: Bearer ` instead of `x-api-key`. Equivalent in every way — same key, same workspace permissions — and offered because it is what most HTTP clients and SDK generators reach for by default. A **401** from any operation that accepts it carries `WWW-Authenticate: Bearer realm="formbricks"`. Session-only operations accept neither this nor `x-api-key`. schemas: SurveyVisibility: type: string enum: - restricted - workspace description: '`restricted`: readable by the owner and the organisation''s owners and managers only. The owner''s access is conditional on their workspace access, level for level: they read the survey only while they can read the workspace, and edit it only while they can write to it — being the owner grants nothing the workspace does not. `workspace`: readable by everyone with access to the workspace — today''s behaviour.' SurveyOwner: type: object description: 'The authorization owner — the person whose restricted surveys these are. Distinct from `creator`, which is attribution only and is never rewritten. Display only, like `creator`: no user id is exposed, because `access.via === "owner"` already answers "is this mine" and `filter[owner][in]=me` filters server-side. Ownership transfer can add an identifier later, additively. Durable source: `Survey.ownerId` in PostgreSQL, projected to `survey#owner@user`; the graph never holds ownership alone, so a rebuild from an empty graph restores it.' required: - name properties: name: type: string additionalProperties: false SurveyAccess: type: object description: Why **this caller** can see the survey, and whether they may change its visibility. Derived by the server per request; a rendering hint only — every mutation is re-authorised on the server. required: - via - canManageVisibility properties: via: type: string description: 'Why this caller can see the survey. Known values today: `workspace` (the survey is workspace-visible and the caller has workspace read — always the value for API keys), `owner` (restricted, the caller owns it), `organizationRole` (restricted, the caller reaches it only as an organisation owner or manager — the case that shows the admin banner). **Open set, deliberately not an enum**: named person and team sharing will add values, so clients treat an unknown string as "some other access path" and never branch exhaustively. Explanatory metadata, not authorization truth — the server re-authorises every call.' examples: - workspace - owner - organizationRole canManageVisibility: type: boolean description: 'Permission only: `true` when this caller holds `survey.change_visibility` — owner or organisation owner/manager, feature enabled for the organisation and deployment. It can be `true` while `allowedTargets` is empty (blockers, or no owner): it answers "may this person manage visibility at all", which is what shows or hides the controls; `allowedTargets` on `GET …/visibility` answers which transitions would succeed now. Stays `true` for an organisation manager on an ownerless restricted survey.' additionalProperties: false SurveyVisibilityFields: type: object description: The three fields every `SurveyListItem` and `SurveyResource` carries, merged into both via `allOf`. All required, always present. required: - visibility - owner - access properties: visibility: allOf: - $ref: '#/components/schemas/SurveyVisibility' description: The **enforced** visibility on this request. `restricted` whenever a change is pending, in either direction (fail closed). `workspace` for every survey while the deployment's readiness marker is unset, whatever the stored flag, and always `workspace` for API keys. owner: anyOf: - $ref: '#/components/schemas/SurveyOwner' - type: 'null' description: '`null` when the author''s account no longer exists (`Survey.ownerId` is set null on user deletion) or the survey was created with an API key. A survey with `owner: null` cannot be restricted; an organisation owner/manager can still make it workspace-visible.' access: $ref: '#/components/schemas/SurveyAccess' SurveyListItem: allOf: - type: object description: | Shape returned by `GET /api/v3/surveys`. Serialized dates are ISO 8601 strings. The v3 overview contract intentionally omits internal fields such as `_count`. Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`. required: - id - name - workspaceId - type - status - publishOn - createdAt - updatedAt - archivedAt - responseCount - completedResponseCount - creator properties: id: type: string name: type: string workspaceId: type: string type: type: string enum: - link - app - website - web status: type: string enum: - draft - inProgress - paused - completed createdAt: type: string format: date-time updatedAt: type: string format: date-time archivedAt: type: - string - 'null' format: date-time description: Soft-delete/archive marker (ISO 8601); `null` when the survey is active. publishOn: type: - string - 'null' format: date-time description: Scheduled publish time (ISO 8601), or null if not scheduled. responseCount: type: integer description: Number of responses, including partial ones. completedResponseCount: type: integer description: Number of responses the respondent finished. creator: type: - object - 'null' description: The user who created the survey, or null for API-key/system-created surveys. required: - name properties: name: type: string additionalProperties: false - $ref: '#/components/schemas/SurveyVisibilityFields' ListPaginationMeta: type: object description: |- The pagination half of a v3 list's `meta`, shared by every paginated collection. Deliberately open, and never an endpoint's `meta` by itself: an endpoint composes it with `allOf` and closes the result with `unevaluatedProperties: false`. A base that closed itself could not be extended — inside an `allOf`, `additionalProperties: false` sees a sibling branch's properties as additional and rejects them. An endpoint that adds nothing still wraps it, so no `meta` anywhere is open. Every field here is `required`. A shared envelope that makes its fields optional is how v3's predecessor stopped honouring its own pagination contract without the schema gate noticing (ENG-2622): `meta: {}` — or no `meta` at all — validated cleanly while the endpoint returned nothing. required: - limit - nextCursor properties: limit: type: integer minimum: 1 description: 'The page size actually applied. Carries no `maximum`: it echoes what the server used, it does not bound what may be asked for. The request-side bound is the `limit` query parameter on the operation itself and differs per collection — read it there. A shared description cannot state it without being wrong for some of the operations that compose this base.' nextCursor: type: - string - 'null' description: 'Opaque cursor for the next page. `null` when there are no more results — the only reliable end-of-collection signal, since the server may return fewer items than `limit` without the collection being exhausted. Pass it back verbatim: the encoding is deliberately unspecified and may change without notice, so a client that decodes or constructs one is relying on something this contract does not promise.' SurveyListMeta: allOf: - $ref: '#/components/schemas/ListPaginationMeta' - type: object required: - totalCount - workspaceSurveyCount properties: totalCount: type: - integer - 'null' minimum: 0 description: Total number of surveys matching the current filters across all pages. `null` when `includeTotalCount=false`. workspaceSurveyCount: type: - integer - 'null' minimum: 0 description: Total number of surveys in the workspace, archived (soft-deleted) ones included and independent of the current filters. `null` when `includeTotalCount=false` (the same gate as `totalCount`). Replaces the boolean `hasArchived` field of earlier releases. unevaluatedProperties: false description: The list `meta` for surveys — pagination, the filtered total, and the workspace total. Both counting fields are `required` but nullable, gated on `includeTotalCount`. InvalidParam: type: object description: | Field-level validation error. `name` and `reason` are always intended for humans. Optional machine-readable fields are included when the API can identify the semantic error, especially for survey locale, identifier, and reference validation used by MCP and agent clients. required: - name - reason properties: name: type: string description: Dot-path to the invalid request field. reason: type: string description: Human-readable repair guidance. code: type: string enum: - dangling_reference - duplicate_identifier - duplicate_locale - forbidden_identifier - immutable_identifier - invalid_locale - invalid_reference - limit_exceeded - missing_required_field - misordered_reference - missing_translation - qsf_limit_exceeded - qsf_not_recognized - read_only_field - unsupported_field - unsupported_locale description: | Machine-readable validation category, when available. This is the shared error shape every v3 operation returns, so it lists what the API can emit today and nothing more — `INVALID_PARAM_CODES` in `app/api/v3/lib/response.ts` is the registry, and a code absent from it is dropped before the response is built. Categories for operations that do not exist yet are added with the code that emits them, not ahead of it. `limit_exceeded`: the field is well formed but past a limit the operation states, which `reason` names together with what to change. Chart queries use it for a date range that spans more time buckets than its granularity allows. `qsf_not_recognized` and `qsf_limit_exceeded` come from the Qualtrics import (an internal, session-only route sharing this shape): the file is not a Qualtrics export it can read, or it is one past an import limit, which `identifier` names. identifier: type: string description: Identifier involved in the validation failure, when available. For language errors, this can be the normalized language code. referenceType: type: string enum: - block - element - ending - hiddenField - language - variable - variableName - recall description: 'Identifier namespace involved in the validation failure. Same rule as `code`: these are the namespaces the API emits today, and more arrive with the operations that emit them.' missingId: type: string description: Referenced id that does not exist in the submitted survey document. firstUsedAt: type: string description: Dot-path where a duplicate identifier was first used. conflictsWith: type: string description: Dot-path to the existing identifier that conflicts with this field. additionalProperties: false Problem: type: object description: |- RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`. Branch on `code`, not on `detail` or `title`: `code` is stable and locale-independent, whereas `detail` is prose that may be reworded. No problem sets `type`, so per RFC 9457 §4.2.1 `title` is always the HTTP reason phrase for `status` and carries no information of its own. Member naming is frozen as published, mixed conventions included (`requestId` alongside `invalid_params` and `details.resource_type`): renaming any of them would break every client that reads them. required: - title - status - detail - requestId properties: type: type: string format: uri title: type: string status: type: integer detail: type: string instance: type: string code: type: string enum: - ai_features_not_enabled - ai_generated_payload_invalid - ai_instance_not_configured - ai_output_too_long - ai_provider_auth_failed - ai_smart_tools_disabled - bad_gateway - bad_request - chart_in_use - conflict - creatives_not_entitled - custom_css_plan_required - dashboards_plan_required - forbidden - internal_server_error - invalid_workflow_state - not_authenticated - not_found - payload_too_large - projection_pending - service_unavailable - stored_survey_invalid - survey_has_linked_creatives - survey_not_workspace_visible - too_many_requests - unprocessable_content - visibility_blocked_by_connections - visibility_change_not_allowed - visibility_not_enabled - workflow_not_executable - workspace_survey_limit_reached requestId: type: string details: type: object invalid_params: type: array items: $ref: '#/components/schemas/InvalidParam' TranslatableTextMap: type: object description: Multilingual text map keyed by the emitted `languages[].code` values for this survey. propertyNames: type: string description: Survey language code/tag, for example `en-US`, `de-DE`, or `zh-Hans-CN`. additionalProperties: type: string TranslatableText: allOf: - $ref: '#/components/schemas/TranslatableTextMap' description: | Survey authoring text. `GET /api/v3/surveys/{surveyId}` always returns maps keyed by the emitted `languages[].code` values for this survey. Use `?lang=` to filter which language keys are included. The internal storage key `default` is never exposed by v3. examples: - en-US: What should we improve? de-DE: Was sollten wir verbessern? SurveyMetadata: type: object description: | Arbitrary JSON survey context for customer- or operation-specific metadata. v3 preserves arbitrary metadata values as-is. If present, `title` and `description` are treated as translatable text maps and returned with real locale-code keys. properties: title: $ref: '#/components/schemas/TranslatableText' description: $ref: '#/components/schemas/TranslatableText' additionalProperties: true LocaleCode: type: string pattern: ^[a-z]{2}(?:-[A-Z][a-z]{3})?-[A-Z]{2}$ description: Canonical locale code accepted by v3 survey APIs, for example `en-US`, `de-DE`, or `zh-Hans-CN`. example: en-US CreateSurveyLanguage: type: object description: Language to connect to the created survey. `default`, if provided, must match `defaultLanguage`. required: - code properties: code: $ref: '#/components/schemas/LocaleCode' default: type: boolean description: Optional marker for readability; only the `defaultLanguage` entry may set this to true. enabled: type: boolean default: true description: Whether this language is enabled for respondent-facing delivery. additionalProperties: false SurveyWelcomeCard: type: object description: Optional card shown before the first survey block. required: - enabled properties: enabled: type: boolean headline: $ref: '#/components/schemas/TranslatableText' subheader: $ref: '#/components/schemas/TranslatableText' buttonLabel: $ref: '#/components/schemas/TranslatableText' fileUrl: type: string videoUrl: type: string timeToFinish: type: boolean showResponseCount: type: boolean additionalProperties: false SurveyElementBase: type: object required: - id - type - headline - required properties: id: type: string pattern: ^[a-zA-Z0-9_-]+$ description: Stable element id. Avoid spaces and reserved ids. type: type: string enum: - openText - multipleChoiceSingle - multipleChoiceMulti - nps - rating - csat - ces - consent - pictureSelection - cta - date - fileUpload - cal - matrix - address - ranking - contactInfo headline: $ref: '#/components/schemas/TranslatableText' subheader: $ref: '#/components/schemas/TranslatableText' required: type: boolean imageUrl: type: string imageAltText: allOf: - $ref: '#/components/schemas/TranslatableText' description: Text alternative for `imageUrl`, read by screen readers. Omit or leave empty for a decorative image (rendered as `alt=""`). videoUrl: type: string isDraft: type: boolean description: Draft marker used by the editor and future update rules. SurveyCharLimit: type: object description: Optional `openText` character limit configuration. properties: enabled: type: boolean default: false min: type: number max: type: number additionalProperties: false SurveyValidationRule: type: object required: - id - type - params properties: id: type: string type: type: string enum: - minLength - maxLength - pattern - email - url - phone - equals - doesNotEqual - contains - doesNotContain - minValue - maxValue - isGreaterThan - isLessThan - minSelections - maxSelections - minRanked - rankAll - minRowsAnswered - answerAllRows - isLaterThan - isEarlierThan - isBetween - isNotBetween - fileExtensionIs - fileExtensionIsNot params: type: object additionalProperties: true field: type: string enum: - addressLine1 - addressLine2 - city - state - zip - country - firstName - lastName - email - phone - company additionalProperties: false SurveyValidation: type: object description: Optional element-level validation rules. required: - rules properties: logic: type: string enum: - and - or default: and rules: type: array items: $ref: '#/components/schemas/SurveyValidationRule' additionalProperties: false SurveyOpenTextElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object properties: type: type: string enum: - openText placeholder: $ref: '#/components/schemas/TranslatableText' longAnswer: type: boolean inputType: type: string enum: - text - email - url - number - phone default: text insightsEnabled: type: boolean default: false charLimit: $ref: '#/components/schemas/SurveyCharLimit' validation: $ref: '#/components/schemas/SurveyValidation' unevaluatedProperties: false SurveyConsentElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - label properties: type: type: string enum: - consent label: $ref: '#/components/schemas/TranslatableText' validation: $ref: '#/components/schemas/SurveyValidation' unevaluatedProperties: false SurveyChoice: type: object required: - id - label properties: id: type: string description: Stable choice id. label: $ref: '#/components/schemas/TranslatableText' additionalProperties: false SurveyShuffleOption: type: string enum: - none - all - exceptLast - reverseOrderOccasionally - reverseOrderExceptLast SurveyMultipleChoiceDisplayType: type: string enum: - list - row - dropdown SurveyMultipleChoiceSingleElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - choices properties: type: type: string enum: - multipleChoiceSingle choices: type: array minItems: 2 items: $ref: '#/components/schemas/SurveyChoice' shuffleOption: $ref: '#/components/schemas/SurveyShuffleOption' otherOptionPlaceholder: $ref: '#/components/schemas/TranslatableText' displayType: $ref: '#/components/schemas/SurveyMultipleChoiceDisplayType' unevaluatedProperties: false SurveyMultipleChoiceMultiElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - choices properties: type: type: string enum: - multipleChoiceMulti choices: type: array minItems: 2 items: $ref: '#/components/schemas/SurveyChoice' shuffleOption: $ref: '#/components/schemas/SurveyShuffleOption' otherOptionPlaceholder: $ref: '#/components/schemas/TranslatableText' validation: $ref: '#/components/schemas/SurveyValidation' displayType: $ref: '#/components/schemas/SurveyMultipleChoiceDisplayType' unevaluatedProperties: false SurveyNpsElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object properties: type: type: string enum: - nps lowerLabel: $ref: '#/components/schemas/TranslatableText' upperLabel: $ref: '#/components/schemas/TranslatableText' isColorCodingEnabled: type: boolean default: false unevaluatedProperties: false SurveyCtaElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object description: If `buttonExternal` is true, `buttonUrl` and `ctaButtonLabel` are required. properties: type: type: string enum: - cta buttonExternal: type: boolean default: false buttonUrl: type: string ctaButtonLabel: $ref: '#/components/schemas/TranslatableText' - if: required: - buttonExternal properties: buttonExternal: const: true then: required: - buttonUrl - ctaButtonLabel unevaluatedProperties: false SurveyRatingElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - scale - range properties: type: type: string enum: - rating scale: type: string enum: - number - smiley - star range: type: integer enum: - 3 - 4 - 5 - 6 - 7 - 10 lowerLabel: $ref: '#/components/schemas/TranslatableText' upperLabel: $ref: '#/components/schemas/TranslatableText' isColorCodingEnabled: type: boolean default: false unevaluatedProperties: false SurveyPictureChoice: type: object required: - id - imageUrl properties: id: type: string description: Stable picture choice id. imageUrl: type: string imageAltText: allOf: - $ref: '#/components/schemas/TranslatableText' description: Text alternative for `imageUrl`, read by screen readers. Omit or leave empty to fall back to the positional label ("Option 1", "Option 2", …); a choice image is never decorative, since the respondent selects it. additionalProperties: false SurveyPictureSelectionElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - choices properties: type: type: string enum: - pictureSelection allowMulti: type: boolean default: false choices: type: array minItems: 2 items: $ref: '#/components/schemas/SurveyPictureChoice' validation: $ref: '#/components/schemas/SurveyValidation' unevaluatedProperties: false SurveyDateElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - format properties: type: type: string enum: - date html: $ref: '#/components/schemas/TranslatableText' format: type: string enum: - M-d-y - d-M-y - y-M-d validation: $ref: '#/components/schemas/SurveyValidation' unevaluatedProperties: false SurveyFileUploadElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - allowMultipleFiles properties: type: type: string enum: - fileUpload allowMultipleFiles: type: boolean maxSizeInMB: type: number allowedFileExtensions: type: array items: type: string validation: $ref: '#/components/schemas/SurveyValidation' unevaluatedProperties: false SurveyCalElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - calUserName properties: type: type: string enum: - cal calUserName: type: string minLength: 1 calHost: type: string unevaluatedProperties: false SurveyMatrixElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - rows - columns properties: type: type: string enum: - matrix rows: type: array items: $ref: '#/components/schemas/SurveyChoice' columns: type: array items: $ref: '#/components/schemas/SurveyChoice' shuffleOption: $ref: '#/components/schemas/SurveyShuffleOption' validation: $ref: '#/components/schemas/SurveyValidation' unevaluatedProperties: false SurveyToggleInputConfig: type: object description: Field config for address and contact info elements. required: - show - required - placeholder properties: show: type: boolean required: type: boolean placeholder: $ref: '#/components/schemas/TranslatableText' additionalProperties: false SurveyAddressElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - addressLine1 - addressLine2 - city - state - zip - country properties: type: type: string enum: - address addressLine1: $ref: '#/components/schemas/SurveyToggleInputConfig' addressLine2: $ref: '#/components/schemas/SurveyToggleInputConfig' city: $ref: '#/components/schemas/SurveyToggleInputConfig' state: $ref: '#/components/schemas/SurveyToggleInputConfig' zip: $ref: '#/components/schemas/SurveyToggleInputConfig' country: $ref: '#/components/schemas/SurveyToggleInputConfig' validation: $ref: '#/components/schemas/SurveyValidation' unevaluatedProperties: false SurveyRankingElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - choices properties: type: type: string enum: - ranking choices: type: array minItems: 2 maxItems: 25 items: $ref: '#/components/schemas/SurveyChoice' otherOptionPlaceholder: $ref: '#/components/schemas/TranslatableText' shuffleOption: $ref: '#/components/schemas/SurveyShuffleOption' validation: $ref: '#/components/schemas/SurveyValidation' unevaluatedProperties: false SurveyContactInfoElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - firstName - lastName - email - phone - company properties: type: type: string enum: - contactInfo firstName: $ref: '#/components/schemas/SurveyToggleInputConfig' lastName: $ref: '#/components/schemas/SurveyToggleInputConfig' email: $ref: '#/components/schemas/SurveyToggleInputConfig' phone: $ref: '#/components/schemas/SurveyToggleInputConfig' company: $ref: '#/components/schemas/SurveyToggleInputConfig' validation: $ref: '#/components/schemas/SurveyValidation' unevaluatedProperties: false SurveyCsatElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - scale - range properties: type: type: string enum: - csat scale: type: string enum: - number - smiley - star range: type: integer enum: - 5 lowerLabel: $ref: '#/components/schemas/TranslatableText' upperLabel: $ref: '#/components/schemas/TranslatableText' isColorCodingEnabled: type: boolean default: false unevaluatedProperties: false SurveyCesElement: allOf: - $ref: '#/components/schemas/SurveyElementBase' - type: object required: - scale - range properties: type: type: string enum: - ces scale: type: string enum: - number - smiley - star range: type: integer enum: - 5 - 7 lowerLabel: $ref: '#/components/schemas/TranslatableText' upperLabel: $ref: '#/components/schemas/TranslatableText' isColorCodingEnabled: type: boolean default: false unevaluatedProperties: false SurveyElement: oneOf: - $ref: '#/components/schemas/SurveyOpenTextElement' - $ref: '#/components/schemas/SurveyConsentElement' - $ref: '#/components/schemas/SurveyMultipleChoiceSingleElement' - $ref: '#/components/schemas/SurveyMultipleChoiceMultiElement' - $ref: '#/components/schemas/SurveyNpsElement' - $ref: '#/components/schemas/SurveyCtaElement' - $ref: '#/components/schemas/SurveyRatingElement' - $ref: '#/components/schemas/SurveyPictureSelectionElement' - $ref: '#/components/schemas/SurveyDateElement' - $ref: '#/components/schemas/SurveyFileUploadElement' - $ref: '#/components/schemas/SurveyCalElement' - $ref: '#/components/schemas/SurveyMatrixElement' - $ref: '#/components/schemas/SurveyAddressElement' - $ref: '#/components/schemas/SurveyRankingElement' - $ref: '#/components/schemas/SurveyContactInfoElement' - $ref: '#/components/schemas/SurveyCsatElement' - $ref: '#/components/schemas/SurveyCesElement' discriminator: propertyName: type mapping: openText: '#/components/schemas/SurveyOpenTextElement' consent: '#/components/schemas/SurveyConsentElement' multipleChoiceSingle: '#/components/schemas/SurveyMultipleChoiceSingleElement' multipleChoiceMulti: '#/components/schemas/SurveyMultipleChoiceMultiElement' nps: '#/components/schemas/SurveyNpsElement' cta: '#/components/schemas/SurveyCtaElement' rating: '#/components/schemas/SurveyRatingElement' pictureSelection: '#/components/schemas/SurveyPictureSelectionElement' date: '#/components/schemas/SurveyDateElement' fileUpload: '#/components/schemas/SurveyFileUploadElement' cal: '#/components/schemas/SurveyCalElement' matrix: '#/components/schemas/SurveyMatrixElement' address: '#/components/schemas/SurveyAddressElement' ranking: '#/components/schemas/SurveyRankingElement' contactInfo: '#/components/schemas/SurveyContactInfoElement' csat: '#/components/schemas/SurveyCsatElement' ces: '#/components/schemas/SurveyCesElement' description: | Survey element/question inside a block. Element ids are stable public identifiers used by logic, recall strings, response data, quotas, integrations, and analysis. `type` selects the allowed shape; unsupported fields are rejected instead of ignored. SurveyDynamicReference: type: object description: Dynamic reference to another value in the survey document. required: - type - value properties: type: type: string enum: - element - variable - hiddenField - reserved value: type: string description: Element id, variable id, hidden field id, or — for `reserved` — the name of an Embedded Data field-catalog entry (`country`, `deviceType`, `utmSource`, …). Reserved names are checked for non-emptiness only, deliberately not against the catalog, so a survey authored against a newer catalog still parses on an older self-hosted deployment. meta: type: object additionalProperties: type: string additionalProperties: false SurveyLogicOperand: oneOf: - type: object required: - type - value properties: type: type: string enum: - static value: oneOf: - type: string - type: number - type: array items: type: string additionalProperties: false - $ref: '#/components/schemas/SurveyDynamicReference' SurveyCondition: type: object description: | Single condition. Operators such as `isSubmitted`, `isSkipped`, `isClicked`, `isAccepted`, `isBooked`, `isSet`, and `isEmpty` do not use `rightOperand`; comparison operators do. required: - id - leftOperand - operator properties: id: type: string format: cuid2 leftOperand: $ref: '#/components/schemas/SurveyDynamicReference' operator: type: string enum: - equals - doesNotEqual - contains - doesNotContain - startsWith - doesNotStartWith - endsWith - doesNotEndWith - isSubmitted - isSkipped - isGreaterThan - isLessThan - isGreaterThanOrEqual - isLessThanOrEqual - equalsOneOf - includesAllOf - includesOneOf - doesNotIncludeOneOf - doesNotIncludeAllOf - isClicked - isNotClicked - isAccepted - isBefore - isAfter - isBooked - isPartiallySubmitted - isCompletelySubmitted - isSet - isNotSet - isEmpty - isNotEmpty - isAnyOf rightOperand: $ref: '#/components/schemas/SurveyLogicOperand' additionalProperties: false SurveyConditionGroup: type: object required: - id - connector - conditions properties: id: type: string format: cuid2 connector: type: string enum: - and - or conditions: type: array items: oneOf: - $ref: '#/components/schemas/SurveyCondition' - $ref: '#/components/schemas/SurveyConditionGroup' additionalProperties: false SurveyCalculateAction: type: object description: Updates a survey variable when the logic rule matches. required: - id - objective - variableId - operator - value properties: id: type: string format: cuid2 objective: type: string enum: - calculate variableId: type: string format: cuid2 description: Variable id for `calculate`. operator: type: string enum: - assign - concat - add - subtract - multiply - divide value: $ref: '#/components/schemas/SurveyLogicOperand' additionalProperties: false SurveyRequireAnswerAction: type: object description: Requires an element/question to be answered before continuing. required: - id - objective - target properties: id: type: string format: cuid2 objective: type: string enum: - requireAnswer target: type: string description: Target element id. additionalProperties: false SurveyJumpToBlockAction: type: object description: Jumps to another block or ending when the logic rule matches. required: - id - objective - target properties: id: type: string format: cuid2 objective: type: string enum: - jumpToBlock target: type: string format: cuid2 description: Target block id or ending id. additionalProperties: false SurveyLogicAction: oneOf: - $ref: '#/components/schemas/SurveyCalculateAction' - $ref: '#/components/schemas/SurveyRequireAnswerAction' - $ref: '#/components/schemas/SurveyJumpToBlockAction' description: | Logic action. Keep referenced ids stable: `calculate.variableId` points to a variable id, `requireAnswer.target` points to an element id, and `jumpToBlock.target` points to a block id or ending id. SurveyBlockLogic: type: object description: Conditional logic rule evaluated at block level. required: - id - conditions - actions properties: id: type: string format: cuid2 conditions: $ref: '#/components/schemas/SurveyConditionGroup' actions: type: array items: $ref: '#/components/schemas/SurveyLogicAction' additionalProperties: false CreateSurveyBlock: type: object description: | Block accepted by `POST /api/v3/surveys`. `id` may be omitted and will be generated by the server. Provide an explicit cuid2 id when logic in the same request needs to jump to this block. For normal sequential flow, omit `logic` and `logicFallback`. `logicFallback` is only valid when this same block has at least one `logic` rule. required: - name - elements properties: id: type: string format: cuid2 description: Optional stable block id. Generated when omitted. name: type: string minLength: 1 elements: type: array minItems: 1 items: $ref: '#/components/schemas/SurveyElement' logic: type: array items: $ref: '#/components/schemas/SurveyBlockLogic' logicFallback: type: string format: cuid2 description: | Block or ending id used when no logic condition matches. Only valid when this same block has at least one `logic` rule; omit it for normal sequential flow. buttonLabel: $ref: '#/components/schemas/TranslatableText' backButtonLabel: $ref: '#/components/schemas/TranslatableText' additionalProperties: false SurveyEndScreenEnding: type: object description: Visual end screen displayed after survey completion. required: - id - type properties: id: type: string format: cuid2 description: Stable ending id. `jumpToBlock.target` may point to this id. type: type: string enum: - endScreen headline: $ref: '#/components/schemas/TranslatableText' subheader: $ref: '#/components/schemas/TranslatableText' buttonLabel: $ref: '#/components/schemas/TranslatableText' buttonLink: type: string imageUrl: type: string imageAltText: allOf: - $ref: '#/components/schemas/TranslatableText' description: Text alternative for `imageUrl`, read by screen readers. Omit or leave empty for a decorative image (rendered as `alt=""`). videoUrl: type: string hideDefaultIcon: type: boolean description: Hides the default checkmark icon rendered above the headline when the ending carries no image or video. Omit to keep the icon. additionalProperties: false SurveyRedirectEnding: type: object description: | Redirects the respondent to a URL after survey completion. External redirects require the organization's external URL permission; otherwise write endpoints return `403 Forbidden`. required: - id - type - url properties: id: type: string format: cuid2 description: Stable ending id. `jumpToBlock.target` may point to this id. type: type: string enum: - redirectToUrl url: type: string format: uri description: External redirect URL. Requires the organization's external URL permission. label: type: string description: Optional internal label for redirect endings. additionalProperties: false SurveyEnding: oneOf: - $ref: '#/components/schemas/SurveyEndScreenEnding' - $ref: '#/components/schemas/SurveyRedirectEnding' discriminator: propertyName: type mapping: endScreen: '#/components/schemas/SurveyEndScreenEnding' redirectToUrl: '#/components/schemas/SurveyRedirectEnding' description: | Ending reached after the last block or a jump action. `type` selects the allowed shape; unsupported fields are rejected instead of ignored. SurveyHiddenFields: type: object description: | Hidden fields, sometimes called embedded data in other survey products. Field ids are stable public identifiers and may be referenced by logic, recall, quotas, integrations, and response data. **Naming rule for new field ids** — the same rule the survey editor applies (ENG-2539): a new id must start with a lowercase letter and then contain only lowercase letters, numbers and underscores (`^[a-z][a-z0-9_]*$`), and must not take a reserved name. Reserved are the link-survey system params and internal ids — `userId`, `source`, `suid`, `end`, `start`, `welcomeCard`, `hidden`, `verifiedEmail`, `multiLanguage`, `embed`, `verify`, `suToken`, `lang`, `preview`, `startAt`, `skipPrefilled`, `offlineSupport` (matched case-insensitively) — and the names of auto-captured system fields, which every survey can already read without declaring them: `source`, `url`, `country`, `action`, `browser`, `os`, `deviceType`, `ipAddress`, `finished`, `language`, `locale`, `responseId`, `surveyId`, `durationSeconds`, `startedAt`, `finishedAt`, `pagePath`, `pageReferrer`, `utmSource`, `utmMedium`, `utmCampaign`, `utmTerm`, `utmContent`, `screenWidth`, `screenHeight`, `viewportWidth`, `viewportHeight`, `timezone`, `visitPagesViewed`, `visitUniquePagesViewed`, `visitTimeOnSite`, `visitEntryReferrer`, `visitUrls`, `openedByCreativeId`, `openedByCreativeName`. **Ids a survey already declares are grandfathered**: they always load, and re-sending the survey unchanged never fails validation — only *adding* such a name as a new field is refused. Deleting a grandfathered field spends the reprieve: once it is gone from the saved survey, the name can no longer be re-added. Note this means re-creating a survey from an exported document can fail with a 400 when the export declares a name the rule refuses; rename the field in the payload before importing. The `pattern` below describes what stored surveys may hold (the pre-existing lenient charset), not what a new id may look like. required: - enabled properties: enabled: type: boolean description: | Derived, not stored: `true` whenever the survey declares at least one hidden field. A value sent on create or update is ignored; to turn hidden fields off, remove them from `fieldIds`. fieldIds: type: array maxItems: 200 items: type: string pattern: ^[a-zA-Z0-9_-]+$ uniqueItems: true additionalProperties: false CreateSurveyNumberVariable: type: object required: - name - type - value properties: id: type: string format: cuid2 description: Optional stable variable id. Generated when omitted. name: type: string pattern: ^[a-z][a-z0-9_]*$ description: | Unique variable name. Must start with a lowercase letter, then lowercase letters, numbers and underscores only — the same rule as new hidden-field ids (this schema is create-only, so no grandfathered name validates against it). Reserved names are refused: `userId`, `source`, `suid`, `end`, `start`, `welcomeCard`, `hidden`, `verifiedEmail`, `multiLanguage`, `embed`, `verify`, `suToken`, `lang`, `preview`, `startAt`, `skipPrefilled`, `offlineSupport`, and the auto-captured system field names `source`, `url`, `country`, `action`, `browser`, `os`, `deviceType`, `ipAddress`, `finished`, `language`, `locale`, `responseId`, `surveyId`, `durationSeconds`, `startedAt`, `finishedAt`, `pagePath`, `pageReferrer`, `utmSource`, `utmMedium`, `utmCampaign`, `utmTerm`, `utmContent`, `screenWidth`, `screenHeight`, `viewportWidth`, `viewportHeight`, `timezone`, `visitPagesViewed`, `visitUniquePagesViewed`, `visitTimeOnSite`, `visitEntryReferrer`, `visitUrls`, `openedByCreativeId`, `openedByCreativeName`. type: type: string enum: - number value: type: number description: Default numeric value. additionalProperties: false CreateSurveyTextVariable: type: object required: - name - type - value properties: id: type: string format: cuid2 description: Optional stable variable id. Generated when omitted. name: type: string pattern: ^[a-z][a-z0-9_]*$ description: | Unique variable name. Must start with a lowercase letter, then lowercase letters, numbers and underscores only — the same rule as new hidden-field ids (this schema is create-only, so no grandfathered name validates against it). Reserved names are refused: `userId`, `source`, `suid`, `end`, `start`, `welcomeCard`, `hidden`, `verifiedEmail`, `multiLanguage`, `embed`, `verify`, `suToken`, `lang`, `preview`, `startAt`, `skipPrefilled`, `offlineSupport`, and the auto-captured system field names `source`, `url`, `country`, `action`, `browser`, `os`, `deviceType`, `ipAddress`, `finished`, `language`, `locale`, `responseId`, `surveyId`, `durationSeconds`, `startedAt`, `finishedAt`, `pagePath`, `pageReferrer`, `utmSource`, `utmMedium`, `utmCampaign`, `utmTerm`, `utmContent`, `screenWidth`, `screenHeight`, `viewportWidth`, `viewportHeight`, `timezone`, `visitPagesViewed`, `visitUniquePagesViewed`, `visitTimeOnSite`, `visitEntryReferrer`, `visitUrls`, `openedByCreativeId`, `openedByCreativeName`. type: type: string enum: - text value: type: string description: Default text value. additionalProperties: false CreateSurveyVariable: oneOf: - $ref: '#/components/schemas/CreateSurveyNumberVariable' - $ref: '#/components/schemas/CreateSurveyTextVariable' description: | Survey variable accepted by `POST /api/v3/surveys`. `id` may be omitted and will be generated by the server. Provide an explicit cuid2 id when logic in the same request needs to reference this variable. SurveyShowCondition: type: object description: | One check, read like a sentence: what to look at (`leftOperand`), how to compare (`operator`) and what to compare with (`rightOperand`). Text comparisons ignore upper and lower case. | `leftOperand.type` | `leftOperand.value` | Operators | | --- | --- | --- | | `page` | `currentUrl` (full URL, query included) | `pathIsAnyOf`, `pathIsNoneOf`, `containsAnyOf`, `containsNoneOf`, `equals`, `doesNotEqual`, `contains`, `doesNotContain`, `startsWith`, `endsWith` | | `page` | `pageReferrer`, `entryReferrer` (first page of the visit) | `equals`, `doesNotEqual`, `contains`, `doesNotContain`, `startsWith`, `endsWith`, `containsAnyOf`, `containsNoneOf` | | `page` | `queryParam` (name in `meta.key`) | `equals`, `doesNotEqual`, `contains`, `doesNotContain`, `startsWith`, `endsWith`, `isSet`, `isNotSet` | | `visit` | `pagesViewed`, `uniquePagesViewed`, `timeOnSite` (seconds) | `equals`, `isGreaterThan`, `isLessThan`, `isGreaterThanOrEqual`, `isLessThanOrEqual` | | `visit` | `visitedUrls` (pages seen in this visit, the current one included) | `includes`, `doesNotInclude` | | `device` | `deviceType` (`phone`, `tablet`, `desktop`), `browser` (`chrome`, `safari`, `firefox`, `edge`, `opera`, `samsung`, `other`) | `equals`, `doesNotEqual` | | `device` | `screenWidth` (browser window width, CSS px) | `equals`, `isGreaterThan`, `isLessThan`, `isGreaterThanOrEqual`, `isLessThanOrEqual` | `pathIsAnyOf` / `pathIsNoneOf` take a list of page paths, each compared exactly with the URL's path: case, a trailing slash, the query string and the `#hash` are ignored, and a full URL is reduced to its path. `/plans` matches `/plans?ref=email` but not `/plans/compare`. `containsAnyOf` / `containsNoneOf` take a list of fragments; a line passes when the URL contains it anywhere, so `/plans` also matches `/plans/compare`. `visitedUrls` takes one page path, compared exactly like a `pathIsAnyOf` line. `uniquePagesViewed` takes at most 200, the number of different pages a visit remembers. `isSet` / `isNotSet` take no `rightOperand`. A visit is one browser tab; if the browser blocks storage, visit conditions fail. required: - id - leftOperand - operator properties: id: type: string description: Client-generated id (cuid2), unique within the survey's conditions. leftOperand: type: object required: - type - value properties: type: type: string enum: - page - visit - device value: type: string enum: - currentUrl - pageReferrer - entryReferrer - queryParam - pagesViewed - uniquePagesViewed - timeOnSite - visitedUrls - deviceType - browser - screenWidth meta: type: object properties: key: type: string description: The query parameter's name. Required for `queryParam`; matched ignoring case. additionalProperties: false additionalProperties: false operator: type: string enum: - equals - doesNotEqual - contains - doesNotContain - startsWith - endsWith - containsAnyOf - containsNoneOf - pathIsAnyOf - pathIsNoneOf - isGreaterThan - isLessThan - isGreaterThanOrEqual - isLessThanOrEqual - includes - doesNotInclude - isSet - isNotSet rightOperand: type: object required: - type - value properties: type: type: string enum: - static value: anyOf: - type: string minLength: 1 - type: number minimum: 0 - type: array minItems: 1 maxItems: 200 items: type: string minLength: 1 additionalProperties: false additionalProperties: false SurveyShowConditionGroup: type: object description: | A group of show conditions. `and` means every condition must hold; `or` means one is enough. A group can hold one more group, no deeper. An empty group is stored as `null`. required: - id - connector - conditions properties: id: type: string description: Client-generated id (cuid2), unique within the survey's conditions. connector: type: string enum: - and - or conditions: type: array items: anyOf: - $ref: '#/components/schemas/SurveyShowCondition' - $ref: '#/components/schemas/SurveyShowConditionGroup' additionalProperties: false SurveyTrigger: type: object description: | Reference to an existing workspace action class that triggers this app survey. Discover available action-class ids with `GET /api/v3/action-classes`. required: - actionClassId properties: actionClassId: type: string format: cuid2 description: Id of an action class in the same workspace. additionalProperties: false SurveyDistribution: type: object description: | App-survey runtime/display settings. Only valid for `type: app`; rejected for link surveys. **Replacement semantics (destructive):** when `distribution` is provided on PATCH it fully replaces the stored object — any sub-field you omit is reset to its default, NOT preserved. In particular, omitting `triggers` removes ALL existing triggers, and omitting `displayOption` resets it to `displayOnce`. To change one setting, send the complete desired distribution. To leave distribution untouched, omit the whole `distribution` key. properties: displayOption: type: string enum: - displayOnce - displayMultiple - respondMultiple - displaySome default: displayOnce description: | How often the survey may be shown to a contact. `displaySome` shows it up to `displayLimit` times (or until the contact responds, whichever comes first). displayPercentage: type: - number - 'null' minimum: 0.01 maximum: 100 default: null description: | Independent throttle: show the survey to only this percentage of triggered contacts. Optional and valid with any `displayOption`; null/omitted means no throttle (shown to everyone). displayLimit: type: - integer - 'null' minimum: 0 default: null description: | Maximum number of times the survey is shown to a single contact. Required (must be >= 1) when `displayOption` is `displaySome`; otherwise optional. recontactDays: type: - integer - 'null' minimum: 0 default: null description: Days to wait before showing this survey again to the same contact. Null uses the workspace default. autoClose: type: - integer - 'null' minimum: 0 default: null description: Seconds of inactivity after which the survey widget auto-closes. Null disables auto-close. keepEndingCardOpen: type: boolean default: false description: | Keep the ending card on screen until the respondent closes the survey. When false, the survey widget closes 3 seconds after the ending card appears. Has no effect on a survey with no ending card (it closes immediately) or whose first ending redirects to a URL. Independent of `autoClose`. autoComplete: type: - integer - 'null' minimum: 1 default: null description: Automatically stop collecting responses after this many completed responses. Null disables it. delay: type: integer minimum: 0 default: 0 description: Seconds to wait after a trigger fires before showing the survey. showConditions: anyOf: - $ref: '#/components/schemas/SurveyShowConditionGroup' - type: 'null' default: null description: | Conditions on the page, the visit and the device, checked in the visitor's browser when a trigger fires and again when `delay` ends. The survey shows only if they hold. `null` (or an empty group) means no conditions. Available on every plan. At most 20 conditions, one nested group, 200 entries per list and 10 KB in total. Mobile SDKs ignore them. triggers: type: array default: [] maxItems: 50 items: $ref: '#/components/schemas/SurveyTrigger' description: | Action classes that trigger the survey. Every id must reference an existing workspace action class. On PATCH this list fully replaces the survey's triggers — omitting it (or sending `[]`) removes all existing triggers. additionalProperties: false example: displayOption: displaySome displayLimit: 3 displayPercentage: null recontactDays: 7 autoClose: null keepEndingCardOpen: false autoComplete: null delay: 0 showConditions: id: grp1 connector: and conditions: - id: cnd1 leftOperand: type: page value: currentUrl operator: pathIsAnyOf rightOperand: type: static value: - /plan-compare - /drug-coverage - id: cnd2 leftOperand: type: visit value: pagesViewed operator: isGreaterThanOrEqual rightOperand: type: static value: 3 triggers: - actionClassId: clyy1234567890123456789012 SegmentFilterValue: description: | Comparison value for a segment filter condition. A string or number for most operators; a relative-date object (`{ amount, unit }`) for `isOlderThan` / `isNewerThan`; a two-element `[from, to]` string array for `isBetween`. Ignored for `isSet` / `isNotSet` but still required. oneOf: - type: string - type: number - type: object required: - amount - unit properties: amount: type: number unit: type: string enum: - days - weeks - months - years additionalProperties: false - type: array items: type: string minItems: 2 maxItems: 2 SegmentFilter: description: | A single targeting condition. `root.type` selects the subject and determines the valid `qualifier.operator` set: `attribute` (a workspace contact-attribute key), `person` (a built-in person identifier such as `userId`/`email`), `segment` (membership in another segment), `device`, or `surveyInteraction` (how the contact interacted with surveys). Each variant is one member of this union; the matching member is chosen by `root.type`. oneOf: - title: AttributeFilter description: Matches on a workspace contact-attribute value. type: object required: - id - root - qualifier - value properties: id: type: string format: cuid2 root: type: object required: - type - contactAttributeKey properties: type: type: string enum: - attribute contactAttributeKey: type: string description: A contact-attribute key defined in the workspace (e.g. `plan`, `role`). additionalProperties: false qualifier: type: object required: - operator properties: operator: type: string enum: - lessThan - lessEqual - greaterThan - greaterEqual - equals - notEquals - isSet - isNotSet - contains - doesNotContain - startsWith - endsWith - isOlderThan - isNewerThan - isBefore - isAfter - isBetween - isSameDay additionalProperties: false value: $ref: '#/components/schemas/SegmentFilterValue' additionalProperties: false - title: PersonFilter description: Matches on a built-in person identifier. type: object required: - id - root - qualifier - value properties: id: type: string format: cuid2 root: type: object required: - type - personIdentifier properties: type: type: string enum: - person personIdentifier: type: string description: A built-in person identifier (e.g. `userId`). additionalProperties: false qualifier: type: object required: - operator properties: operator: type: string enum: - equals - notEquals - isSet - isNotSet - contains - doesNotContain - startsWith - endsWith additionalProperties: false value: $ref: '#/components/schemas/SegmentFilterValue' additionalProperties: false - title: SegmentFilter description: Matches on membership in another segment. type: object required: - id - root - qualifier - value properties: id: type: string format: cuid2 root: type: object required: - type - segmentId properties: type: type: string enum: - segment segmentId: type: string description: Id of the segment to test membership against. additionalProperties: false qualifier: type: object required: - operator properties: operator: type: string enum: - userIsIn - userIsNotIn additionalProperties: false value: $ref: '#/components/schemas/SegmentFilterValue' additionalProperties: false - title: DeviceFilter description: Matches on the contact's device type. type: object required: - id - root - qualifier - value properties: id: type: string format: cuid2 root: type: object required: - type - deviceType properties: type: type: string enum: - device deviceType: type: string description: Device type (e.g. `desktop`, `phone`). additionalProperties: false qualifier: type: object required: - operator properties: operator: type: string enum: - equals - notEquals additionalProperties: false value: $ref: '#/components/schemas/SegmentFilterValue' additionalProperties: false - title: SurveyInteractionFilter description: Matches on how the contact interacted with surveys within a time window. `surveyIds` only names surveys the caller may see; ids of other surveys the segment targets are left out. type: object required: - id - root - qualifier - value properties: id: type: string format: cuid2 root: type: object required: - type properties: type: type: string enum: - surveyInteraction additionalProperties: false qualifier: type: object required: - operator properties: operator: type: string enum: - haveCompleted - haveNotCompleted - haveSeen - haveNotSeen - haveStartedRespondingTo additionalProperties: false value: type: object required: - surveyScope - surveyIds - within properties: surveyScope: type: string enum: - any - specific description: '`any` matches every survey in the workspace; `specific` only `surveyIds`.' surveyIds: type: array maxItems: 100 items: type: string format: cuid2 within: type: object required: - amount - unit properties: amount: type: integer minimum: 1 maximum: 999 unit: type: string enum: - days - weeks - months additionalProperties: false additionalProperties: false additionalProperties: false SegmentFilters: type: array description: | Contact targeting filter tree. An empty array targets everyone. Each node joins the previous one via its `connector` (`and`/`or`; the first node's connector is null). A node's `resource` is either a single filter condition or a nested filter group (enabling parenthesized `and`/`or` logic). items: type: object required: - id - connector - resource properties: id: type: string format: cuid2 connector: type: - string - 'null' enum: - and - or - null description: Logical connector joining this node to the previous one. Null for the first node. resource: oneOf: - $ref: '#/components/schemas/SegmentFilter' - $ref: '#/components/schemas/SegmentFilters' description: A single filter condition, or a nested filter group for grouped logic. additionalProperties: false SurveyTargeting: type: object description: | App-survey contact targeting. Only valid for `type: app`. `filters: []` targets everyone. Setting or changing non-empty filters requires the Contacts entitlement; otherwise create/patch returns 403. `attribute` filters reference workspace contact-attribute keys (discover them via `GET /api/v3/contact-attribute-keys`) and `segment` filters reference other segment ids — both are workspace-scoped resources you must obtain before constructing filters (an invalid key/id targets no one rather than erroring). See `SegmentFilter` for the per-`root.type` operator sets. required: - filters properties: filters: $ref: '#/components/schemas/SegmentFilters' additionalProperties: false CustomCssInput: type: object description: 'Custom CSS **source** as a caller writes it (ENG-3641). Both keys are required. `light` is base CSS that applies in both appearances; `dark` adds overrides on top of it when the survey renders dark. `null`, an empty string or whitespace means "no CSS" for that field. Source only: compiled output and the processor version are computed by the server on every write, and a request that supplies either is rejected. The combined source is limited to 100 000 UTF-8 bytes for a workspace and 20 000 for a survey; larger CSS is a **422** with error code `source_too_large`.' required: - light - dark properties: light: type: - string - 'null' description: Base CSS, applied in light and dark appearance. dark: type: - string - 'null' description: Additional CSS applied only in dark appearance. additionalProperties: false CreateSurveyRequest: type: object description: | Strict v3 survey creation document. This endpoint accepts survey structure only: name, metadata, languages and language settings, welcome card, blocks/elements/logic, endings, hidden fields, and variables. App surveys (`type: app`) additionally accept the `distribution` (display options + triggers) and `targeting` (contact segment filters) objects; these are rejected for `link` surveys. It rejects legacy `questions` and out-of-scope settings such as styling, follow-ups, recaptcha, single-use/email verification, slug, custom scripts, analytics fields, timestamps, and `createdBy`. Translatable fields use real locale-code maps. The map must include the canonical `defaultLanguage` key, such as `en-US`, so the server can persist the internal default translation. Locale keys must be canonical BCP 47 codes such as `de-DE`, `pt-PT`, or `zh-Hans-CN`. Non-default locale keys must be declared in `languages`; undeclared locale keys in metadata, welcome cards, blocks, or endings are rejected with `unsupported_locale`. `blocks[].id` and `variables[].id` may be omitted on create and will be generated by the server. Provide explicit cuid2 ids when other fields in the same request reference them. For normal sequential flow, omit `logicFallback`. It is only valid together with a non-empty `logic` array on the same block. required: - workspaceId - name - blocks properties: workspaceId: type: string format: cuid2 description: Workspace where the survey will be created. Requires read/write access. name: type: string minLength: 1 type: type: string enum: - link - app default: link description: | Survey type. `link` surveys are distributed by URL; `app` surveys are shown in-app and configure their runtime behavior via the `distribution` and `targeting` objects. `type` is immutable after creation (it cannot be changed via PATCH). status: type: string enum: - draft - inProgress - paused - completed default: draft metadata: allOf: - $ref: '#/components/schemas/SurveyMetadata' default: {} defaultLanguage: allOf: - $ref: '#/components/schemas/LocaleCode' default: en-US description: Default authoring locale. Translatable maps must include this locale key. languages: type: array default: [] maxItems: 50 items: $ref: '#/components/schemas/CreateSurveyLanguage' description: | Optional survey language configuration. Every non-default locale used by translatable maps must be declared here; omitted languages are not inferred from map keys. showLanguageSwitch: type: - boolean - 'null' default: null description: Show respondents a language switch on multi-language surveys. `null` and `false` both mean off. autoSelectLanguage: type: - boolean - 'null' default: null description: Open link and app surveys in the respondent's browser language when it matches an enabled survey language; otherwise the default language is used. `null` and `false` both mean off. welcomeCard: allOf: - $ref: '#/components/schemas/SurveyWelcomeCard' default: enabled: false blocks: type: array minItems: 1 maxItems: 1000 items: $ref: '#/components/schemas/CreateSurveyBlock' endings: type: array default: [] maxItems: 100 items: $ref: '#/components/schemas/SurveyEnding' hiddenFields: allOf: - $ref: '#/components/schemas/SurveyHiddenFields' default: enabled: false variables: type: array default: [] maxItems: 100 items: $ref: '#/components/schemas/CreateSurveyVariable' distribution: allOf: - $ref: '#/components/schemas/SurveyDistribution' description: App-survey runtime/display settings. Only valid when `type` is `app`. targeting: allOf: - $ref: '#/components/schemas/SurveyTargeting' description: App-survey contact targeting. Only valid when `type` is `app`. customCss: anyOf: - $ref: '#/components/schemas/CustomCssInput' - type: 'null' description: Optional survey custom CSS source. Processed by the server; constructs it removes come back as top-level `warnings` on the 201. Adding CSS needs the Scale plan on Formbricks Cloud (403 `custom_css_plan_required`); CSS that cannot be processed is a 422 with `details.errors`. Processing counts against the custom CSS budget of 60 per minute per user or API key, shared with custom CSS validation; beyond it the create is a 429 and nothing is written. additionalProperties: false SurveyLanguage: type: object description: | Language configured for this survey. GET responses expose the server-emitted code/tag used as the translatable map key. Existing surveys can use region-qualified, language-only, or script-only codes. Disabled languages can still be read by the management API so unfinished translations can be completed. required: - code - default - enabled properties: code: type: string description: Server-emitted survey language code/tag used as the translatable map key. example: en-US alias: type: - string - 'null' description: Optional configured alias accepted by `?lang` for compatibility and agent discovery. example: english default: type: boolean description: Whether this is the default authoring language. enabled: type: boolean description: Whether this language is enabled for respondent-facing delivery. SurveyBlock: type: object description: | Block-based survey section. Block ids are stable public identifiers. Logic and fallbacks can jump to block ids or ending ids, so clients and agents should preserve ids unless intentionally creating/deleting a block. required: - id - name - elements properties: id: type: string format: cuid2 description: Stable block id. name: type: string minLength: 1 elements: type: array minItems: 1 items: $ref: '#/components/schemas/SurveyElement' logic: type: array items: $ref: '#/components/schemas/SurveyBlockLogic' logicFallback: type: string format: cuid2 description: | Block or ending id used when no logic condition matches. Only valid when this same block has at least one `logic` rule; omit it for normal sequential flow. buttonLabel: $ref: '#/components/schemas/TranslatableText' backButtonLabel: $ref: '#/components/schemas/TranslatableText' additionalProperties: false SurveyNumberVariable: type: object description: | Number variable. Used by `calculate` logic actions with numeric operators such as `add`, `subtract`, `multiply`, `divide`, or `assign`. required: - id - name - type - value properties: id: type: string format: cuid2 description: Stable variable id referenced from logic. name: type: string pattern: ^[a-z0-9_]+$ description: | Unique variable name. `pattern` is the charset of a *stored* name; a newly declared one is held to more than that, and the extra rules split into two groups that behave differently. **400 `forbidden_identifier`, grandfathered.** A new name must match `^[a-z][a-z0-9_]*$` (no leading digit or underscore) and must not take a reserved name — the link-survey system params and internal ids (`userId`, `source`, `suid`, `end`, `start`, `welcomeCard`, `hidden`, `verifiedEmail`, `multiLanguage`, `embed`, `verify`, `suToken`, `lang`, `preview`, `startAt`, `skipPrefilled`, `offlineSupport`) or an auto-captured system field name (`country`, `browser`, `deviceType`, …; `SurveyHiddenFields` carries the full list, and the two sets are the same there). Names the survey already declares are grandfathered against these, so re-sending an existing document never trips them. **400 `duplicate_identifier`, not grandfathered.** A variable name that collides case-insensitively with a hidden field id on the same survey. This is checked by reference validation over the whole merged document rather than against the incoming names, so a survey that already holds such a clash is refused on **every** patch — including one that touches neither collection — until one side is renamed. type: type: string enum: - number value: type: number description: Default numeric value. additionalProperties: false SurveyTextVariable: type: object description: | Text variable. Used by `calculate` logic actions with text operators such as `assign` or `concat`. required: - id - name - type - value properties: id: type: string format: cuid2 description: Stable variable id referenced from logic. name: type: string pattern: ^[a-z0-9_]+$ description: | Unique variable name. `pattern` is the charset of a *stored* name; a newly declared one is held to more than that, and the extra rules split into two groups that behave differently. **400 `forbidden_identifier`, grandfathered.** A new name must match `^[a-z][a-z0-9_]*$` (no leading digit or underscore) and must not take a reserved name — the link-survey system params and internal ids (`userId`, `source`, `suid`, `end`, `start`, `welcomeCard`, `hidden`, `verifiedEmail`, `multiLanguage`, `embed`, `verify`, `suToken`, `lang`, `preview`, `startAt`, `skipPrefilled`, `offlineSupport`) or an auto-captured system field name (`country`, `browser`, `deviceType`, …; `SurveyHiddenFields` carries the full list, and the two sets are the same there). Names the survey already declares are grandfathered against these, so re-sending an existing document never trips them. **400 `duplicate_identifier`, not grandfathered.** A variable name that collides case-insensitively with a hidden field id on the same survey. This is checked by reference validation over the whole merged document rather than against the incoming names, so a survey that already holds such a clash is refused on **every** patch — including one that touches neither collection — until one side is renamed. type: type: string enum: - text value: type: string description: Default text value. additionalProperties: false SurveyVariable: oneOf: - $ref: '#/components/schemas/SurveyNumberVariable' - $ref: '#/components/schemas/SurveyTextVariable' description: | Survey variable. Variable ids are stable references used by logic and calculation actions. Variable names are human-readable labels and must be unique within the survey. SurveyResource: allOf: - type: object required: - id - workspaceId - createdAt - updatedAt - archivedAt - name - type - status - metadata - defaultLanguage - languages - showLanguageSwitch - autoSelectLanguage - welcomeCard - blocks - endings - hiddenFields - variables - customCss properties: id: type: string workspaceId: type: string createdAt: type: string format: date-time updatedAt: type: string format: date-time archivedAt: type: - string - 'null' format: date-time description: Soft-delete/archive marker (ISO 8601); `null` when the survey is active. name: type: string type: type: string enum: - link - app status: type: string enum: - draft - inProgress - paused - completed metadata: anyOf: - $ref: '#/components/schemas/SurveyMetadata' - type: 'null' defaultLanguage: type: string description: Emitted language code/tag for the survey default language. The internal `default` translation key is never exposed. languages: type: array items: $ref: '#/components/schemas/SurveyLanguage' showLanguageSwitch: type: - boolean - 'null' description: Show respondents a language switch on multi-language surveys. `null` and `false` both mean off. autoSelectLanguage: type: - boolean - 'null' description: Open link and app surveys in the respondent's browser language when it matches an enabled survey language; otherwise the default language is used. `null` and `false` both mean off. welcomeCard: $ref: '#/components/schemas/SurveyWelcomeCard' blocks: type: array items: $ref: '#/components/schemas/SurveyBlock' endings: type: array items: $ref: '#/components/schemas/SurveyEnding' hiddenFields: $ref: '#/components/schemas/SurveyHiddenFields' variables: type: array items: $ref: '#/components/schemas/SurveyVariable' distribution: allOf: - $ref: '#/components/schemas/SurveyDistribution' description: App-survey runtime/display settings. Present only for app surveys; omitted for link surveys. targeting: allOf: - $ref: '#/components/schemas/SurveyTargeting' description: App-survey contact targeting. Present only for app surveys; omitted for link surveys. customCss: anyOf: - $ref: '#/components/schemas/CustomCssInput' - type: 'null' description: 'The survey''s custom CSS **source** (ENG-3641), `null` when it has none. Compiled output and the processor version are internal and never exposed here. The full resource may be sent back with PATCH: unchanged source is not reprocessed and needs no plan.' - $ref: '#/components/schemas/SurveyVisibilityFields' CustomCssWarning: type: object description: 'A construct the custom CSS processor removed; the rest of the CSS still applies. `unknown_property` and `invalid_value` are notes instead: the declaration stays, but browsers ignore it. The `code` is stable — branch on it, not on `reason`, which never echoes customer source beyond a short construct name.' required: - code - scope - appearance - line - column - reason properties: code: type: string enum: - import_removed - font_face_removed - external_resource_removed - unsupported_at_rule_removed - unsafe_property_removed - unsafe_value_removed - fixed_position_removed - unsafe_selector_removed - unknown_property - invalid_value scope: type: string enum: - workspace - survey appearance: type: - string - 'null' enum: - light - dark - null description: Source field the issue is in; `null` when it concerns the scope as a whole. line: type: - integer - 'null' minimum: 1 description: 1-based line in that field's source; `null` when there is no single location. column: type: - integer - 'null' minimum: 1 reason: type: string additionalProperties: false CustomCssWarnings: type: array description: 'Processing warnings for this write: constructs the processor removed. Additive, returned beside `data` (never inside it, so `data` can be sent back unchanged); present only when the write processed custom CSS.' maxItems: 50 items: $ref: '#/components/schemas/CustomCssWarning' AISurveyGenerationLocaleCode: type: string enum: - ar-EG - bg-BG - cs-CZ - da-DK - de-DE - el-GR - en-US - es-ES - et-EE - fi-FI - fr-FR - hi-IN - hr-HR - hu-HU - id-ID - it-CH - it-IT - ja-JP - km-KH - ko-KR - lt-LT - lv-LV - ms-MY - nb-NO - ne-NP - nl-BE - nl-NL - no-NO - pl-PL - pt-BR - pt-PT - ro-RO - ru-RU - sk-SK - sl-SI - sv-SE - tr-TR - uk-UA - ur-PK - uz-UZ - vi-VN - zh-Hans-CN - zh-Hant-TW description: 'Survey language code AI survey creation can write a draft in: every language the survey runtime ships button and validation strings for, plus the dashboard locales.' example: en-US GenerateSurveyRequest: type: object description: | AI survey creation request. `prompt` is used only to create a draft payload; it is not persisted by this endpoint. The generated payload is validated and returned to the caller for a separate create request. required: - workspaceId - prompt properties: workspaceId: type: string format: cuid2 description: Workspace where the AI-created survey draft would be created. Requires read/write access. prompt: type: string minLength: 4 maxLength: 1200 description: | Natural-language survey goal and audience. Prompts with fewer than 24 characters or fewer than four whitespace-separated words return prompt feedback before any AI provider call. type: type: string enum: - link - app default: link description: Survey type to generate. The AI generates the survey content (blocks/questions) for both types; for `app` it additionally seeds a default `distribution` (display once, no triggers, no targeting) that you finish configuring before publishing. language: $ref: '#/components/schemas/AISurveyGenerationLocaleCode' description: | Preferred survey language. Session clients should send the logged-in user's language. The generator writes the draft in the language of the prompt, detected among the dashboard locales plus this language, and uses this one only when the prompt language is ambiguous or not one of those. To get a draft in any other supported survey language, name it here or in `surveyLanguage`. surveyLanguage: $ref: '#/components/schemas/AISurveyGenerationLocaleCode' description: | Required survey language. When set, the draft is written in this language whatever language the prompt is in, and it takes precedence over `language`. A draft the model returns under any other language code is rejected as an invalid generated payload rather than relabelled. The code is what is checked: the generated text itself is not language-verified, so review the draft before creating the survey. additionalProperties: false GeneratedSurveyValidationLanguage: type: object required: - code - default - enabled properties: code: $ref: '#/components/schemas/AISurveyGenerationLocaleCode' default: type: boolean enabled: type: boolean additionalProperties: false GeneratedSurveyValidationResult: type: object required: - valid - invalid_params - languages properties: valid: type: boolean description: Whether the generated create payload passed the same v3 create preparation pipeline. invalid_params: type: array description: Repairable validation errors. Successful generate responses return an empty array. items: $ref: '#/components/schemas/InvalidParam' languages: type: array description: Languages that the generated create payload would connect or create. items: $ref: '#/components/schemas/GeneratedSurveyValidationLanguage' additionalProperties: false GenerateSurveyResult: type: object required: - language - payload - validation properties: language: $ref: '#/components/schemas/AISurveyGenerationLocaleCode' description: Survey language used for the AI-created survey content and create payload language maps. Always one of the dashboard locales (`de-DE`, `en-US`, `es-ES`, `fr-FR`, `hu-HU`, `ja-JP`, `nl-NL`, `pt-BR`, `pt-PT`, `ro-RO`, `ru-RU`, `sv-SE`, `tr-TR`, `zh-Hans-CN`, `zh-Hant-TW`) unless the request named another code in `language` or `surveyLanguage`, so existing callers never receive a code they did not ask for. payload: allOf: - $ref: '#/components/schemas/CreateSurveyRequest' description: | Strict create payload ready for `POST /api/v3/surveys`. It always has `status: draft`. validation: $ref: '#/components/schemas/GeneratedSurveyValidationResult' additionalProperties: false ValidateSurveyCreateRequest: type: object required: - operation - data properties: operation: type: string enum: - create data: description: Intended create payload. Invalid documents are allowed so the endpoint can return repairable validation errors. anyOf: - $ref: '#/components/schemas/CreateSurveyRequest' - type: object additionalProperties: true additionalProperties: false PatchSurveyLanguage: type: object description: 'Language entry accepted by `PATCH /api/v3/surveys/{surveyId}`. Identical to the create shape plus `alias`, which `GET` returns so a round-tripped body carries it. `alias` belongs to the workspace language rather than the survey, so it is verified rather than applied: echoing the value `GET` returned is a no-op, and a different one is rejected with `read_only_field`.' required: - code properties: code: type: string description: 'Server-emitted survey language code/tag, echoed from `GET`. Deliberately not `LocaleCode`: existing surveys carry language-only and script-only codes (`vi`, `gu`), and a read-only echo has to accept whatever the read returned. New languages are still created through the strict shape.' example: en-US default: type: boolean description: Optional marker for readability; only the `defaultLanguage` entry may set this to true. enabled: type: boolean default: true description: Whether this language is enabled for respondent-facing delivery. alias: type: - string - 'null' description: Workspace-level alias for the language. Read-only; echo the value `GET` returned. additionalProperties: false PatchSurveyRequest: type: object minProperties: 1 description: | Patch payload shape. Top-level fields are partial; any provided nested object or array fully replaces that subtree. Omitted top-level fields are preserved. The server-owned fields `GET` returns — `id`, `workspaceId`, `type`, `createdAt`, `updatedAt`, `archivedAt`, `defaultLanguage` and `languages[].alias` — are accepted and verified, so a survey fetched with `GET` can be sent straight back. Echoing the value you were given is a no-op; changing one is a **422** `read_only_field`. `visibility`, `owner` and `access` are accepted the same way, but a changed value is a **422** `unsupported_field`: visibility changes go through `POST /api/v3/surveys/{surveyId}/visibility`. A round-tripped `updatedAt` additionally acts as an optimistic-concurrency precondition, and a stale one is rejected with **409**. `questions`, analytics, styling and scripts remain rejected outright. App surveys (stored `type: app`) additionally accept `distribution` and `targeting`; both are rejected for link surveys, and changing `targeting` filters requires the Contacts entitlement. `metadata`, `languages`, `welcomeCard`, `blocks`, `endings`, `hiddenFields`, and `variables` replace their full top-level value when provided. Missing ids in replaced arrays are deletions. Non-default locale keys in translatable maps must be declared in `languages`; undeclared locale keys are rejected with `unsupported_locale`. To remove a language, remove it from both `languages` and every translatable map that contains that locale key. Use `/api/v3/surveys/validate` with `operation: patch` before committing automated or agentic changes. properties: id: type: string description: Read-only echo. Must match the survey being patched. workspaceId: type: string description: Read-only echo. A survey cannot be moved between workspaces. type: type: string enum: - link - app description: Read-only echo. Immutable after creation. createdAt: type: string format: date-time description: Read-only echo of the creation timestamp. updatedAt: type: string format: date-time description: 'Read-only echo, and an optimistic-concurrency precondition: the write is applied only if the survey has not changed since the read this value came from, and is otherwise a **409**.' archivedAt: type: - string - 'null' format: date-time description: Read-only echo. Use `/archive` and `/restore` to change it. visibility: allOf: - $ref: '#/components/schemas/SurveyVisibility' description: Read-only echo. Accepted only unchanged, so a survey fetched with `GET` can be sent back; any other value is a **422** `unsupported_field`. Change it with `POST /api/v3/surveys/{surveyId}/visibility`. owner: anyOf: - $ref: '#/components/schemas/SurveyOwner' - type: 'null' description: Read-only echo, accepted only unchanged (otherwise **422** `unsupported_field`). access: allOf: - $ref: '#/components/schemas/SurveyAccess' description: Read-only echo, accepted only unchanged (otherwise **422** `unsupported_field`). defaultLanguage: type: string description: Read-only echo of the survey default language, in whatever shape `GET` returned — legacy surveys carry language-only codes, so this is not constrained to `LocaleCode`. name: type: string minLength: 1 status: type: string enum: - draft - inProgress - paused - completed metadata: allOf: - $ref: '#/components/schemas/SurveyMetadata' languages: type: array maxItems: 50 items: $ref: '#/components/schemas/PatchSurveyLanguage' description: | Replaces the survey language configuration. The default language cannot be changed through PATCH; any `default: true` entry must match the existing default language. Languages referenced by `metadata`, `welcomeCard`, `blocks`, or `endings` translatable maps must be explicitly declared here, so remove the locale key from those maps as well when intentionally removing a language. showLanguageSwitch: type: - boolean - 'null' description: Show respondents a language switch on multi-language surveys. `null` and `false` both mean off. Omit to keep the stored value. autoSelectLanguage: type: - boolean - 'null' description: Open link and app surveys in the respondent's browser language when it matches an enabled survey language; otherwise the default language is used. `null` and `false` both mean off. Omit to keep the stored value. welcomeCard: $ref: '#/components/schemas/SurveyWelcomeCard' blocks: type: array minItems: 1 maxItems: 1000 items: $ref: '#/components/schemas/SurveyBlock' endings: type: array maxItems: 100 items: $ref: '#/components/schemas/SurveyEnding' hiddenFields: $ref: '#/components/schemas/SurveyHiddenFields' variables: type: array maxItems: 100 items: $ref: '#/components/schemas/SurveyVariable' distribution: allOf: - $ref: '#/components/schemas/SurveyDistribution' description: | App-survey runtime/display settings. Only valid for stored `type: app`. Replaces the full distribution; omitted scalars reset to their defaults. targeting: allOf: - $ref: '#/components/schemas/SurveyTargeting' description: App-survey contact targeting. Only valid for stored app surveys. customCss: anyOf: - $ref: '#/components/schemas/CustomCssInput' - type: 'null' description: 'Survey custom CSS source. Omitted: unchanged. `null`: clears both fields. An object replaces both fields and needs both keys. Change is judged on normalized source, so echoing the value `GET` returned is a no-op that needs no plan. Adding or editing needs the Scale plan on Formbricks Cloud (403 `custom_css_plan_required`); clearing never does. A failed write keeps the saved CSS. Only an addition or edit is processed, so only it counts against the custom CSS budget of 60 per minute per user or API key, shared with custom CSS validation; beyond it the patch is a 429.' additionalProperties: false ValidateSurveyPatchRequest: type: object required: - operation - surveyId - data properties: operation: type: string enum: - patch surveyId: type: string format: cuid2 data: description: Intended patch payload. Invalid documents are allowed so the endpoint can return repairable validation errors. anyOf: - $ref: '#/components/schemas/PatchSurveyRequest' - type: object additionalProperties: true additionalProperties: false ValidateSurveyCustomCssRequest: type: object description: Validate custom CSS alone, for a live editor preview (ENG-3641). Processes the CSS exactly as a save would and returns the compiled result and warnings, without saving, versioning, touching timestamps or invalidating caches. Requires read access to `workspaceId`, and to `surveyId` when given; a valid result grants nothing — the write path checks permissions and the plan again. required: - operation - workspaceId - scope - data properties: operation: type: string enum: - customCss workspaceId: type: string format: cuid2 scope: type: string enum: - workspace - survey description: Which budget and cascade layer the CSS is processed for. surveyId: type: string format: cuid2 description: 'Optional, `scope: survey` only. An existing survey, which must belong to `workspaceId`; omit it to validate an unsaved draft.' creativeType: type: string enum: - feedbackButton - banner description: 'Optional, `scope: survey` only and without `surveyId`. Validates a creative''s own Custom CSS (`styling.theme.customCss`) and compiles it for that creative type''s root element inside its shadow root, `.fb-creative-button` or `.fb-creative-banner`, instead of the survey root `#fbjs`: the CSS a visitor''s browser gets for the creative. Same budget and rules as survey CSS.' data: type: object required: - customCss properties: customCss: anyOf: - $ref: '#/components/schemas/CustomCssInput' - type: 'null' additionalProperties: false additionalProperties: false ValidateSurveyRequest: oneOf: - $ref: '#/components/schemas/ValidateSurveyCreateRequest' - $ref: '#/components/schemas/ValidateSurveyPatchRequest' - $ref: '#/components/schemas/ValidateSurveyCustomCssRequest' discriminator: propertyName: operation mapping: create: '#/components/schemas/ValidateSurveyCreateRequest' patch: '#/components/schemas/ValidateSurveyPatchRequest' customCss: '#/components/schemas/ValidateSurveyCustomCssRequest' SurveyValidationLanguage: type: object required: - code - default - enabled - writeBehavior properties: code: $ref: '#/components/schemas/LocaleCode' default: type: boolean enabled: type: boolean writeBehavior: type: string enum: - connect_or_create description: Validation is read-only; a real write would connect an existing language or create it if missing. additionalProperties: false CustomCssCompiled: type: object description: 'Compiled custom CSS returned by a successful validation: complete stylesheets wrapped in the custom CSS cascade layers and scoped to the survey root. `null` for a field with no CSS. Never returned on a failed validation, and never part of an authenticated resource (those carry source).' required: - light - dark properties: light: type: - string - 'null' dark: type: - string - 'null' additionalProperties: false CustomCssError: type: object description: 'A failure that rejects the whole custom CSS operation: nothing is saved, no compiled output is returned, and the previously saved CSS stays in effect.' required: - code - scope - appearance - line - column - reason properties: code: type: string enum: - syntax_error - source_too_large - output_too_large - limit_exceeded - processing_failed scope: type: string enum: - workspace - survey appearance: type: - string - 'null' enum: - light - dark - null line: type: - integer - 'null' minimum: 1 column: type: - integer - 'null' minimum: 1 reason: type: string additionalProperties: false SurveyValidationResult: type: object required: - valid - operation - invalid_params properties: valid: type: boolean description: Whether the submitted survey document is valid. operation: type: string enum: - create - patch invalid_params: type: array description: Repairable validation errors using document-relative paths. items: $ref: '#/components/schemas/InvalidParam' languages: type: array description: Languages that a successful write would connect or create. Present only when `valid=true`. items: $ref: '#/components/schemas/SurveyValidationLanguage' customCss: $ref: '#/components/schemas/CustomCssCompiled' description: Compiled custom CSS, when the payload carried `customCss` and `valid=true`. Never present on a failed validation. warnings: type: array maxItems: 50 description: Constructs the processor removed from the payload's `customCss`, when it carried one. items: $ref: '#/components/schemas/CustomCssWarning' errors: type: array maxItems: 50 description: Why the payload's `customCss` could not be processed, when it could not. items: $ref: '#/components/schemas/CustomCssError' additionalProperties: false CustomCssValidationResult: type: object description: 'Result of `operation: customCss`. On success it carries the compiled CSS and warnings; on failure the processor''s errors, with no compiled output.' required: - valid - operation - invalid_params properties: valid: type: boolean operation: type: string enum: - customCss invalid_params: type: array items: $ref: '#/components/schemas/InvalidParam' customCss: $ref: '#/components/schemas/CustomCssCompiled' warnings: type: array maxItems: 50 items: $ref: '#/components/schemas/CustomCssWarning' errors: type: array maxItems: 50 items: $ref: '#/components/schemas/CustomCssError' additionalProperties: false LinkedCreativesProblem: allOf: - $ref: '#/components/schemas/Problem' - type: object required: - details properties: details: type: object required: - creatives properties: creatives: type: array minItems: 1 description: The non-archived creatives whose Destination is this survey, by name. items: type: object required: - id - name properties: id: type: string description: Creative identifier. name: type: string additionalProperties: false SurveyBlockUpdateOperation: type: object additionalProperties: false description: Replace one existing block, addressed by id. required: - op - id - block properties: op: type: string enum: - update id: type: string maxLength: 128 minLength: 1 description: Block to replace. block: allOf: - $ref: '#/components/schemas/CreateSurveyBlock' description: | Replaces the target block entirely — the same whole-subtree rule PATCH uses. `block.id` is optional and taken from `id` when omitted; if present it must equal `id`. SurveyBlockInsertPosition: oneOf: - type: object additionalProperties: false required: - type properties: type: type: string enum: - start description: Insert before every existing block. - type: object additionalProperties: false required: - type properties: type: type: string enum: - end description: Insert after every existing block. - type: object additionalProperties: false required: - type - blockId properties: type: type: string enum: - after blockId: type: string maxLength: 128 minLength: 1 description: | Block to insert after. May be a block inserted by an earlier operation in the same request, since operations are applied in order. description: Where an inserted block goes. Anchored to a block id, never to a positional index. SurveyBlockInsertOperation: type: object additionalProperties: false description: Add a new block at an anchored position. required: - op - block - position properties: op: type: string enum: - insert block: allOf: - $ref: '#/components/schemas/CreateSurveyBlock' description: | New block. `id` is optional and generated when omitted, matching survey creation. Supply one only when a later operation in the same request anchors after it; if supplied it must not already exist on the survey. position: $ref: '#/components/schemas/SurveyBlockInsertPosition' SurveyBlockRemoveOperation: type: object additionalProperties: false description: Delete one existing block, addressed by id. required: - op - id properties: op: type: string enum: - remove id: type: string maxLength: 128 minLength: 1 description: | Block to delete, along with every element in it. Answers already collected for those elements are orphaned and the API cannot undo that. SurveyBlockOperation: oneOf: - $ref: '#/components/schemas/SurveyBlockUpdateOperation' - $ref: '#/components/schemas/SurveyBlockInsertOperation' - $ref: '#/components/schemas/SurveyBlockRemoveOperation' discriminator: propertyName: op mapping: update: '#/components/schemas/SurveyBlockUpdateOperation' insert: '#/components/schemas/SurveyBlockInsertOperation' remove: '#/components/schemas/SurveyBlockRemoveOperation' description: A single block operation. EditSurveyBlocksRequest: type: object additionalProperties: false required: - ops properties: ops: type: array minItems: 1 maxItems: 100 items: $ref: '#/components/schemas/SurveyBlockOperation' description: | Operations applied in order against a single snapshot, atomically — either all of them land or none do. Each operation resolves against the state left by the previous one, so an insert may anchor after a block an earlier operation inserted, and a remove followed by an insert of the same id is legal. expectedUpdatedAt: type: string format: date-time description: | Optimistic-concurrency precondition: the survey's `updatedAt` from your last read. If the survey changed since, the write is rejected with **409** and the response carries the current value. Omit it to accept last-write-wins. SetSurveyBlockOrderRequest: type: object additionalProperties: false required: - order properties: order: type: array minItems: 1 maxItems: 1000 items: type: string maxLength: 128 minLength: 1 description: | Every current block id, exactly once, in the desired order. Ids are trimmed before use, so an id that is empty or whitespace-only is a **400**, as is a list of more than 1000 ids. A missing, unknown or repeated id is a **422** — repeats are reported as `duplicate_identifier` rather than rejected by the schema, so the response names the offending entry, and a dropped block cannot pass unnoticed, which is what makes this safer than resending the blocks. expectedUpdatedAt: type: string format: date-time description: | Optimistic-concurrency precondition: the survey's `updatedAt` from your last read. If the survey changed since, the write is rejected with **409**. SurveyVisibilityBlocker: type: object description: |- An outbound connection that must be removed before the survey can become restricted. A `creative` is a non-archived creative whose Destination is this survey: delete it (pausing or closing it keeps it linked). New `type` values may appear as Formbricks gains connection types (`creative` was added in 6.2). Treat a value you don't recognise as a generic connection: list it by `name`. required: - type - id - name properties: type: type: string enum: - webhook - integration - feedbackSource - dashboard - workflow - creative id: type: string name: type: string description: Display name of the connection, for the UI to list. Never empty; falls back to the type. additionalProperties: false SurveyVisibilityImpact: type: object description: The numbers the confirmation dialogues show. required: - memberCount - responseCount properties: memberCount: type: integer minimum: 0 description: People other than the owner and the organisation's owners/managers who can see the survey now (if workspace-visible) or would gain access (if restricted). responseCount: type: integer minimum: 0 description: Responses on the survey, partial ones included. additionalProperties: false SurveyVisibilityState: allOf: - type: object required: - id properties: id: type: string description: Survey identifier. - $ref: '#/components/schemas/SurveyVisibilityFields' - type: object required: - blockers - impact properties: blockers: type: array items: $ref: '#/components/schemas/SurveyVisibilityBlocker' description: Connections that make `restricted` refusable right now. Empty when being restricted is allowed, and always empty for the `workspace` direction. impact: $ref: '#/components/schemas/SurveyVisibilityImpact' - type: object required: - pending - version - allowedTargets properties: pending: anyOf: - $ref: '#/components/schemas/SurveyVisibility' - type: 'null' description: The value a `POST` stored that the graph has not yet acknowledged. While non-null the survey is restricted for everyone but the owner (for as long as they keep the workspace access the action needs — read to view, write to edit) and the organisation's owners and managers, whichever value is pending — `visibility` reads `restricted`. Always `null` for a survey just created or copied whose visibility has not been changed yet — its stored value is in effect at once. `"restricted"` means the restriction is already enforced and only the graph is catching up; `"workspace"` means the grant is not yet in effect. `null` normally. Clears when the outbox delivers (within 60 seconds, or the deployment fails closed), or when a later `POST` cancels or supersedes it. version: type: integer minimum: 0 description: 'Monotonic per survey; incremented by every accepted `POST`, including cancellations. Projection work is fenced on it: a projector holding an older version never writes. `0` for a survey whose visibility was never changed through this endpoint.' allowedTargets: type: array items: $ref: '#/components/schemas/SurveyVisibility' uniqueItems: true description: Values a `POST …/visibility` by this caller would act on right now. Never the enforced value — **except** when a different value is pending, in which case the enforced value is listed because requesting it cancels the pending change. Never `restricted` while `blockers[]` is non-empty or `owner` is `null`. Empty when nothing would happen. Drives the row menu and the editor control. SurveyVisibilityChangeRequest: type: object required: - visibility properties: visibility: $ref: '#/components/schemas/SurveyVisibility' additionalProperties: false SurveyVisibilityActor: type: object description: Who made the change. Always a user, since API keys cannot change visibility. required: - type - id - name properties: type: type: string enum: - user id: type: string name: type: string additionalProperties: false SurveyVisibilityChangeResult: allOf: - type: object required: - id properties: id: type: string description: Survey identifier. - $ref: '#/components/schemas/SurveyVisibilityFields' - type: object required: - version - pending - changedAt - changedBy properties: version: type: integer minimum: 0 description: The survey's visibility version after this call. Unchanged by a no-op. pending: anyOf: - $ref: '#/components/schemas/SurveyVisibility' - type: 'null' description: '`"restricted"` when the restriction is enforced but the graph is still catching up (the outbox will finish it); `null` otherwise. A grant never returns 200 with a pending value.' changedAt: type: - string - 'null' format: date-time description: When the current visibility was last set through this endpoint. `null` when it never was — the survey still has its creation visibility, which is the case for a no-op on a survey nobody has touched. Unchanged by a no-op otherwise. changedBy: anyOf: - $ref: '#/components/schemas/SurveyVisibilityActor' - type: 'null' description: Who last set it. `null` exactly when `changedAt` is `null`. VisibilityBlockedProblem: allOf: - $ref: '#/components/schemas/Problem' - type: object required: - details properties: details: type: object required: - blockers properties: blockers: type: array minItems: 1 items: $ref: '#/components/schemas/SurveyVisibilityBlocker' description: The same list `GET …/visibility` returns as `blockers[]`. ResponseResolution: type: object description: | Receipt describing how `answers[]` and `embeddedData[]` labels were produced. It exists so a consumer can tell whether the labels it is reading are the wording the respondent actually saw. required: - labelPolicy - labelsLanguage - surveyUpdatedAt properties: labelPolicy: type: string enum: - currentSurveyDefinition description: | Where labels came from. Today the only value is `currentSurveyDefinition`: labels are resolved at **read time** against the survey as it stands now. Nothing about the survey is snapshotted when a response is written and there is no survey-version model, so a response collected under an older headline reads back under today's wording — text the respondent may never have seen. Typeform, SurveyMonkey, Qualtrics, Google Forms and Jotform all behave this way, so this is the industry norm rather than a shortcut. Compare `surveyUpdatedAt` against the response's `createdAt` to detect that the definition moved after collection. If a write-time label snapshot ever ships, a second value `asAsked` is added here and consumers branch on it — treat this as an open enum. labelsLanguage: type: - string - 'null' description: The language the labels in this payload were resolved in. Follows the response's `language` when it is set and the survey has that language enabled, otherwise the survey's default language. `null` when the survey carries no translations. surveyUpdatedAt: type: string format: date-time description: When the survey definition was last modified. Later than the response's `createdAt` means the wording in `elementLabel` may have drifted from what was asked. additionalProperties: false ResponseAnswerBase: type: object description: Fields every answer carries, whatever the element type. `elementType` selects the value-bearing shape — see `ResponseAnswer`. required: - position - elementId - elementType - elementLabel properties: position: type: integer minimum: 1 description: 1-based position of the element in the current survey definition, so a consumer can present answers in survey order without fetching the survey. Elements the respondent never reached are absent from `answers[]` entirely rather than present-and-empty. elementId: type: string description: Stable element id, as authored on the survey. elementType: type: string enum: - openText - multipleChoiceSingle - multipleChoiceMulti - nps - rating - csat - ces - consent - pictureSelection - cta - date - fileUpload - cal - matrix - address - ranking - contactInfo description: The element's type in the current survey definition. Matches the `type` of the corresponding `SurveyElement`. elementLabel: type: string description: The element's headline, resolved in `resolution.labelsLanguage`. Read at request time from the current definition — see `ResponseResolution` for what that means for older responses. durationSeconds: type: number minimum: 0 maximum: 86400 description: | Seconds the respondent spent on this element. Converted from the stored per-element time-to-complete, which is recorded in milliseconds. This telemetry is reported by the client and is therefore not trustworthy input: treat it as an indication rather than a measurement. Absent when the submission carried no timing for this element. The bounds hold on **every** row, including ones collected before the write-side clamp shipped. The stored representation is deliberately unbounded so that historical data still parses, and a row written then can hold a negative value or a tab left open for days; those are clamped into range on read rather than returned as-is, because a schema a real row violates is worse than a slightly blunted outlier. So `86400` means "at least 24 hours", not exactly. ResponseAnswerText: allOf: - $ref: '#/components/schemas/ResponseAnswerBase' - type: object required: - valueText properties: elementType: type: string enum: - openText valueText: type: string description: The submitted text, verbatim. inputType: type: string enum: - text - email - url - number - phone description: The element's configured input type in the current definition. Useful for rendering and for knowing how the value was validated at collection time; the value itself is always returned as a string, including for `number`. unevaluatedProperties: false description: Free-text answer to an `openText` element. ResponseAnswerNumber: allOf: - $ref: '#/components/schemas/ResponseAnswerBase' - type: object required: - valueNumber - range properties: elementType: type: string enum: - nps - rating - csat - ces valueNumber: type: number description: The submitted score. range: type: object description: The scale the score was collected on, from the current definition. Returned because a bare `8` is uninterpretable without it, and because scales are editable — a consumer comparing scores across time needs to see that the range moved. required: - min - max properties: min: type: number max: type: number additionalProperties: false scale: type: string enum: - number - smiley - star description: How the scale was presented. Carried by the element types that configure one — `rating`, `csat` and `ces` — and absent for `nps`, which has no scale presentation. unevaluatedProperties: false description: Numeric-scale answer. Covers `nps` (0–10), `rating` (configurable range and presentation), `csat` and `ces`. ResponseAnswerBoolean: allOf: - $ref: '#/components/schemas/ResponseAnswerBase' - type: object required: - valueBoolean - rawValue properties: elementType: type: string enum: - consent - cta valueBoolean: type: boolean description: Whether the respondent accepted (`consent`) or clicked through (`cta`). Derived from the stored token, which is why `rawValue` is returned alongside it. rawValue: type: string description: The token as stored — conventionally `accepted` for `consent` and `clicked` for `cta`. Returned because the boolean is a derivation and this is the underlying fact. unevaluatedProperties: false description: Acknowledgement answer. Covers `consent` (accepted) and `cta` (clicked through). A `cta` element the respondent skipped has no entry in `answers[]` at all. ResponseValueMatch: type: string enum: - exact - label - other - unmatched description: | How a stored raw value was resolved against the current survey definition. Precedence is fixed and applied in this order: - `exact` — the stored value matched an option **id** in the current definition. - `label` — the stored value matched an option's **label text** (ids are stable, labels are editable and language-dependent, so a label match is weaker evidence). - `other` — the value arrived through the element's **Other** free-text input. Only reported when the element actually has that input enabled and the value was submitted through it. - `unmatched` — no option resolves. The raw value is still returned; it is **not** silently reclassified as `other`, because a *renamed* option is also unmatched text and filing it as a write-in would misattribute the answer. ResponseSelection: type: object description: | One selected option within a choice-style answer. `optionId` plus `rawValue` is deliberately a two-field split — the same split the Hub stores as `value_id` plus `value_text` — because option ids are stable while labels are editable and language-dependent. Consumers that need to group or compare across time should key on `optionId`; consumers rendering to a human should use `optionLabel`. required: - optionId - optionLabel - rawValue - match properties: optionId: type: - string - 'null' description: Resolved option id, or the literal `other` for a write-in. `null` when `match` is `unmatched`. optionLabel: type: - string - 'null' description: Option label in the resolved language. `null` when `match` is `unmatched`. rawValue: type: string description: 'The value as stored on the response, verbatim and before resolution — always present, and the string to use when rebuilding the stored map. Note what is stored differs by element type: `multipleChoiceSingle`, `multipleChoiceMulti` and `ranking` store the option **label** in the respondent''s language, while `pictureSelection` stores the option **id**. `""` occurs when the respondent selected Other and left it blank, and is returned rather than dropped.' match: $ref: '#/components/schemas/ResponseValueMatch' rank: type: integer minimum: 1 description: 1-based rank, present only for `ranking` elements. Array order already reflects the rank; this field makes it explicit so consumers need not rely on ordering. additionalProperties: false ResponseAnswerSelection: allOf: - $ref: '#/components/schemas/ResponseAnswerBase' - type: object required: - selections properties: elementType: type: string enum: - multipleChoiceSingle - multipleChoiceMulti - pictureSelection - ranking selections: type: array description: The chosen options, resolved against the current definition. Always an array, including for `multipleChoiceSingle`, so consumers need one code path rather than two. For `ranking`, the array is in rank order and each entry also carries an explicit `rank`. items: $ref: '#/components/schemas/ResponseSelection' unevaluatedProperties: false description: Choice-style answer. Covers `multipleChoiceSingle`, `multipleChoiceMulti`, `pictureSelection` and `ranking`. See `ResponseValueMatch` for how each selection was resolved and why an unresolvable value is never reclassified as a write-in. ResponseAnswerDate: allOf: - $ref: '#/components/schemas/ResponseAnswerBase' - type: object required: - valueDate properties: elementType: type: string enum: - date valueDate: type: string description: The submitted date as stored, an ISO-8601 date or date-time string. No `format` is asserted because historical rows were written by several client versions and the stored precision varies; parse defensively. unevaluatedProperties: false description: Date answer. ResponseAnswerFileUpload: allOf: - $ref: '#/components/schemas/ResponseAnswerBase' - type: object required: - fileUrls properties: elementType: type: string enum: - fileUpload fileUrls: type: array description: | The uploaded files, **as stored** — workspace-relative paths, not absolute URLs, and neither resolved nor signed. A deliberate choice between two things this field could be. Storage writes a relative path and the app's own read paths rewrite it to an absolute URL for display; v3 returns the stored bytes instead, because `answers[]` promises verbatim values a client can write straight back, and an absolute URL is neither what is stored nor what a write accepts. Fetching a file is a separate concern with its own ticket (ENG-513, signed short-lived URLs behind a dedicated endpoint); this field identifies the file, it does not serve it. Not publicly fetchable in any case: access follows the same authorization as the response, and deleting the response deletes the underlying files. items: type: string fileCount: type: integer minimum: 0 description: Convenience count of `fileUrls`, so a list consumer need not materialize the array. unevaluatedProperties: false description: File-upload answer. ResponseAnswerBooking: allOf: - $ref: '#/components/schemas/ResponseAnswerBase' - type: object required: - booked - rawValue properties: elementType: type: string enum: - cal booked: type: boolean description: Whether the respondent completed a booking. rawValue: type: string description: The token as stored, verbatim — conventionally `booked`. Required, like the equivalent field on `consent` and `cta`, because `booked` is a derivation and this is the underlying value a client needs to rebuild the stored map. unevaluatedProperties: false description: Scheduling answer for a `cal` element. Only the fact of the booking is recorded on the response; the meeting itself lives in the scheduling provider and is not proxied by this API. ResponseAnswerMatrix: allOf: - $ref: '#/components/schemas/ResponseAnswerBase' - type: object required: - rows properties: elementType: type: string enum: - matrix rows: type: array description: One entry per row the respondent answered. Rows left blank are omitted rather than returned with a null column. Both sides of a matrix cell need resolving against the current definition, so each entry reports its own `match` — see `ResponseValueMatch`. items: type: object required: - rawKey - rowLabel - rawValue - columnLabel - match properties: rawKey: type: string description: The stored map key for this row, verbatim. Matrix answers are stored keyed by the **localized row label** in the respondent's language, not by row id — so this is the string to use when rebuilding the stored object, and it is not necessarily equal to `rowLabel`, which is resolved in the *current* definition's language. rowId: type: - string - 'null' description: Row id resolved from the current definition, or `null` when the stored key matches no current row. Derived, never stored. rowLabel: type: string description: Row label in the resolved language. Falls back to `rawKey` when the row no longer resolves. columnId: type: - string - 'null' description: Column id resolved from the current definition, or `null` when it no longer resolves. Derived, never stored. columnLabel: type: - string - 'null' description: Column label in the resolved language. `null` when `match` is `unmatched`. rawValue: type: string description: The selected column as stored, verbatim — the localized column label, not a column id. This is the value to use when rebuilding the stored object. match: $ref: '#/components/schemas/ResponseValueMatch' additionalProperties: false unevaluatedProperties: false description: Matrix answer — one selected column per answered row. ResponseAnswerComposite: allOf: - $ref: '#/components/schemas/ResponseAnswerBase' - type: object required: - fields properties: elementType: type: string enum: - address - contactInfo fields: type: array description: | The element's sub-fields, **every slot, in storage order**, including ones the respondent left blank (`valueText: ""`). Blanks are returned rather than omitted because the stored value is a positional fixed-length array — `address` has 6 slots, `contactInfo` has 5 — so dropping an empty slot would shift every later value onto the wrong field and make the stored array impossible to rebuild. items: type: object required: - slot - fieldId - fieldLabel - valueText properties: slot: type: integer minimum: 0 description: 0-based index of this sub-field in the stored array. Reconstruct the stored value by ordering on this and taking `valueText`. fieldId: type: string enum: - addressLine1 - addressLine2 - city - state - zip - country - firstName - lastName - email - phone - company description: Stable sub-field id. `address` uses `addressLine1`, `addressLine2`, `city`, `state`, `zip`, `country` in that order; `contactInfo` uses `firstName`, `lastName`, `email`, `phone`, `company`. fieldLabel: type: string description: Sub-field label in the resolved language. valueText: type: string description: The submitted value, verbatim. `""` when the respondent left it blank. additionalProperties: false unevaluatedProperties: false description: Composite answer whose value is a set of labelled sub-fields. Covers `address` and `contactInfo`. Stored as a positional array of strings, which is why every slot is reported. ResponseAnswer: oneOf: - $ref: '#/components/schemas/ResponseAnswerText' - $ref: '#/components/schemas/ResponseAnswerNumber' - $ref: '#/components/schemas/ResponseAnswerBoolean' - $ref: '#/components/schemas/ResponseAnswerSelection' - $ref: '#/components/schemas/ResponseAnswerDate' - $ref: '#/components/schemas/ResponseAnswerFileUpload' - $ref: '#/components/schemas/ResponseAnswerBooking' - $ref: '#/components/schemas/ResponseAnswerMatrix' - $ref: '#/components/schemas/ResponseAnswerComposite' discriminator: propertyName: elementType mapping: openText: '#/components/schemas/ResponseAnswerText' nps: '#/components/schemas/ResponseAnswerNumber' rating: '#/components/schemas/ResponseAnswerNumber' csat: '#/components/schemas/ResponseAnswerNumber' ces: '#/components/schemas/ResponseAnswerNumber' consent: '#/components/schemas/ResponseAnswerBoolean' cta: '#/components/schemas/ResponseAnswerBoolean' multipleChoiceSingle: '#/components/schemas/ResponseAnswerSelection' multipleChoiceMulti: '#/components/schemas/ResponseAnswerSelection' pictureSelection: '#/components/schemas/ResponseAnswerSelection' ranking: '#/components/schemas/ResponseAnswerSelection' date: '#/components/schemas/ResponseAnswerDate' fileUpload: '#/components/schemas/ResponseAnswerFileUpload' cal: '#/components/schemas/ResponseAnswerBooking' matrix: '#/components/schemas/ResponseAnswerMatrix' address: '#/components/schemas/ResponseAnswerComposite' contactInfo: '#/components/schemas/ResponseAnswerComposite' description: | One answer, carrying the element's id, its label, its type and its value. `elementType` selects the shape: switch on it rather than probing for which value field is present. Answers are a **typed array**, not a map keyed by element id, because a consumer holding only a response would otherwise see bare ids and have to fetch the survey and join to learn what was asked — a join that is easy to get wrong and that agent clients cannot make cheaply. The array also separates answers from hidden fields and variables, which the stored representation mixes into one flat map. Non-answer data lives in `embeddedData[]`. Several element types share a value shape and therefore a schema — the four numeric scales, the four choice styles, the two composites. All seventeen element types are covered. Only elements the respondent actually reached appear here. Data whose element has since been deleted appears in `unresolved[]` instead. ResponseEmbeddedDatum: type: object description: | One piece of non-answer data carried by the response. Survey **variables**, **hidden fields** and **auto-captured context** are one concept here rather than three parallel ones: they all have a key, a provenance, a declared type and a label, and consumers treat them alike. Auto-captured (`reserved`) entries are drawn from the Embedded Data field catalog, which declares a privacy policy per field (`keep` / `drop` / `redactQuery`). Two of those act **before** this API sees the response, and one is a rule this API adds: - `drop` is an **ingest-time** policy, not a filter applied here: when the survey has "Anonymize responses" enabled the field is never captured, so it is absent from the response for every consumer. With anonymization off it is captured and appears here normally. - `redactQuery` is applied on **every** read, anonymization or not — `url`, `pageReferrer` and `visitEntryReferrer` carry their query string stripped. - `ipAddress` is **never** projected by this API, in any view. That is a v3 rule rather than a catalog behaviour: the catalog reads the field, and the dashboard shows it. Do not infer the same suppression for the other `drop` fields. Where a survey's own field and a catalog entry share a name, both appear — the declared one as `ingested` or `computed`, the auto-captured one as `reserved` — and `kind` is what tells them apart. Neither shadows the other, because each is a real value the response carries and dropping one would hide data the author can see elsewhere. Which catalog entries appear at all is a third axis, `display`, and this collection carries the ones that are not `none`. The `none` set is the response's own identity and timing — `responseId`, `surveyId`, `finished`, `language`, `durationSeconds`, `startedAt`, `finishedAt` — every one of which is already a first-class field on the response. Without that filter they would each appear twice in the same payload, once at the top level and once here. required: - key - kind - type - label - value properties: key: type: string description: | The field's **name** — what an author calls it, and the key `embeddedData` on a write accepts. Not the storage key. A hidden field happens to be stored under its name and a variable under its cuid, and that split is a property of the storage layout rather than of the field; exposing it would publish an internal id and make the writable and readable views of one field disagree on how to address it. **Unique per `kind`, not across the collection.** A survey created before the reserved names were enforced can declare a hidden field called `country`, and the auto-captured `country` still exists — so the same name can appear once as `ingested` and once as `reserved`. That set is finite and no longer growing: a new declaration may no longer take a reserved name, or the name of a variable or hidden field the survey already has. Key on `(key, kind)` rather than `key` alone, or read the pair you care about. kind: type: string enum: - ingested - computed - reserved description: | Provenance of the value: - `ingested` — supplied by the caller or the embedding page (hidden fields). - `computed` — derived during the submission by survey logic (variables). - `reserved` — auto-captured context from the submission environment (for example `country`, `deviceType`), drawn from the Embedded Data field catalog. type: type: string enum: - string - number - boolean - date description: Declared type of `value`, so a consumer need not infer it. `computed` entries are only ever `string` or `number`, because survey variables are typed that narrowly. label: type: string description: Human-readable name for the key, in the resolved language where one exists. value: oneOf: - type: string - type: number - type: boolean description: |- The value, matching `type`. `date` values are ISO-8601 strings. Always a scalar. A legacy row can hold an array or an object under a declared field's key — the ingest contract refuses those now, but older rows have them — and such a value resolves to nothing rather than being coerced, so the entry is omitted here and reported in `unresolved[]` with the bytes intact. Nothing is silently dropped, and no `type` would describe an array anyway. There is no `null` member: a declared key that resolves to nothing is **omitted** from the collection rather than carried with a null. So `embeddedData[]` enumerates what the response actually holds, not what the survey declares — a survey with twelve declared fields and two filled in yields two entries. A field with a default value is never unset, since the default is the fallback before the key is considered missing. additionalProperties: false ResponseRawValue: description: | One stored answer or field value, in the four shapes the v3 contract can carry: a string, a number, a list of strings, or a string-to-string map. Declared once and referenced by `ResponseDataMap` and `ResponseUnresolvedEntry.rawValue`, so the two places that publish stored JSON cannot drift apart. A stored value outside these shapes (a legacy JSON `null`, an array with non-string items, a map with non-string values) is not published at all rather than reaching a client as a body this contract would reject. oneOf: - type: string - type: number - type: array items: type: string - type: object additionalProperties: type: string ResponseUnresolvedEntry: type: object description: | Stored data that nothing in the current survey definition can account for. Deleting an element, a hidden field or a variable leaves its values behind on already-collected responses and nothing cleans them up, so the payload reports them here instead of dropping them silently or faking them into `answers[]` without a type or a label. Consumers doing analysis can ignore this array; consumers doing export or migration should not. **A declared field's `defaultValue` takes precedence over anything unreadable stored under its key**, and that is worth stating because this collection otherwise reads as exhaustive. Resolution falls back to the default before a value is ever judged unreadable, so a field that declares one always resolves — and the stored bytes, whatever their shape, are reported nowhere. Only a field with **no** `defaultValue` can reach `valueShapeMismatch`. A **scalar** under a defaultless field is not reported either, and the cause is the reporting guard rather than `locked`. Only `resolveEmbeddedValue` consults the lock; the guard that decides what reaches this collection admits objects only. So an uncoercible scalar — `"yes"` under a boolean field — resolves to nothing and is then excluded for being a scalar, lock or no lock. A non-scalar under a lockless, defaultless field is what does reach `valueShapeMismatch`. Neither omission is a silent drop by accident — one follows from resolution order, the other from the reporting guard — and both are written down so a consumer does not read `unresolved[]` as a guarantee that nothing was left behind. If either proves to matter in practice a reason code can be added without breaking anyone, whereas removing one could not — which is why this is documentation rather than a guess at a fifth enum member. required: - key - rawValue - reason properties: key: type: string description: The key as stored on the response. For `elementNotInSurvey` it is a former element id or hidden-field name; for `variableNotInSurvey` a variable's cuid, since that map is keyed by id rather than by name; and for `valueShapeMismatch` the id or name of the element or declared field that *does* still exist — that reason means the key resolved and the value did not. rawValue: description: The stored value, returned verbatim and untyped because nothing describes it any more. $ref: '#/components/schemas/ResponseRawValue' reason: type: string enum: - elementNotInSurvey - variableNotInSurvey - valueShapeMismatch description: | Why the entry could not be resolved: - `elementNotInSurvey` — nothing in the current definition claims this key. It covers a deleted element **and** a deleted hidden field, because the two cannot be told apart: an answer is stored under its element id and a hidden field under its name, in one shared map, and storage keeps no record of which kind a vanished key used to be. - `variableNotInSurvey` — no variable with this id exists in the current definition. Variables *are* distinguishable, because they live in their own map keyed by cuid rather than sharing the answer map. - `valueShapeMismatch` — the key resolves to a current element **or a declared Embedded Data field**, but the stored value's shape cannot be read as an answer of that element's type (for example a string where the element expects a selection list). Signals element-type churn rather than deletion. additionalProperties: false ResponseTag: type: object description: | A tag applied to a response, as a lightweight reference. Deliberately **not** the full `TagResource`: that shape carries a `count` of how many responses hold the tag, which would mean an extra aggregate per tag on every row of every page. Read tags in full — with counts and timestamps — from `GET /api/v3/tags`. required: - id - name properties: id: type: string format: cuid2 name: type: string description: Tag label as shown in the app. additionalProperties: false ResponseBase: type: object description: | Fields shared by both response views. Self-describing: `answers[]` carries each element's id, label, type and value, so a consumer can read a response without also fetching the survey. Deliberately open — `ResponseListItem` and `ResponseResource` compose this and close themselves, so that the detailed view can add fields without the base rejecting them. required: - id - surveyId - surveyName - workspaceId - createdAt - updatedAt - finished - endingId - language - resolution - answers - embeddedData - unresolved - tags properties: id: readOnly: true type: string format: cuid2 description: Response identifier. surveyId: type: string format: cuid2 description: The survey this response belongs to. surveyName: readOnly: true type: string description: The survey's current name, denormalized into the payload so a consumer listing responses need not resolve survey names separately. workspaceId: readOnly: true type: string format: cuid2 description: The owning workspace, resolved through the survey. Responses carry no workspace column of their own; this is a derived field. createdAt: readOnly: true type: string format: date-time description: | When the response was created. **Server-owned** — it cannot be supplied on create or changed by a patch. This is the AIP-148 output-only convention, and it is deliberate on two counts: monthly response metering counts rows by `(surveyId, createdAt)`, and a client-chosen `createdAt` can insert rows into the middle of another consumer's already-paginated stream. Backdating remains available on v1/v2; a dedicated import path is future work. updatedAt: readOnly: true type: string format: date-time description: When the response was last modified. Server-owned. Neither filterable nor sortable — it has no index, and being mutable it cannot anchor a pagination cursor. finished: type: boolean description: Whether the respondent reached an ending. endingId: type: - string - 'null' description: The ending card the respondent reached. `null` for unfinished responses. language: type: - string - 'null' description: The survey language this response was collected in, as a survey language code (not necessarily a BCP-47 locale — `default` is a legitimate value). `null` when the survey is single-language or no language was stamped. durationSeconds: readOnly: true type: number minimum: 0 description: |- Total seconds the respondent spent, summed from per-element timing and **excluding** the stored `_total` bucket, which the server maintains alongside real element ids and which would otherwise be double-counted. Absent when the submission carried no timing. Summing rather than reading `_total` is deliberate: the stored bucket is only written when a response is finished, so reading it would leave every partial response without a duration even when it carries per-element timing. This is also why the value can differ from the reserved `durationSeconds` catalog entry, which does read the bucket and rounds to whole seconds — that entry is not projected into `embeddedData[]`, so the two never appear side by side. resolution: readOnly: true $ref: '#/components/schemas/ResponseResolution' answers: readOnly: true type: array description: One entry per element the respondent reached, in survey order. Empty for a response that recorded no answers. items: $ref: '#/components/schemas/ResponseAnswer' embeddedData: readOnly: true type: array description: Hidden fields, variables and auto-captured context, as one collection. Empty when the response carries none. items: $ref: '#/components/schemas/ResponseEmbeddedDatum' unresolved: readOnly: true type: array description: Stored data whose element or key no longer exists in the survey definition. Empty in the normal case. items: $ref: '#/components/schemas/ResponseUnresolvedEntry' tags: type: array description: Tags applied to this response, as `{ id, name }` references. Empty when the response is untagged. items: $ref: '#/components/schemas/ResponseTag' ResponseListItem: allOf: - $ref: '#/components/schemas/ResponseBase' unevaluatedProperties: false description: | A survey response as returned by the list endpoint. Self-describing: `answers[]` carries each element's id, label, type and value, so a consumer can read a response without also fetching the survey. `answers[]` and `embeddedData[]` ship on the list view as well as the detailed view — they are the point of the resource, and withholding them from the list would force consumers into an N+1 of item reads. The list view omits only the identity and provenance fields: `contact`, `displayId` and `singleUseId`. See `ResponseResource` for those. ResponseListMeta: allOf: - $ref: '#/components/schemas/ListPaginationMeta' - type: object required: - totalCount - totalCountRelation properties: totalCount: type: - integer - 'null' minimum: 0 description: | Responses matching the current scope and filters across all pages, **counted only up to 10 000**. Exact below that; at the cap the value is exactly `10000` and means "at least 10 000", which `totalCountRelation` states explicitly. `null` unless `includeTotalCount=true`. The cap is not timidity — an exact count over this table grows superlinearly with the data while a capped one stays flat, so an uncapped total is the one part of a page whose cost is unbounded. Read `totalCountRelation` rather than testing for the magic number. totalCountRelation: type: - string - 'null' enum: - eq - gte - null description: | How to read `totalCount`: `eq` when it is exact, `gte` when the 10 000 cap was reached and it is a lower bound, `null` when `includeTotalCount=false`. This is the same eq/gte convention Elasticsearch uses for `hits.total.relation`, so a consumer can render "10,000+" honestly instead of claiming a precise figure it does not have. unevaluatedProperties: false description: The list `meta` for responses — pagination plus a bounded, opt-in total. Both counting fields are `required` but nullable, so `includeTotalCount=false` answers `null` rather than omitting them. ResponseDataMap: type: object description: | Answers as stored, **keyed by element id**. Hidden-field values do not live here. They are stored in the same underlying map, but that is a storage detail rather than part of this contract: a hidden field is an Embedded Data field, and Embedded Data is addressed by name through `embeddedData`. A hidden field's name appearing as a key here is rejected rather than silently written to the wrong namespace — with **422**, not 400, because knowing that a key names a hidden field means loading the survey, and these operations route every failure that needs stored state to 422. The problem carries `invalid_params[]` with `name` set to the offending key, `code: unsupported_field` and `referenceType: hiddenField`, so a client can point at it without parsing prose. A key that is **also** an element id is the exception, and it is **accepted**: the answer owns that address. Answers and hidden fields do share one slot, and the older surveys that declare a field named after a question have exactly one value under that key — but it is always the answer's. The field is dropped at ingest with `element_id_collision` (`packages/types/embedded-data-ingest.ts`) and filtered again in the renderer before it can reach a submission, precisely so a display-time field value cannot land on top of what the respondent answered. So on such a survey `answers[]` carries the value, `embeddedData` neither reports it nor accepts a write for it, and refusing the key here would block the only write that fits it. New surveys cannot reach that state through the editor or `POST`/`PATCH /api/v3/surveys`, which refuse an element id colliding with a declared field name. Both v1 management survey writes still accept one — that guard reads variable and hidden-field names but never element ids — so this set, unlike the reserved-name one, is still growing. Keeping the two apart is what lets the storage layout change underneath — the legacy columns go away eventually — without this contract either following it or breaking a round-trip it promised. The value shapes are the stored ones, and they differ by element type. `ResponseAnswer` documents each; `answers[]` on the read is the resolved view of this same map. **Bounds are a write-side concern and live on `ResponseDataMapInput`, not here.** This schema is the stored shape, and a read must describe what the API can actually return: the v1 and v2 write paths store this same map with no cardinality limit, so a response written through them can carry more keys — or a longer multi-select — than a v3 write would accept. Declaring the write bounds on this schema would make `GET /api/v3/responses/{responseId}` promise something it cannot keep for those rows. additionalProperties: $ref: '#/components/schemas/ResponseRawValue' ResponseDataMapInput: allOf: - $ref: '#/components/schemas/ResponseDataMap' - type: object description: | The same map on the way in, with the cardinality bounds a v3 write enforces. At most 500 keys, and at most 1000 entries in a multi-select array or matrix object. Both sit far above any real survey — a response holds one entry per element, a multi-select answer one per choice — and exist because every later reader pays per entry: the serializer's per-answer mapping, export columns, and the positional probe the "Other" response filter emits. Exceeding either is a **400** from the request schema, not a 422. Deliberately separate from `ResponseDataMap`, which the read returns. These bounds are v3-only — the v1 and v2 write paths store the same map without them — so a stored response may exceed them, and the read schema must not claim otherwise. maxProperties: 500 additionalProperties: if: type: array then: maxItems: 1000 else: if: type: object then: maxProperties: 1000 ResponseEmbeddedDataInput: type: object description: | Embedded Data values to write, **keyed by field name**. This is the write counterpart of `embeddedData[]` on the read, and it is keyed the way an author names a field rather than the way storage addresses it. A hidden field is stored under its name but a variable is stored under its cuid, and that split is an implementation detail of the storage layout — the server resolves a name to its storage key through the survey's Embedded Data links, so a caller never sees a cuid and never has to know which kind it is writing. **Which kinds are writable:** - `ingested` (hidden fields) — accepted, through the same ingest contract the SDK uses: the name must be declared on the survey, the value is coerced to the field's declared `type`, a `locked` field ignores the write, and the whole payload is bounded. A name the survey does not declare is a **422** rather than a silent drop — knowing whether a name is declared means loading the survey, and these operations route every failure that needs stored state to 422. - `computed` (variables) — accepted, because a management caller correcting a response needs to, and v1 and v2 both allow it today. - `reserved` — refused. Auto-captured context describes the submission environment, and a caller-supplied value would be fiction. The three exceptions are `source`, `url` and `action`, which `ResponseMetaInput` accepts for exactly the replay case. Values follow their field's declared type. A field the payload omits is left as it is; this is a merge over named fields, not a replacement of the collection. `null` **clears** a field. Omission and `null` therefore mean different things — leave it alone versus remove its value — which is the only reading that makes a merge-shaped map able to express deletion at all. A cleared field is then absent from `embeddedData[]` on the next read, exactly as one that was never written, and a field whose definition carries a `defaultValue` falls back to that default rather than disappearing. **An ambiguous name is refused.** The read says `key` is unique per `kind` rather than across the collection, because a survey predating the reserved-name guard can hold a variable and a hidden field under one name. This map has no `kind`, so on such a survey a name cannot say which field it means — and guessing would write the caller's value into the wrong one. A name that matches more than one declared field is a **422** naming it, for the same reason as an undeclared one; rename one side on the survey to clear it. The set is finite, frozen and tracked, and nothing new can enter it. Reserved names collide differently and are not ambiguous: `reserved` is not writable at all, so a name that matches both a declared field and a catalog entry resolves to the declared one. **At most 500 names.** Every name that matches no declared field becomes its own `invalid_params` entry naming it, so without a cap a large body answers with a 422 several times its own size. Far above any real survey; a v3-only bound, as on `data`. maxProperties: 500 additionalProperties: oneOf: - type: string - type: number - type: boolean - type: 'null' ResponseTtcMap: type: object description: | Per-element time-to-complete, keyed by element id, in **milliseconds**. Accepted on create so that a caller replaying a real submission can carry its timing; reads report the same information as `durationSeconds` in seconds, on each answer and summed at the top level. This is client-reported telemetry, not a measurement the server can vouch for, so each value is clamped into `[0, 86400000]` (24 hours per element) rather than rejected — a noisy timing value should never cost you the response. Any finite *value* is therefore accepted, and none is declared out of range. The number of **entries** is capped at 500, matching `data`: a response holds one timing per element, so anything beyond that is not telemetry the server can use. A map with more entries is **rejected** — `maxProperties` below is enforced during request validation, so the whole write fails rather than the excess being trimmed or stored. **Do not send a `_total` key.** The server owns that bucket: it drops any `_total` you supply and writes its own when the response is finished, by summing the element entries. Sending one therefore changes nothing rather than skewing the stored total, and `durationSeconds` on the read is computed from the element entries regardless. **An omitted map is not the same as an empty one.** Omitting `ttc` stores no timings at all and the response reports no `durationSeconds`; sending `{}` on a finished response stores a `_total` of zero, which reads as "completed instantly". maxProperties: 500 additionalProperties: type: number ResponseMetaInput: type: object description: | Submission context, on the narrow subset a caller can legitimately supply. A management-API create has no browser behind it, so unlike a client-side submission there is nothing for the server to auto-capture — if these are not sent they are simply absent, which is why the API accepts them at all rather than treating all context as server-derived. Deliberately **not** accepted here, because the routes derive them from the request itself and a caller-supplied value would be fiction: `country`, `userAgent` (the browser/OS/device breakdown is nested under it, there is no top-level `device` key) and `ipAddress`. `ipAddress` is additionally never projected by v3 on read, in any view. Nor are `openedByCreativeId` and `openedByCreativeName`: only the client ingest routes stamp them, after checking the creative opened that survey. The stored `meta` is wider than this input. It also carries a block of browser-runtime context — `pagePath`, `pageReferrer`, the five `utm*` keys, the four screen/viewport dimensions, `timezone` and `locale` — which the SDK captures and which this endpoint does **not** currently accept, so sending one is a 400. That is a gap rather than a decision: an integration replaying a real submission can legitimately carry `utmSource` or `pagePath`, and those are live catalog entries that read back in `embeddedData[]`. Whether v3 create accepts them is open on the write ticket. Reads surface what is stored as `reserved` entries in `embeddedData[]`. properties: source: type: string maxLength: 512 description: Where the submission came from — the integration or channel name. The field most worth setting on an API create, since nothing else can distinguish an imported or integration-created response from an organic one. url: type: string maxLength: 2048 description: The page or context the response was collected from. action: type: string maxLength: 512 description: The action or trigger that produced the submission, when one applies. additionalProperties: false CreateResponseRequest: type: object description: | Creates a survey response. **This is a real submission**: it runs the full response pipeline — webhooks, integrations, follow-ups, quota evaluation, and Hub ingestion once the response is finished — and it counts against metered monthly responses, exactly as a v1/v2 management create does today. It is not an import primitive; a dedicated import path is future work. The workspace is resolved from `surveyId`, so unlike `POST /api/v3/surveys` there is no `workspaceId` in the body — the caller cannot choose which scope it is authorized against. Unsupported fields are rejected rather than ignored. Absent by design, each for its own reason: - `createdAt` and `updatedAt` are **server-owned** — see `ResponseListItem.createdAt`. This is the one capability v3 does not carry over from v2, and it is why bulk historical loading still belongs on v2 until a dedicated import path exists. - `userId` is not accepted: a contact `userId` is a non-unique **attribute**, not a column, so it cannot safely identify who to attribute a response to. Link the contact explicitly with `contactId`. - `contactAttributes` is not accepted, and never was writable — v1/v2 snapshot it from the linked contact. v3 does not expose the snapshot at all; `contact` covers the need. - `meta.ipAddress`, and the derived `userAgent` / `country` / `device` family, are not accepted; see `ResponseMetaInput` for what is. required: - surveyId - finished - data properties: surveyId: type: string format: cuid2 description: The survey to record the response against. Immutable after creation. finished: type: boolean description: 'Whether this response is complete. Creating with `finished: true` fires the finish side of the pipeline immediately, including Hub ingestion.' data: $ref: '#/components/schemas/ResponseDataMapInput' embeddedData: $ref: '#/components/schemas/ResponseEmbeddedDataInput' ttc: $ref: '#/components/schemas/ResponseTtcMap' meta: $ref: '#/components/schemas/ResponseMetaInput' tags: type: array uniqueItems: true maxItems: 100 description: Tag ids to apply, so a tagged response can be created in one call rather than a create followed by a patch. Tags must already exist in the same workspace — create them via `POST /api/v3/tags`. Applied in the same transaction as the response. items: type: string format: cuid2 endingId: type: - string - 'null' description: The ending card the respondent reached. Must exist in the survey definition. language: type: - string - 'null' description: Survey language code to stamp on the response. Must be a language the survey has enabled. Omit for single-language surveys. contactId: type: string format: cuid2 description: Contact to attribute the response to. **Create-only** — a response cannot later be re-linked to a different contact. Must be a contact in the same workspace. displayId: type: string format: cuid2 description: Display (survey impression) this response answers. Create-only. Each display can back at most one response. singleUseId: type: string minLength: 1 maxLength: 255 description: 'Single-use link id, for surveys configured with single-use links. Create-only, and unique per survey. Bounded because the value is indexed with `surveyId`: an empty string would slip past the uniqueness pre-check and still be stored, and an oversize one overflows the index entry.' additionalProperties: false ResponseContact: type: object description: | The contact that submitted the response, when the response is identified. Present on the detailed view only — the list view omits it. `null` for anonymous (link-survey) responses. required: - id properties: id: type: string format: cuid2 description: Contact identifier. userId: type: - string - 'null' description: The contact's `userId` attribute — the id your application knows the person by. This is a contact **attribute**, not a column, and it is not guaranteed unique within a workspace, so it identifies the person for display but must not be used as a lookup key. `null` when the contact has no `userId` attribute set. additionalProperties: false ResponseResource: allOf: - $ref: '#/components/schemas/ResponseBase' - type: object required: - contact - displayId - singleUseId - data properties: data: allOf: - $ref: '#/components/schemas/ResponseDataMap' description: | Answers in their **stored** form — byte-for-byte the same map `POST /api/v3/responses` and `PATCH /api/v3/responses/{responseId}` accept. Returned so that a read can be edited and written straight back: take this map, change what you need, and `PATCH` it. Without it a client wanting to correct one answer would have to rebuild the map from `answers[]`, which is possible but is work the server can simply not impose. `answers[]` remains the field to read — it is typed, labelled and ordered, and it separates answers from hidden fields. This is the same data in the shape the write side speaks, offered alongside rather than instead. Present on the detailed view only. The list view omits it deliberately: on a page of 250 it would carry every answer twice, and a bulk reader is not editing. contact: anyOf: - $ref: '#/components/schemas/ResponseContact' - type: 'null' description: The contact that submitted the response, or `null` when the response is anonymous. Present on the detailed view only. displayId: type: - string - 'null' description: The display (survey impression) this response was submitted against, when one was recorded. singleUseId: type: - string - 'null' description: The single-use link id this response was submitted through, when the survey uses single-use links. unevaluatedProperties: false description: | A survey response in the detailed view, as returned by `GET /api/v3/responses/{responseId}` and by the write endpoints. Everything in `ResponseListItem`, plus the identity and provenance fields the list view omits. It also carries `data` — the answers as stored, returned unchanged, so a client can read a response, edit an answer and send it straight back. Embedded Data is not echoed in stored form: the read's `embeddedData[]` and a request's `embeddedData` map already address the same fields by the same names, so a second raw map would only republish the storage layout. That is the property v1/v2 had for free by being symmetric, and the one OpenAPI 3.1.1 argues for directly: its "Validating `readOnly` and `writeOnly`" section notes that making clients strip fields before a write is "burdensome for clients, particularly when the JSON data is complex or deeply nested". They are not the *same* view, and the differences are deliberate. A write accepts `ingested` and `computed` fields only: a `reserved` entry describes the submission environment, and being able to read one back is not a licence to author it. An unset field is **omitted** from the read while `null` on a write **clears** one, because a merge-shaped map has no other way to spell deletion. And a name the read returns twice under different `kind`s cannot be written at all — see `ResponseEmbeddedDataInput`, which refuses an ambiguous name rather than guessing. Values themselves round-trip: within those two kinds every value the read emits is accepted verbatim on a write, and a `date` reads and writes as the same ISO-8601 string. So a read-edit-write cycle loses nothing except in two cases, both confined to rows v1 or v2 wrote. The first is the finite set of legacy surveys carrying one name as both a variable and a hidden field, where the write refuses the name outright. The second is a `data` map holding more keys than `ResponseDataMapInput` accepts: the stored map is unbounded and the write bound is not, so such a response cannot be patched here — and trimming it to fit is not a workaround, since `data` is replaced wholesale and an omitted key is a deletion. Note which fields carry `readOnly` here and which do not, because it is the opposite of what an "echo" framing would suggest. `readOnly` means a value is managed exclusively by the server, so it marks the **derived** views — `answers[]`, `embeddedData[]`, `unresolved[]`, `resolution`, the denormalized `surveyName` and `workspaceId`, the timestamps, `durationSeconds` and `id`. `data` is *not* read-only: it is exactly what the write endpoints accept, and leaving it unmarked is what makes this representation round-trippable rather than merely informative. To correct one answer, read this resource, take `data`, change the entry you want and `PATCH` `{ "data": … }`. Send only the fields you are changing — `PATCH` rejects unknown and non-patchable keys, so passing the whole representation back verbatim is not the intended flow; passing `data` back is. **Never exposed on this resource, in either view:** the respondent's IP address, and the `contactAttributes` snapshot that v1/v2 return. That snapshot is copied from the contact at submission time and never updated after, so it is stale PII by construction — `contact` covers the legitimate need to know who answered. ValidateResponseCreateRequest: type: object required: - operation - data properties: operation: type: string enum: - create data: description: 'Intended create payload, checked against `CreateResponseRequest` by the operation rather than by this envelope. Any JSON value is accepted here so the endpoint can return repairable validation errors: a document that is not an object, or that breaks `CreateResponseRequest`, is a **200** with `valid: false`, not a 400. Only the request limits come first: an array past its budget, or nesting past 256 levels, is a 400 for any request.' additionalProperties: false ValidateResponsePatchRequest: type: object required: - operation - responseId - data properties: operation: type: string enum: - patch responseId: type: string format: cuid2 data: description: 'Intended patch payload, checked against `PatchResponseRequest` by the operation rather than by this envelope. Any JSON value is accepted here so the endpoint can return repairable validation errors: a document that is not an object, or that breaks `PatchResponseRequest`, is a **200** with `valid: false`, not a 400. Only the request limits come first: an array past its budget, or nesting past 256 levels, is a 400 for any request.' additionalProperties: false ValidateResponseRequest: oneOf: - $ref: '#/components/schemas/ValidateResponseCreateRequest' - $ref: '#/components/schemas/ValidateResponsePatchRequest' discriminator: propertyName: operation mapping: create: '#/components/schemas/ValidateResponseCreateRequest' patch: '#/components/schemas/ValidateResponsePatchRequest' ResponseValidationEffects: type: object description: | What a successful write **would** do, reported without doing it. This mirrors the detail that makes `POST /api/v3/surveys/validate` useful: it reports effects, not just a verdict, so a caller can see the consequences of a payload before committing thousands of them. Present only when `valid` is `true`. required: - language - firesPipeline - countsTowardMeteredResponses properties: language: type: - string - 'null' description: The survey language code that would be stamped on the response. `null` means the response would be stored **without** a language — the case worth catching before a bulk write, since language-less responses cannot later be told apart by language. contactId: type: - string - 'null' description: The contact that would be linked, or `null` if the response would be anonymous. displayId: type: - string - 'null' description: The display that would be linked, or `null` if none. firesPipeline: type: boolean description: 'Whether a real write would run the response pipeline — webhooks, integrations, follow-ups, and Hub ingestion. Always `true`: a create emits `responseCreated` and every patch emits `responseUpdated`, so a write is never side-effect free. A patch that transitions the response to finished additionally emits `responseFinished`; the transition decides which events go out, not whether any do.' countsTowardMeteredResponses: type: boolean description: Whether a real write would consume one metered monthly response. quotas: type: array description: Quotas this response would count against, and whether it would fill them. Empty when the survey defines no quotas or the plan does not include them. items: type: object required: - quotaId - quotaName - wouldCount properties: quotaId: type: string format: cuid2 quotaName: type: string wouldCount: type: boolean description: Whether this response matches the quota's criteria. wouldFill: type: boolean description: Whether counting this response would take the quota to its limit. Absent when `wouldCount` is `false`. additionalProperties: false tagsToApply: type: array description: Tag ids that would end up applied to the response. Patch operations only. items: type: string format: cuid2 additionalProperties: false ResponseValidationResult: type: object description: | Outcome of a dry-run validation. Deliberately the same shape as `SurveyValidationResult` — the value of this endpoint is that the two behave alike, so a client can treat validation uniformly. Answers are checked against the survey's own validation rules (`SurveyValidationRule`) rather than a parallel rule set, so a payload this endpoint accepts is a payload the real write accepts. required: - valid - operation - invalid_params properties: valid: type: boolean description: Whether the submitted payload would be accepted by the real operation. operation: type: string enum: - create - patch invalid_params: type: array description: Repairable validation errors, using paths relative to the submitted `data` document. items: $ref: '#/components/schemas/InvalidParam' effects: $ref: '#/components/schemas/ResponseValidationEffects' additionalProperties: false BatchDeleteResponsesRequest: type: object description: | Ids to delete. A POST custom method rather than a `DELETE` on the collection: a collection-level `DELETE` that loses its filters degrades into erasing every response in scope, and a distinct path cannot be reached by accident — which is exactly the shape `feedbackRecords` ships today, a collection `DELETE` whose filters are the only thing standing between a caller and the whole dataset. AIP-165 blesses the custom-method form. required: - ids properties: ids: type: array minItems: 1 maxItems: 100 uniqueItems: true description: Response ids to delete, at most 100 per call. items: type: string format: cuid2 additionalProperties: false PatchResponseRequest: type: object description: | Partially updates a response. Strict at the top level: unknown keys return **400** rather than being ignored, and at least one patchable field must be present. `data` is **replaced wholesale**, not deep-merged — send the complete answer map you want stored. `embeddedData` is the exception and merges by name: it addresses individual fields, so a field the payload omits keeps its value rather than being cleared. v3 replaces on purpose, where v1 and v2 merge: a merge makes it impossible to delete an answer, which a correction flow needs. **Every patch runs the response pipeline**, emitting `responseUpdated` — so webhooks, integrations and follow-ups fire for a correction as much as for a substantive edit. A patch that *transitions* the response to finished additionally emits `responseFinished`; one that patches an already-finished response does not re-emit it. Not patchable, and rejected if sent: `createdAt`, `updatedAt`, `surveyId`, `contactId`, `displayId`, `singleUseId`, `contactAttributes`, and the create-only `ttc` and `meta` — timing and submission context describe the original submission event, so they are set once and not revised. `contactId` is create-only deliberately — re-attributing an existing response to a different person is a capability only v2 offers, and it is not carried forward. minProperties: 1 properties: finished: type: boolean endingId: type: - string - 'null' language: type: - string - 'null' data: $ref: '#/components/schemas/ResponseDataMapInput' embeddedData: $ref: '#/components/schemas/ResponseEmbeddedDataInput' tags: type: array maxItems: 100 description: Complete set of tag ids for this response, replacing whatever is currently applied. Repeats are allowed and collapse, because the set is applied as a set. Tags must already exist in the same workspace — create them via `POST /api/v3/tags`. items: type: string format: cuid2 additionalProperties: false WorkflowStatus: type: string enum: - draft - enabled - disabled - archived description: | Workflow lifecycle state. `draft` workflows are editable and inert. `enabled` workflows respond to trigger events. `disabled` workflows keep their configuration but are inert. `archived` workflows are soft-deleted: read-only, excluded from default list reads, and restorable via unarchive. Status only changes through lifecycle endpoints. Valid transitions: `draft` → `enabled`, `enabled` ↔ `disabled`, `enabled`/`disabled` → `draft`, any non-archived state → `archived` (archive), and `archived` → `draft` (unarchive). WorkflowRunStatus: type: string enum: - queued - running - completed - failed - canceled description: | Workflow run lifecycle state. `completed`, `failed`, and `canceled` are terminal. Valid transitions: `queued` → `running`, `running` → `queued` (retry/backoff), `queued` → `canceled`, and `running` → `completed`/`failed`/`canceled`. Retries do not introduce a separate status; retry state is exposed via `attempt` and `nextAttemptAt`. WorkflowRunSummary: type: object description: | Slim run shape embedded as `lastRun` in workflow resources. The runs list (`GET /api/v3/workflows/runs`) returns `WorkflowRunListItem`, which extends this with `workflowName`. Excludes `triggerPayload`, `data`, and `logs`. required: - id - workflowId - workspaceId - workflowVersionId - status - isDryRun - triggerType - surveyId - responseId - error - attempt - createdAt - updatedAt - startedAt - finishedAt properties: id: type: string format: cuid2 workflowId: type: string format: cuid2 workspaceId: type: string format: cuid2 workflowVersionId: type: - string - 'null' format: cuid2 description: | Immutable workflow version snapshot the run executes against. `null` for dry runs of workflows that have never been enabled. Version contents are not readable through the v3 API in Scope 1. status: $ref: '#/components/schemas/WorkflowRunStatus' isDryRun: type: boolean description: '`true` for runs created by `POST /api/v3/workflows/{workflowId}/test`.' triggerType: type: string enum: - response.completed surveyId: type: - string - 'null' format: cuid2 responseId: type: - string - 'null' format: cuid2 description: Survey response that triggered the run. `null` for synthesized dry-run data or when the response was deleted. error: type: - string - 'null' description: Terminal or most recent failure reason. `null` while the run is healthy. attempt: type: integer minimum: 0 description: Retry attempt counter. `0` on the first execution; retries increment it without changing `status`. createdAt: type: string format: date-time updatedAt: type: string format: date-time startedAt: type: - string - 'null' format: date-time finishedAt: type: - string - 'null' format: date-time WorkflowListItem: type: object description: | Slim workflow shape returned by `GET /api/v3/workflows`. `triggerType` and `surveyId` are derived read-only projections of `definition.trigger`, so list consumers can render and filter without loading full definitions. Serialized dates are ISO 8601 strings. required: - id - workspaceId - name - description - status - triggerType - surveyId - createdBy - creator - createdAt - updatedAt - lastRun - runCount properties: id: type: string format: cuid2 workspaceId: type: string format: cuid2 name: type: string description: type: - string - 'null' status: $ref: '#/components/schemas/WorkflowStatus' triggerType: type: string enum: - response.completed description: Derived from `definition.trigger.triggerType`. surveyId: type: string format: cuid2 description: Derived from `definition.trigger.config.surveyId`. createdBy: type: - string - 'null' format: cuid2 description: User id that created the workflow. `null` when the creating user was deleted. creator: type: - object - 'null' required: - name properties: name: type: string description: Creating user's name. `null` when the creating user was deleted. createdAt: type: string format: date-time updatedAt: type: string format: date-time lastRun: anyOf: - $ref: '#/components/schemas/WorkflowRunSummary' - type: 'null' description: Most recent run summary (dry runs included). `null` when the workflow has never run. runCount: type: integer minimum: 0 description: Total number of runs (dry runs included) for this workflow. CountedListMeta: allOf: - $ref: '#/components/schemas/ListPaginationMeta' - type: object required: - totalCount properties: totalCount: type: - integer - 'null' minimum: 0 description: Total number of items matching the current filters (and search) across all pages. `null` when the request did not ask for it — see the operation's `includeTotalCount` parameter, whose default differs per collection. unevaluatedProperties: false description: A list `meta` carrying pagination and the filtered total. `totalCount` is `required` but nullable, gated on the operation's `includeTotalCount` parameter. WorkflowNodeUi: type: object description: Builder-only UI metadata that does not affect workflow execution. Additional keys are preserved. properties: position: type: object required: - x - 'y' properties: x: type: number 'y': type: number additionalProperties: false collapsed: type: boolean additionalProperties: true WorkflowResponseCompletedTriggerNode: type: object required: - id - type - triggerType - config properties: id: type: string minLength: 1 type: type: string enum: - trigger triggerType: type: string enum: - response.completed label: type: string minLength: 1 maxLength: 120 description: Optional display label shown in the builder. config: type: object required: - surveyId properties: surveyId: type: string format: cuid2 description: Survey whose completed responses trigger the workflow. Must exist in the workspace. endingCardIds: type: array items: type: string format: cuid2 default: [] description: Ending card ids that should trigger the workflow. Empty or omitted means every ending matches. additionalProperties: false ui: $ref: '#/components/schemas/WorkflowNodeUi' additionalProperties: false WorkflowSendEmailActionConfig: type: object description: |- Send-email action with 1:1 field parity to survey Follow-ups (`ZSurveyFollowUpAction`). `to`, `subject` and `body` accept an empty string on create and update, so an author can save an unfinished draft. Completeness is enforced when the workflow has to run: enabling it returns **422** `workflow_not_executable` naming each blank field, and the dry run reports them as problems. required: - to - from - replyTo - subject - body - attachResponseData properties: to: type: string description: | Recipient: either a literal email address (e.g. a teammate) or the element id of a survey question / hidden field whose answer contains the respondent's email address (contact-info elements resolve index [2]) — the same semantics as the Follow-ups `to` field. A **literal** address must belong to someone who can access this workspace — an organization owner or manager, or a member of a team the workspace is shared with. Enabling a workflow whose literal recipient cannot access the workspace returns **422** `workflow_not_executable`, the dry run reports a `recipient_not_allowed` problem, and the runner refuses to send to it — so a workflow cannot forward response data to an arbitrary external inbox. An element-id `to` resolves to the respondent's own address and is never allowlist-checked. from: type: string format: email replyTo: type: array items: type: string format: email subject: type: string maxLength: 998 description: | Email subject. Used verbatim (recall tokens are not expanded in the subject), the same as Follow-ups. Capped at RFC 5322's maximum line length. body: type: string maxLength: 100000 description: | Email body as HTML with recall tokens (`#recall:[elementId]/fallback:x#`). Recall tokens are expanded against the response, the result is sanitized to a narrow HTML allowlist, and it is wrapped in the branded Follow-ups email template — identical rendering to survey Follow-ups. attachResponseData: type: boolean includeVariables: type: boolean default: false includeHiddenFields: type: boolean default: false additionalProperties: false WorkflowSendEmailActionNode: type: object description: 'Send-email action node. Renders 1:1 with survey Follow-ups: recall-token body, question/hidden-field `to` resolution, and the branded Follow-ups email template.' required: - id - type - actionType - config properties: id: type: string minLength: 1 type: type: string enum: - action actionType: type: string enum: - send_email label: type: string minLength: 1 maxLength: 120 description: Optional display label shown in the builder. config: $ref: '#/components/schemas/WorkflowSendEmailActionConfig' ui: $ref: '#/components/schemas/WorkflowNodeUi' additionalProperties: false WorkflowEdge: type: object required: - id - source - target properties: id: type: string minLength: 1 source: type: string minLength: 1 description: Node id the edge starts from. target: type: string minLength: 1 description: Node id the edge points to. sourceHandle: type: string minLength: 1 description: Builder handle identifier on the source node. Reserved for branch nodes (`then`/`else`) in future scopes. targetHandle: type: string minLength: 1 description: Builder handle identifier on the target node. additionalProperties: false WorkflowDefinition: type: object description: | Scope 1 workflow JSON document, mirroring `ZWorkflowDefinition` in `packages/workflows`. The definition is the public source of truth for the builder and API clients. Scope 1 intentionally excludes user-authored if/else branches, webhook actions, schedules, delays, loops, and AI/MCP-assisted creation; future node types extend `nodes` additively. Graph rules enforced on every write (**400** with `invalid_params` on violation): node ids are unique across `trigger` and `nodes`; every edge references existing node ids; the trigger has at most one outgoing edge; `entryNodeId` equals `trigger.id`. Trigger-only drafts (no `nodes`, no `edges`) are valid persisted documents. Executability rules (exactly one outgoing trigger edge, acyclic graph, every node reachable from the trigger) are additionally enforced by `enable` and `test` with **422** `workflow_not_executable`. required: - trigger - entryNodeId properties: schemaVersion: type: integer enum: - 1 default: 1 description: Definition schema version. Optional on writes (defaults to the current version); always present in responses. trigger: $ref: '#/components/schemas/WorkflowResponseCompletedTriggerNode' nodes: type: array default: [] items: $ref: '#/components/schemas/WorkflowSendEmailActionNode' description: Child nodes of the graph. The trigger lives in `trigger`, not in this array. Scope 1 accepts `send_email` action nodes only. Optional on writes (defaults to empty for trigger-only drafts); always present in responses. edges: type: array default: [] items: $ref: '#/components/schemas/WorkflowEdge' description: Optional on writes (defaults to empty for trigger-only drafts); always present in responses. entryNodeId: type: string minLength: 1 description: Workflow entry point. Must equal `trigger.id`. additionalProperties: false CreateWorkflowRequest: type: object required: - workspaceId - name - definition properties: workspaceId: type: string format: cuid2 name: type: string minLength: 1 maxLength: 120 description: type: - string - 'null' maxLength: 500 status: type: string enum: - draft description: New workflows are created as drafts in Scope 1. definition: $ref: '#/components/schemas/WorkflowDefinition' additionalProperties: false WorkflowResource: description: | Full workflow shape returned by detail, create, update, duplicate, and lifecycle endpoints: the list-item fields plus the complete `definition` document. allOf: - $ref: '#/components/schemas/WorkflowListItem' - type: object required: - definition properties: definition: $ref: '#/components/schemas/WorkflowDefinition' WorkflowRunListItem: description: | Run-list row returned by `GET /api/v3/workflows/runs`: the slim run summary plus the resolved `workflowName`, joined so the workspace-wide runs table can label each row. Distinct from the `lastRun` summary embedded in workflow resources, which omits the name (the parent workflow already carries it). Excludes `triggerPayload`, `data`, and `logs`. allOf: - $ref: '#/components/schemas/WorkflowRunSummary' - type: object required: - workflowName properties: workflowName: type: string description: Name of the workflow this run belongs to, joined for the runs-list display. CursorPaginationMeta: allOf: - $ref: '#/components/schemas/ListPaginationMeta' unevaluatedProperties: false description: |- A list `meta` carrying pagination and nothing else — the shape for collections that do not report a total and have no domain context to echo. Adds nothing to `ListPaginationMeta`; the `allOf` wrapper is what closes it. Each composing operation bounds its own requests through its `limit` query parameter, which is why this shape states no cap of its own. WorkflowTriggerPayload: type: object description: | Trigger payload snapshot captured when a run is created, mirroring `ZWorkflowTriggerRunPayload`. Additional runner keys may be present. required: - type - workspaceId - surveyId - responseId - triggeredAt properties: type: type: string enum: - response.completed workspaceId: type: string format: cuid2 surveyId: type: string format: cuid2 responseId: type: string format: cuid2 description: For dry runs without a `responseId` input, this references the synthesized sample response. endingCardId: type: string format: cuid2 description: Ending card the respondent reached, when the survey uses ending cards. data: type: object description: Response data snapshot used by the runner. triggeredAt: type: string format: date-time additionalProperties: true WorkflowRunLogStatus: type: string enum: - pending - running - succeeded - failed - skipped description: Status of one executed (or skipped) workflow step within a run. WorkflowRunStepResult: type: object description: In-document result for one workflow step, mirroring `ZWorkflowStepResult`. required: - stepId - stepType - status properties: stepId: type: string minLength: 1 description: Node id of the executed step. stepType: type: string minLength: 1 description: Node/step type, e.g. `response.completed` or `send_email`. status: $ref: '#/components/schemas/WorkflowRunLogStatus' input: type: object description: Step input snapshot. output: type: object description: Step output snapshot, or the mocked output for dry runs. error: type: string startedAt: type: string format: date-time finishedAt: type: string format: date-time additionalProperties: false WorkflowRunData: type: object description: | Run execution data persisted with the run, mirroring `ZWorkflowRunData`. Scope 1 exposes this as a debug-oriented JSON dump. Additional runner keys may be present. required: - steps properties: trigger: allOf: - $ref: '#/components/schemas/WorkflowTriggerPayload' description: Optional duplicate of the trigger payload snapshot kept inside the run data document. steps: type: array items: $ref: '#/components/schemas/WorkflowRunStepResult' additionalProperties: true WorkflowRunLog: type: object description: Persisted trace entry for one workflow run step, mirroring the `WorkflowRunLog` table row. required: - id - runId - sequence - stepId - stepType - status - input - output - error - startedAt - finishedAt properties: id: type: string format: cuid2 runId: type: string format: cuid2 sequence: type: integer minimum: 0 description: Order of the entry within the run. stepId: type: string minLength: 1 stepType: type: string minLength: 1 status: $ref: '#/components/schemas/WorkflowRunLogStatus' input: type: object description: Step input snapshot. Empty object when nothing was recorded. output: type: object description: Step output snapshot (mocked for dry runs). Empty object when nothing was recorded. error: type: - string - 'null' startedAt: type: - string - 'null' format: date-time finishedAt: type: - string - 'null' format: date-time additionalProperties: false WorkflowRunResource: description: | Full run shape returned by `GET /api/v3/workflows/runs/{runId}` and `POST /api/v3/workflows/{workflowId}/test`: the summary fields plus the captured trigger payload, per-step results, the persisted log trace, and retry/idempotency metadata. allOf: - $ref: '#/components/schemas/WorkflowRunSummary' - type: object required: - triggerPayload - data - logs - idempotencyKey - nextAttemptAt - lastErrorAt properties: triggerPayload: $ref: '#/components/schemas/WorkflowTriggerPayload' data: $ref: '#/components/schemas/WorkflowRunData' logs: type: array items: $ref: '#/components/schemas/WorkflowRunLog' description: Persisted step-by-step trace, ordered by `sequence`. Empty while the run is still `queued`. idempotencyKey: type: - string - 'null' description: Deduplication key for this run, unique per workflow. Set from the `Idempotency-Key` header on dry runs or from trigger-event deduplication. nextAttemptAt: type: - string - 'null' format: date-time description: When the next retry attempt is scheduled. `null` when no retry is pending. lastErrorAt: type: - string - 'null' format: date-time PatchWorkflowRequest: type: object description: | Partial workflow update. `name` and `description` are accepted in any state except `archived`; `definition` is only accepted while the workflow is `draft` or `disabled`. `status` is intentionally not patchable — use the lifecycle endpoints. minProperties: 1 properties: name: type: string minLength: 1 maxLength: 120 description: type: - string - 'null' maxLength: 500 definition: $ref: '#/components/schemas/WorkflowDefinition' additionalProperties: false DuplicateWorkflowRequest: type: object properties: name: type: string minLength: 1 maxLength: 120 description: Optional name for the duplicate. If omitted, the server chooses a non-conflicting copy name. additionalProperties: false WorkflowTestResult: type: object description: | Result of dry-running (testing) a workflow: whether it would execute and, if not, why not. No run is created and no side effects occur. required: - workflowId - ok - problems properties: workflowId: type: string format: cuid2 example: clworkflow0000000000000000 ok: type: boolean description: | True when the definition is executable, every trigger reference resolves, and every literal `send_email` recipient can access this workspace. problems: type: array description: Every problem found, so all issues can be fixed at once. Empty when `ok` is true. items: type: object required: - code - field - message properties: code: type: string enum: - definition_not_executable - survey_not_found - ending_card_not_found - survey_not_workspace_visible - recipient_not_allowed description: Machine-readable problem category. field: type: string description: | Dotted path to the offending field, e.g. `definition.trigger.config.surveyId`. message: type: string description: Human-readable explanation of the problem. ChartDateRange: description: |- A date range on a time dimension: a named preset from the field catalog's `datePresets` (`last 7 days`, `this month`, …), `all-time` (no range), or a pair of ISO calendar dates `[from, to]`, both inclusive, `from` not after `to`. Every range is read in the organization's display time zone; a `timezone` cannot be supplied. Charts saved before ranges were limited to presets may hold a relative range Cube reads itself (`last 3 months`, `this week`). Such a chart still renders and can be previewed, within the bucket limit below, and can be saved with that same range kept; a new chart, or an update to another range, must use a preset or a pair. A chart grouped by time alone (one time dimension with a `granularity`, no other dimensions) gets one row per bucket in its range, empty or not, so its range may span at most 10,000 buckets: about 13 months by the hour, or 27 years by the day. A wider range is refused with **422** `limit_exceeded`; use a coarser granularity or a shorter range. An older chart's relative range is measured back from now (`last 999 years` spans 999 years). Every preset fits at every granularity, and a chart with other dimensions returns only its real rows, so it is not limited. oneOf: - type: string description: A preset name from `datePresets`, or `all-time`. example: last 30 days - type: array minItems: 2 maxItems: 2 items: type: string format: date example: - '2026-01-01' - '2026-03-31' ChartQueryFilter: description: 'A filter: either one condition on a field, or an `and` / `or` group of filters. Filters nest at most three levels: a group, a group inside it, and conditions inside that. A chart query holds at most 25 conditions in all.' oneOf: - type: object title: Condition required: - member - operator properties: member: type: string description: A field name from the field catalog. example: FeedbackRecords.sourceType operator: type: string description: One of the field's `filterOperators` in the catalog. enum: - equals - notEquals - contains - notContains - set - notSet - gt - gte - lt - lte values: type: array description: The values to compare against. Required by every operator except `set` and `notSet`, which take none. maxItems: 100 items: type: string maxLength: 1000 additionalProperties: false - type: object title: And group required: - and properties: and: type: array minItems: 1 items: $ref: '#/components/schemas/ChartQueryFilter' additionalProperties: false - type: object title: Or group required: - or properties: or: type: array minItems: 1 items: $ref: '#/components/schemas/ChartQueryFilter' additionalProperties: false ChartQuery: type: object description: |- What a chart computes, in Cube's query format (ADR 0001): measures, dimensions, time dimensions, filters, order and limit over its Feedback Dataset's fields. Every field must come from the field catalog (`GET /api/v3/charts/fields`), named with its data-source prefix. Display settings are separate (`config`). Refused with **422** (`invalid_params` names each problem): a field that is not in the catalog; Cube options the app does not use (`ungrouped`, `segments`, `renewQuery`, `joinHints`, `total`, `compareDateRange`); a `timezone` (every range is read in the organization's display time zone); more than 15 fields across `measures`, `dimensions` and `timeDimensions`; more than 25 filter conditions or filters nested deeper than three levels; a date range that is not a preset, `all-time` or two ISO calendar dates; on a chart grouped by time alone, a date range that spans more than 10,000 buckets of its `granularity` (code `limit_exceeded`). properties: measures: type: array maxItems: 10 items: type: string description: A field of kind `measure`. example: - FeedbackRecords.npsScore dimensions: type: array maxItems: 5 items: type: string description: A field of kind `dimension` or `timeDimension`. example: - FeedbackRecords.sourceName timeDimensions: type: array maxItems: 2 items: type: object required: - dimension properties: dimension: type: string description: A field of kind `timeDimension`. example: FeedbackRecords.collectedAt granularity: type: string enum: - hour - day - week - month - quarter - year dateRange: $ref: '#/components/schemas/ChartDateRange' additionalProperties: false filters: type: array maxItems: 25 items: $ref: '#/components/schemas/ChartQueryFilter' order: description: 'Sort order: `[field, direction]` pairs, or an object mapping a field to its direction. At most 10 entries.' oneOf: - type: array maxItems: 10 items: type: array minItems: 2 maxItems: 2 prefixItems: - type: string - type: string enum: - asc - desc - type: object maxProperties: 10 additionalProperties: type: string enum: - asc - desc limit: type: integer minimum: 1 maximum: 5000 offset: type: integer minimum: 0 maximum: 10000 additionalProperties: false ChartConfig: type: object description: Display settings, kept apart from the chart query. Every key is optional; each applies to the chart types named in its description and is ignored by the others. properties: barOrientation: type: string enum: - vertical - horizontal description: Bar charts. Defaults to `vertical`. pieDisplay: type: string enum: - pie - breakdown description: Pie charts. Defaults to `pie`. areaDisplay: type: string enum: - filled - line description: Area charts. Defaults to `filled`. matrixCellValue: type: string enum: - percent - count - both description: Matrix charts. Defaults to `percent`. matrixColorScale: type: boolean description: Matrix charts. Defaults to `true`. matrixShowTotals: type: boolean description: Matrix charts. Defaults to `false`. matrixTranspose: type: boolean description: Matrix charts. Swaps rows and columns on screen; the query is unchanged. colors: type: array maxItems: 50 items: type: string maxLength: 64 xAxisLabel: type: string maxLength: 255 yAxisLabel: type: string maxLength: 255 showLegend: type: boolean legendPosition: type: string enum: - top - bottom - left - right stacked: type: boolean showGrid: type: boolean showValues: type: boolean numberFormat: type: string maxLength: 64 dateFormat: type: string maxLength: 64 prefix: type: string maxLength: 32 suffix: type: string maxLength: 32 additionalProperties: false ChartResource: type: object description: 'A chart: one saved visualisation of one Feedback Dataset. It belongs to a workspace and can sit on any number of dashboards — `dashboards` lists them, so a caller can see what an edit or a delete would affect.' required: - id - workspaceId - datasetId - name - type - query - config - creator - dashboards - createdAt - updatedAt properties: id: type: string format: cuid2 workspaceId: type: string format: cuid2 datasetId: type: string format: cuid2 description: The Feedback Dataset the chart reads. Fixed when the chart is created. name: type: string description: Unique within the workspace. type: type: string enum: - area - bar - pie - big_number - matrix query: $ref: '#/components/schemas/ChartQuery' config: $ref: '#/components/schemas/ChartConfig' creator: description: Who created the chart, for display only; `null` once that user is gone. oneOf: - type: object required: - name properties: name: type: string additionalProperties: false - type: 'null' dashboards: type: array description: The dashboards that hold the chart, by name. items: type: object required: - id - name properties: id: type: string format: cuid2 name: type: string additionalProperties: false createdAt: type: string format: date-time updatedAt: type: string format: date-time additionalProperties: false ChartCreateRequest: type: object required: - workspaceId - datasetId - name - type - query properties: workspaceId: type: string format: cuid2 datasetId: type: string format: cuid2 description: The Feedback Dataset the chart reads. Needs write access to it from `workspaceId`. Fixed once the chart exists. name: type: string minLength: 1 maxLength: 255 description: Unique within the workspace; leading and trailing spaces are trimmed. type: type: string enum: - area - bar - pie - big_number - matrix description: '`matrix` is refused with 422 while an instance''s online upgrade to 6.1 is unfinished.' query: $ref: '#/components/schemas/ChartQuery' config: $ref: '#/components/schemas/ChartConfig' additionalProperties: false ChartField: type: object description: One field a chart query may use. Use `name` exactly as given, data-source prefix included (`FeedbackRecords.npsScore`); a field that is not in the catalog is refused. required: - name - kind - type - label - description - filterOperators - granularities - valueLookup properties: name: type: string example: FeedbackRecords.npsScore kind: type: string enum: - measure - dimension - timeDimension description: 'Where the field may appear: a `measure` in `measures`, a `dimension` in `dimensions`, a `timeDimension` in `timeDimensions` (or `dimensions`). Any kind may be filtered.' type: type: string enum: - number - string - time - boolean label: type: string example: 'NPS: Score' description: type: - string - 'null' filterOperators: type: array description: The operators a filter on this field may use. items: type: string enum: - equals - notEquals - contains - notContains - set - notSet - gt - gte - lt - lte granularities: type: array description: The granularities a time dimension may be grouped by; empty for other kinds. items: type: string enum: - hour - day - week - month - quarter - year valueLookup: type: boolean description: Whether `GET /api/v3/charts/fields/{field}/values` lists this field's stored values, for filters that need an exact value. additionalProperties: false ChartFieldCatalog: type: object description: 'The field catalog: the fields a chart query may use, and the named date ranges it may use on `FeedbackRecords.collectedAt` and other time dimensions. The chart query validator checks against this same list, so the catalog is the contract.' required: - fields - datePresets properties: fields: type: array items: $ref: '#/components/schemas/ChartField' datePresets: type: array description: Named date ranges, resolved in the organization's display time zone. `all-time` means no range at all. A date range may also be a pair of ISO calendar dates. items: type: string example: - today - last 7 days - this month - all-time additionalProperties: false ChartPreviewRequest: type: object required: - workspaceId - datasetId - type - query properties: workspaceId: type: string format: cuid2 datasetId: type: string format: cuid2 description: The Feedback Dataset to read; needs read access to it from `workspaceId`. type: type: string enum: - area - bar - pie - big_number - matrix description: The chart type. A `big_number` shows one value, so its grouping is dropped. query: $ref: '#/components/schemas/ChartQuery' dateRange: description: 'Replaces the query''s date range on `FeedbackRecords.collectedAt`: a preset, `all-time` to remove it, or `{ from, to }` ISO calendar dates. Read in the organization''s display time zone.' oneOf: - type: string example: last 7 days - type: object required: - from - to properties: from: type: string format: date to: type: string format: date additionalProperties: false additionalProperties: false ChartData: type: object description: 'What a chart query returns: one row per group, keyed by field name, the label maps to display stored ids with, and the query as it ran.' required: - rows - optionLabels - fieldLabels - query properties: rows: type: array items: type: object additionalProperties: type: - string - number - boolean - 'null' example: - FeedbackRecords.sourceName: Web FeedbackRecords.count: 42 optionLabels: type: object description: Choice option ids to their labels in the survey's default language, in survey order. Filled when the rows group by `FeedbackRecords.valueId` (or a `valueText` grouping the server rewrote to it); empty otherwise. additionalProperties: type: string fieldLabels: type: object description: Matrix row field ids (`__`) to their statements, in survey order. Filled when the rows group by `FeedbackRecords.fieldId` over matrix questions; empty otherwise. additionalProperties: type: string query: type: object description: 'The query as it ran: after the requested date range replaced the saved one, and after a `valueText` grouping on a single-choice question was rewritten to `valueId`. The row keys follow this query.' additionalProperties: false ChartUpdateRequest: type: object description: 'Send at least one field. `config` replaces the display settings whole. A chart''s Feedback Dataset and workspace are fixed: `datasetId` or `workspaceId` in the body is refused with 400 (`read_only_field`) — create a new chart to chart another dataset.' minProperties: 1 properties: name: type: string minLength: 1 maxLength: 255 type: type: string enum: - area - bar - pie - big_number - matrix query: $ref: '#/components/schemas/ChartQuery' config: $ref: '#/components/schemas/ChartConfig' additionalProperties: false WidgetLayout: type: object description: 'A widget''s position and size on the dashboard''s 12-column grid, in whole grid units: `1 ≤ w ≤ 12`, `x + w ≤ 12`, `1 ≤ h ≤ 8`, `0 ≤ y ≤ 1000`. Widgets may not overlap; gaps are allowed and close upward when the dashboard is drawn.' required: - x - 'y' - w - h properties: x: type: integer minimum: 0 maximum: 11 'y': type: integer minimum: 0 maximum: 1000 w: type: integer minimum: 1 maximum: 12 h: type: integer minimum: 1 maximum: 8 additionalProperties: false ChartDuplicateRequest: type: object properties: name: type: string minLength: 1 maxLength: 255 description: Name for the copy, unique within the workspace. Without one the copy is named " (copy)", then "(copy 2)" and so on. dashboardId: type: string format: cuid2 description: 'Also put the copy on this dashboard, in the same transaction: if placing it fails, no copy is left behind. Must be in the chart''s workspace.' layout: $ref: '#/components/schemas/WidgetLayout' description: Where to put the copy on `dashboardId`. Without one, the first open slot. additionalProperties: false DashboardCreator: description: Who created the dashboard, for display only; `null` once that user is gone. oneOf: - type: object required: - name properties: name: type: string additionalProperties: false - type: 'null' DashboardListItem: type: object description: A dashboard as a list shows it — its widgets counted, not loaded. required: - id - workspaceId - name - creator - widgetCount - createdAt - updatedAt properties: id: type: string format: cuid2 workspaceId: type: string format: cuid2 name: type: string creator: $ref: '#/components/schemas/DashboardCreator' widgetCount: type: integer minimum: 0 createdAt: type: string format: date-time updatedAt: type: string format: date-time description: Changes on every rename, save and widget change. Send it back on a full update (`PUT /api/v3/dashboards/{dashboardId}`) to be told if the dashboard changed in between. additionalProperties: false DashboardCreateRequest: type: object description: A new, empty dashboard. Put charts on it with `POST /api/v3/dashboards/{dashboardId}/widgets`. required: - workspaceId - name properties: workspaceId: type: string format: cuid2 name: type: string minLength: 1 maxLength: 255 description: Unique within the workspace; leading and trailing whitespace is trimmed. additionalProperties: false DashboardWidget: type: object description: One chart's placement on the dashboard. `chart` is exactly what reading the chart returns. required: - id - chartId - layout - order - chart properties: id: type: string format: cuid2 chartId: type: string format: cuid2 layout: $ref: '#/components/schemas/WidgetLayout' order: type: integer minimum: 0 chart: $ref: '#/components/schemas/ChartResource' additionalProperties: false DashboardResource: type: object description: 'A dashboard: a named grid of widgets in a workspace. Its charts can come from different Feedback Datasets. Each widget''s data is read separately, from `GET /api/v3/charts/{chartId}/data`.' required: - id - workspaceId - name - creator - widgets - createdAt - updatedAt properties: id: type: string format: cuid2 workspaceId: type: string format: cuid2 name: type: string description: Unique within the workspace. creator: $ref: '#/components/schemas/DashboardCreator' widgets: type: array description: In display order. items: $ref: '#/components/schemas/DashboardWidget' createdAt: type: string format: date-time updatedAt: type: string format: date-time description: Changes on every rename, save and widget change. Send it back on a full update to be told if the dashboard changed in between. additionalProperties: false DashboardReplaceRequest: type: object description: 'The whole edited dashboard: its name and every widget''s place. A widget left out of `widgets` is removed (its chart is kept). `expectedUpdatedAt` is the dashboard''s `updatedAt` from your read; if it changed since, nothing is saved.' required: - name - expectedUpdatedAt - widgets properties: name: type: string minLength: 1 maxLength: 255 expectedUpdatedAt: type: string format: date-time description: The dashboard's `updatedAt` from your read. widgets: type: array maxItems: 1000 items: type: object required: - widgetId - layout - order properties: widgetId: type: string format: cuid2 layout: $ref: '#/components/schemas/WidgetLayout' order: type: integer minimum: 0 maximum: 2147483647 description: Sort key; any non-negative integer, in the order widgets should list. additionalProperties: false additionalProperties: false DashboardRenameRequest: type: object description: A rename. Only the name changes; the layout is saved with `PUT /api/v3/dashboards/{dashboardId}`. required: - name properties: name: type: string minLength: 1 maxLength: 255 description: Unique within the workspace; leading and trailing whitespace is trimmed. additionalProperties: false DashboardDuplicateRequest: type: object properties: name: type: string minLength: 1 maxLength: 255 description: Name for the copy, unique within the workspace. Without one the copy is named " (copy)", then "(copy 2)" and so on — never "(copy) (copy)". additionalProperties: false WidgetCreateRequest: type: object required: - chartId properties: chartId: type: string format: cuid2 description: A chart in the dashboard's workspace. A chart appears at most once per dashboard. layout: $ref: '#/components/schemas/WidgetLayout' description: Where to put the chart. Without one it lands in the first open slot at its chart type's default size. additionalProperties: false WidgetUpdateRequest: type: object description: Send a `layout`, a `chartId`, or both. Repointing shows another chart in the widget's place — the way to give one dashboard its own copy of a shared chart. minProperties: 1 properties: layout: $ref: '#/components/schemas/WidgetLayout' chartId: type: string format: cuid2 description: A chart in the dashboard's workspace that is not already on the dashboard. additionalProperties: false CreativeType: type: string enum: - feedbackButton - banner description: 'The kind of creative, which fixes its `content` and `styling` shape. A `feedbackButton` is a tab on an edge of the page that always opens its Destination; a `banner` is a bar across the top or bottom of the page with a message and an optional button. Fixed at creation: it can never change.' CreativeDisplayStatus: type: string enum: - draft - scheduled - active - paused - closed - archived description: | What the creative is doing now, derived in this order: `archived` when archived; `draft` and `closed` (status `completed`) when the author said so; `closed` when the schedule's `endAt` has passed; `paused` when the author paused it; `scheduled` before the schedule's `startAt`; `paused` when its Destination survey isn't active (not `inProgress`, or archived); otherwise `active`. CreativeStatus: type: string enum: - draft - inProgress - paused - completed description: | The author's intent, and the only status a write sets: `draft` (never published), `inProgress` (published), `paused` (paused by its author) or `completed` (closed by its author). The same values as a survey's `status`. What visitors get is `displayStatus`, which the server derives from this value, `archivedAt`, the schedule and the Destination survey. Nothing writes `status` on its own: a schedule ending or a survey pausing changes `displayStatus` only. CreativeDisplayStatusReason: type: - string - 'null' enum: - surveyNotActive - scheduleEnded - destinationMissing - actionMissing - null description: | Why `displayStatus` differs from what `status` alone would give: `surveyNotActive` on a `paused` creative whose Destination survey isn't active ("Paused with its survey": it comes back by itself when the survey is active again), `scheduleEnded` on a `closed` creative whose `endAt` has passed, `destinationMissing` on a `paused` creative that needs a Destination (a feedback button, or a banner with a button) and has none, and `actionMissing` on a `paused` creative that shows on an action and has none. Sites never show a creative paused for either of the last two until it gets what it lacks (see `warnings`). `null` otherwise. New values may be added. CreativeLanguage: type: object description: A language the creative's texts are written in. required: - code - default - enabled properties: code: $ref: '#/components/schemas/LocaleCode' default: type: boolean description: Whether this is `defaultLanguage`. Exactly one entry is the default. enabled: type: boolean description: Whether visitors in this language see this translation. The default language is always enabled; a disabled or missing translation falls back to the default language. additionalProperties: false CreativeSurveyDestination: type: object description: The survey the creative opens. required: - type - surveyId - surveyName - opensAs properties: type: type: string enum: - survey surveyId: type: string format: cuid2 surveyName: type: string readOnly: true opensAs: type: string enum: - popup - newTab readOnly: true description: 'Follows the survey''s type: an app survey opens as a popup in its own placement, a link survey in a new tab.' additionalProperties: false CreativeLinkDestination: type: object description: The link the creative opens. required: - type - url - opensAs properties: type: type: string enum: - link url: type: string opensAs: type: string enum: - newTab readOnly: true description: A link always opens in a new tab. additionalProperties: false CreativeDestination: oneOf: - $ref: '#/components/schemas/CreativeSurveyDestination' - $ref: '#/components/schemas/CreativeLinkDestination' discriminator: propertyName: type mapping: survey: '#/components/schemas/CreativeSurveyDestination' link: '#/components/schemas/CreativeLinkDestination' description: What the creative opens when pressed, with the read-only `opensAs`. A write takes `CreativeDestinationInput`, which carries neither `opensAs` nor `surveyName`. CreativeTrigger: type: object description: An action that shows the creative when it fires. Discover action-class ids with `GET /api/v3/action-classes`. required: - actionClassId properties: actionClassId: type: string format: cuid2 description: Id of an action class in the creative's workspace. additionalProperties: false ShowCondition: type: object description: | One condition on the page, the visit or the device: `leftOperand` `operator` `rightOperand`. The operand names its family in `leftOperand.type` (`page`: `currentUrl`, `pageReferrer`, `entryReferrer`, `queryParam`; `visit`: `pagesViewed`, `uniquePagesViewed`, `timeOnSite`, `visitedUrls`; `device`: `deviceType`, `browser`, `screenWidth`), and each operand accepts only its own operators. `queryParam` names its parameter in `leftOperand.meta.key`. `isSet` and `isNotSet` take no `rightOperand`; list operators (`containsAnyOf`, `containsNoneOf`, and the `currentUrl`-only `pathIsAnyOf`, `pathIsNoneOf`, which compare exact page paths) take a non-empty list; numeric operands take a number of 0 or more (`uniquePagesViewed` at most 200); `deviceType` takes `phone`, `tablet` or `desktop`. A condition that breaks one of these rules answers **400**. Matching is case-insensitive. required: - id - leftOperand - operator properties: id: type: string format: cuid2 leftOperand: type: object required: - type - value properties: type: type: string enum: - page - visit - device value: type: string enum: - currentUrl - pageReferrer - entryReferrer - queryParam - pagesViewed - uniquePagesViewed - timeOnSite - visitedUrls - deviceType - browser - screenWidth meta: type: object required: - key properties: key: type: string minLength: 1 maxLength: 200 additionalProperties: false additionalProperties: false operator: type: string enum: - equals - doesNotEqual - contains - doesNotContain - startsWith - endsWith - containsAnyOf - containsNoneOf - pathIsAnyOf - pathIsNoneOf - isGreaterThan - isLessThan - isGreaterThanOrEqual - isLessThanOrEqual - includes - doesNotInclude - isSet - isNotSet rightOperand: type: object required: - type - value properties: type: type: string enum: - static value: oneOf: - type: string maxLength: 2048 - type: number - type: array maxItems: 200 items: type: string maxLength: 2048 additionalProperties: false additionalProperties: false ShowConditionSubgroup: type: object description: A group of conditions inside the top-level group. Groups nest at most two levels deep. required: - id - connector - conditions properties: id: type: string format: cuid2 connector: type: string enum: - and - or conditions: type: array maxItems: 20 items: $ref: '#/components/schemas/ShowCondition' additionalProperties: false ShowConditionGroup: type: object description: | Show when: the page, visit and device conditions that must hold, joined by `connector`. The same conditions surveys use. At most 20 conditions in all, groups nested at most two levels deep, and 10 240 characters of JSON; a set beyond those answers **400**. Empty groups are dropped on write, and a top-level group left empty is stored as `null` (always show). required: - id - connector - conditions properties: id: type: string format: cuid2 connector: type: string enum: - and - or conditions: type: array maxItems: 20 items: oneOf: - $ref: '#/components/schemas/ShowCondition' - $ref: '#/components/schemas/ShowConditionSubgroup' additionalProperties: false example: id: k3m9q2w7e5r1t8y4u6i0o2p5 connector: and conditions: - id: x8c2v6b4n1m7q9w3e5r0t2y6 leftOperand: type: page value: currentUrl operator: startsWith rightOperand: type: static value: https://www.medicare.gov/ - id: z5x1c9v3b7n2m6q0w4e8r2t6 leftOperand: type: device value: deviceType operator: doesNotEqual rightOperand: type: static value: phone CreativeDistribution: type: object description: | When the creative shows. `showOn: pageView` shows it on every page view; `showOn: action` only when one of `triggers` fires, and publishing needs at least one. `triggers` must be empty with `pageView`. A creative never falls back to every page view. `conditions` (Show when) narrows either, and `displayPercentage` (Share of visitors) shows it to that share of visitors, each decided once and kept on every page; `null` means everyone. required: - showOn - triggers - conditions - displayPercentage properties: showOn: type: string enum: - pageView - action triggers: type: array maxItems: 20 items: $ref: '#/components/schemas/CreativeTrigger' description: Each id must name an action class in the creative's workspace, at most once; an unknown or repeated id answers **422**. conditions: anyOf: - $ref: '#/components/schemas/ShowConditionGroup' - type: 'null' description: Show when. `null` means no conditions. displayPercentage: type: - number - 'null' minimum: 0.01 maximum: 100 additionalProperties: false example: showOn: pageView triggers: [] conditions: null displayPercentage: null CreativeSchedule: type: object description: When a published creative shows, to the minute. Before `startAt` it is `scheduled`; from `endAt` on it is `closed`. `null` leaves that side open. `endAt` must be after `startAt`, or the write answers **422**. required: - startAt - endAt properties: startAt: type: - string - 'null' format: date-time endAt: type: - string - 'null' format: date-time additionalProperties: false example: startAt: '2026-11-01T02:00:00.000Z' endAt: '2026-11-01T04:00:00.000Z' CreativeWarning: type: object description: | Something about the creative that needs attention; the list marks such rows with a warning icon. `destinationMissing`: it needs a Destination (a feedback button, or a banner with a button) and has none, for example after its survey was unlinked. `destinationNotAllowed`: its survey can no longer be a Destination (it became Restricted, single-use or PIN-protected), so pressing it would fail. `actionMissing`: it shows on an action but has none, so it never shows. New codes may be added. required: - code - message properties: code: type: string enum: - destinationMissing - destinationNotAllowed - actionMissing message: type: string description: The same warning in English, for people. additionalProperties: false CreativeResourceBase: type: object description: Fields every creative carries, whatever its type. `type` selects the `content` and `styling` shape; see `CreativeResource`. required: - id - workspaceId - name - type - status - displayStatus - displayStatusReason - createdAt - updatedAt - publishedAt - archivedAt - creator - defaultLanguage - languages - autoSelectLanguage - destination - distribution - schedule - warnings properties: id: type: string format: cuid2 workspaceId: type: string format: cuid2 name: type: string description: Internal name, shown to authors only. Never shown or announced to visitors. type: $ref: '#/components/schemas/CreativeType' status: $ref: '#/components/schemas/CreativeStatus' displayStatus: $ref: '#/components/schemas/CreativeDisplayStatus' readOnly: true displayStatusReason: $ref: '#/components/schemas/CreativeDisplayStatusReason' readOnly: true createdAt: type: string format: date-time readOnly: true updatedAt: type: string format: date-time readOnly: true publishedAt: type: - string - 'null' format: date-time readOnly: true description: When the creative was first published; `null` while it has never been. archivedAt: type: - string - 'null' format: date-time readOnly: true description: When the creative was archived; `null` unless it is archived. creator: type: - object - 'null' readOnly: true description: The user who created the creative, or `null` for one created with an API key. required: - name properties: name: type: string additionalProperties: false defaultLanguage: $ref: '#/components/schemas/LocaleCode' languages: type: array items: $ref: '#/components/schemas/CreativeLanguage' description: Every language the texts are written in, the default first. autoSelectLanguage: type: - boolean - 'null' description: Show the texts in the visitor's browser language when the site sets none with `setLanguage` and it matches the default or an enabled language; otherwise the default language is used. `null` (never chosen) and `false` both mean off. destination: anyOf: - $ref: '#/components/schemas/CreativeDestination' - type: 'null' description: What pressing the creative opens, or `null` when it opens nothing (a banner without a button, or a draft whose Destination isn't chosen yet). distribution: $ref: '#/components/schemas/CreativeDistribution' schedule: $ref: '#/components/schemas/CreativeSchedule' warnings: type: array readOnly: true items: $ref: '#/components/schemas/CreativeWarning' CreativeText: type: object description: Plain text per language, keyed by locale code. Must hold `defaultLanguage`; every other key must be declared in `languages`. A visitor whose language is missing sees the default language. propertyNames: type: string description: A locale code, `defaultLanguage` or one declared in `languages`. additionalProperties: type: string maxLength: 200 examples: - en-US: Feedback de-DE: Feedback geben FeedbackButtonContent: type: object description: What a feedback button shows. required: - label - icon properties: label: $ref: '#/components/schemas/CreativeText' description: The button's text. Publishing needs a non-empty default-language label. icon: type: - string - 'null' enum: - messageSquare - messageCircle - megaphone - sparkles - null description: An icon before the label, or `null` for none. additionalProperties: false FeedbackButtonPlacement: type: object description: 'Where a feedback button sits: an edge of the page plus an offset along it, in percent, that places the button''s centre. On the left and right edges the offset runs from the top; on the bottom edge, from the left. 50 centres the button on its edge. Near either end the button stops at the end of the edge instead, so it stays fully on screen: 0 puts its top (or left) end at the start of the edge and 100 its bottom (or right) end at the end.' required: - edge - offset properties: edge: type: string enum: - left - right - bottom offset: type: integer minimum: 0 maximum: 100 additionalProperties: false CreativeColor: type: object description: A custom colour per appearance, `#rrggbb`, as the survey styling stores its colours. `light` applies in light appearance. `dark` applies when the visitor's appearance is dark; `null` (or absent) derives it as the theme describes. required: - light properties: light: type: string pattern: ^#[0-9a-fA-F]{6}$ dark: type: - string - 'null' pattern: ^#[0-9a-fA-F]{6}$ additionalProperties: false CreativeFontSize: type: object description: Font size in px, per device. `phone` applies below 768 px viewport width; `null` there uses the `desktop` value, and `null` on `desktop` uses the workspace Look & Feel. required: - desktop - phone properties: desktop: type: - integer - 'null' minimum: 8 maximum: 72 phone: type: - integer - 'null' minimum: 8 maximum: 72 additionalProperties: false CreativePadding: type: object description: Padding in px, per device. `phone` applies below 768 px viewport width; `null` there uses the `desktop` value, and `null` on `desktop` uses the workspace Look & Feel. required: - desktop - phone properties: desktop: type: - integer - 'null' minimum: 0 maximum: 64 phone: type: - integer - 'null' minimum: 0 maximum: 64 additionalProperties: false FeedbackButtonTheme: type: object description: | The feedback button's custom styles. With `customStyles` off the button follows the workspace Look & Feel (the survey button) and the values below are kept but not applied. With it on, each value overrides the workspace one; `null` keeps the workspace value. A colour has a `light` value and an optional `dark` one for the visitor's dark appearance. Without a `dark` value: when the background or the text colour is set, both keep their light values in dark appearance so their contrast holds, a background set without a text colour gets readable text (`#0f172a` or `#ffffff`) in each appearance, and a border keeps its light value. Colours left to the workspace follow its dark palette when the visitor's appearance is dark. Sizes and the position apply in both appearances. required: - customStyles - backgroundColor - textColor - borderColor - borderRadius - fontWeight - fontSize - paddingX - paddingY properties: customStyles: type: boolean description: The "Add custom styles" switch. backgroundColor: anyOf: - $ref: '#/components/schemas/CreativeColor' - type: 'null' textColor: anyOf: - $ref: '#/components/schemas/CreativeColor' - type: 'null' borderColor: anyOf: - $ref: '#/components/schemas/CreativeColor' - type: 'null' borderRadius: type: - integer - 'null' minimum: 0 maximum: 100 description: Corner radius in px. fontWeight: type: - integer - 'null' enum: - 400 - 500 - 600 - 700 - null fontSize: $ref: '#/components/schemas/CreativeFontSize' paddingX: $ref: '#/components/schemas/CreativePadding' paddingY: $ref: '#/components/schemas/CreativePadding' pageFont: type: - boolean - 'null' description: 'Page font: `true` renders in the host page''s font, `false` in the survey font, `null` (or absent) follows the workspace''s Page font setting. Applies only with `customStyles` on.' customCss: anyOf: - $ref: '#/components/schemas/CustomCssInput' - type: 'null' description: The creative's own Custom CSS **source**, `null` (or absent) when it has none. It applies only with `customStyles` on, only to this creative, and is scoped to its element `.fb-creative-button` inside the creative's shadow root; the workspace Custom CSS never reaches a creative. The server processes it on every write that adds or edits it, silently removing `@import`, `@font-face` and external `url()`. Adding or editing it needs the Scale plan on Formbricks Cloud (403 `custom_css_plan_required`); CSS that cannot be processed is a 422 naming `styling.theme.customCss`. Removing it, or sending it back unchanged, needs no plan. Limited to 20 000 UTF-8 bytes. additionalProperties: false FeedbackButtonStyling: type: object description: A feedback button's position and custom styles. The position applies whether or not custom styles are on. required: - position - theme properties: position: type: object required: - desktop - phone properties: desktop: $ref: '#/components/schemas/FeedbackButtonPlacement' phone: anyOf: - $ref: '#/components/schemas/FeedbackButtonPlacement' - type: 'null' description: Applies below 768 px viewport width. `null` uses the desktop position. additionalProperties: false theme: $ref: '#/components/schemas/FeedbackButtonTheme' additionalProperties: false example: position: desktop: edge: right offset: 50 phone: null theme: customStyles: false backgroundColor: null textColor: null borderColor: null borderRadius: null fontWeight: null fontSize: desktop: null phone: null paddingX: desktop: null phone: null paddingY: desktop: null phone: null FeedbackButtonCreative: allOf: - $ref: '#/components/schemas/CreativeResourceBase' - type: object required: - content - styling properties: type: type: string enum: - feedbackButton content: $ref: '#/components/schemas/FeedbackButtonContent' styling: $ref: '#/components/schemas/FeedbackButtonStyling' unevaluatedProperties: false description: A feedback button. CreativeTextRun: type: object description: | One stretch of banner text with the same formatting. Banner text is stored and delivered as runs, never as HTML, so it can't carry markup, scripts or images. `href` makes the run a link. Allowed: `https://` and `http://` URLs with a real host (the same rule as a link Destination's `url`, so `https://example.` is refused), `mailto:` and `tel:` links, and root-relative paths (`/open-enrollment`), which resolve on whichever site shows the banner. required: - text properties: text: type: string minLength: 1 maxLength: 2000 bold: type: boolean italic: type: boolean underline: type: boolean href: type: string maxLength: 2048 pattern: ^(?:https?://(?:[^\s/?#@]+@)?(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[0-9A-Fa-f:.]+\]|(?:[^\s/\\?#@:.\[\]]+\.)+[^\s/\\?#@:.\[\]\d][^\s/\\?#@:.\[\]]+)(?::\d{1,5})?(?:[/?#]\S*)?|(?:mailto:|tel:|/(?!/))\S*)$ additionalProperties: false CreativeRichText: type: object description: 'Formatted text per language, keyed by locale code: each value is the text as a list of runs. Must hold `defaultLanguage`; every other key must be declared in `languages`.' propertyNames: type: string description: A locale code, `defaultLanguage` or one declared in `languages`. additionalProperties: type: array maxItems: 100 items: $ref: '#/components/schemas/CreativeTextRun' examples: - en-US: - text: 'Scheduled maintenance tonight from 02:00 to 04:00. ' - text: Details href: /maintenance underline: true BannerButton: type: object description: The button inside a banner. Pressing it opens the creative's Destination. required: - label properties: label: $ref: '#/components/schemas/CreativeText' description: The button's text. Publishing needs a non-empty default-language label. additionalProperties: false BannerContent: type: object description: What a banner shows. required: - message - button - dismissible - accessibleName properties: message: $ref: '#/components/schemas/CreativeRichText' description: 'The banner text: bold, italic, underline and links; no images. Publishing needs a non-empty default-language message.' button: anyOf: - $ref: '#/components/schemas/BannerButton' - type: 'null' description: An optional button. A banner with a button needs a `destination` to publish; a banner without one is a notice only and can't have a `destination`. dismissible: type: boolean description: Whether visitors can close the banner. A dismissed banner stays closed for the rest of the visit, in that browser tab; a new tab shows it again. accessibleName: anyOf: - $ref: '#/components/schemas/CreativeText' - type: 'null' description: The name screen readers announce for the banner region. `null` uses "Site notice" in the visitor's language. The creative's `name` is never announced. additionalProperties: false BannerTopPlacement: type: object description: A banner at the top of the page, either `inPage` (in the page flow, scrolling away with it) or `overlay` (fixed and always visible). required: - placement - layout properties: placement: type: string enum: - top layout: type: string enum: - inPage - overlay additionalProperties: false BannerBottomPlacement: type: object description: A banner at the bottom of the page, which is always `overlay`. required: - placement - layout properties: placement: type: string enum: - bottom layout: type: string enum: - overlay additionalProperties: false BannerPlacement: oneOf: - $ref: '#/components/schemas/BannerTopPlacement' - $ref: '#/components/schemas/BannerBottomPlacement' discriminator: propertyName: placement mapping: top: '#/components/schemas/BannerTopPlacement' bottom: '#/components/schemas/BannerBottomPlacement' description: Where a banner sits and how it lays out. A bottom banner is always an overlay. BannerTheme: type: object description: | The banner's custom styles. With `customStyles` off the banner follows the workspace Look & Feel (the survey card, with the survey button inside) and the values below are kept but not applied. With it on, each value overrides the workspace one; `null` keeps the workspace value. A colour has a `light` value and an optional `dark` one for the visitor's dark appearance. Without a `dark` value: when the background or the text colour is set, both keep their light values in dark appearance so their contrast holds, a background set without a text colour gets readable text (`#0f172a` or `#ffffff`) in each appearance, and a border keeps its light value. Colours left to the workspace follow its dark palette when the visitor's appearance is dark. Sizes and the position apply in both appearances. The banner's button always looks like the survey button. required: - customStyles - backgroundColor - textColor - borderColor - fontWeight - fontSize - paddingX - paddingY properties: customStyles: type: boolean description: The "Add custom styles" switch. backgroundColor: anyOf: - $ref: '#/components/schemas/CreativeColor' - type: 'null' textColor: anyOf: - $ref: '#/components/schemas/CreativeColor' - type: 'null' borderColor: anyOf: - $ref: '#/components/schemas/CreativeColor' - type: 'null' fontWeight: type: - integer - 'null' enum: - 400 - 500 - 600 - 700 - null fontSize: $ref: '#/components/schemas/CreativeFontSize' paddingX: $ref: '#/components/schemas/CreativePadding' paddingY: $ref: '#/components/schemas/CreativePadding' pageFont: type: - boolean - 'null' description: 'Page font: `true` renders in the host page''s font, `false` in the survey font, `null` (or absent) follows the workspace''s Page font setting. Applies only with `customStyles` on.' customCss: anyOf: - $ref: '#/components/schemas/CustomCssInput' - type: 'null' description: The creative's own Custom CSS **source**, `null` (or absent) when it has none. It applies only with `customStyles` on, only to this creative, and is scoped to its element `.fb-creative-banner` inside the creative's shadow root; the workspace Custom CSS never reaches a creative. The server processes it on every write that adds or edits it, silently removing `@import`, `@font-face` and external `url()`. Adding or editing it needs the Scale plan on Formbricks Cloud (403 `custom_css_plan_required`); CSS that cannot be processed is a 422 naming `styling.theme.customCss`. Removing it, or sending it back unchanged, needs no plan. Limited to 20 000 UTF-8 bytes. additionalProperties: false BannerStyling: type: object description: A banner's position and custom styles. The position applies whether or not custom styles are on. required: - position - theme properties: position: type: object required: - desktop - phone properties: desktop: $ref: '#/components/schemas/BannerPlacement' phone: anyOf: - $ref: '#/components/schemas/BannerPlacement' - type: 'null' description: Applies below 768 px viewport width. `null` uses the desktop position. additionalProperties: false theme: $ref: '#/components/schemas/BannerTheme' additionalProperties: false example: position: desktop: placement: top layout: inPage phone: null theme: customStyles: false backgroundColor: null textColor: null borderColor: null fontWeight: null fontSize: desktop: null phone: null paddingX: desktop: null phone: null paddingY: desktop: null phone: null BannerCreative: allOf: - $ref: '#/components/schemas/CreativeResourceBase' - type: object required: - content - styling properties: type: type: string enum: - banner content: $ref: '#/components/schemas/BannerContent' styling: $ref: '#/components/schemas/BannerStyling' unevaluatedProperties: false description: A banner. CreativeResource: oneOf: - $ref: '#/components/schemas/FeedbackButtonCreative' - $ref: '#/components/schemas/BannerCreative' discriminator: propertyName: type mapping: feedbackButton: '#/components/schemas/FeedbackButtonCreative' banner: '#/components/schemas/BannerCreative' description: | A creative: a page element the web SDK renders on the customer's site next to surveys, with its own content, look, display rules and schedule. `type` selects the `content` and `styling` shape: switch on it. The writable groups are `name`, `status`, `defaultLanguage` with `languages`, `autoSelectLanguage`, `content`, `styling`, `destination`, `distribution` and `schedule`; a `PATCH` replaces each group it sends as a whole. `displayStatus`, `displayStatusReason`, `warnings`, the timestamps, `creator` and `destination.opensAs` are read-only. CreativeListMeta: allOf: - $ref: '#/components/schemas/ListPaginationMeta' unevaluatedProperties: false description: The list `meta` for creatives — pagination only. CreativeLanguageInput: type: object description: A language the creative's texts are written in. Every non-default locale key used in `content` must be declared here. The workspace language is created when it doesn't exist yet. required: - code - enabled properties: code: $ref: '#/components/schemas/LocaleCode' enabled: type: boolean description: Whether visitors in this language see this translation. Ignored for `defaultLanguage`, which is always enabled. additionalProperties: false CreativeSurveyDestinationInput: type: object description: Open a survey. It must be in the creative's workspace, visible to the whole workspace (never Restricted, and no visibility change pending), and neither single-use nor PIN-protected; anything else answers **422**. Email verification is fine. required: - type - surveyId properties: type: type: string enum: - survey surveyId: type: string format: cuid2 additionalProperties: false CreativeLinkDestinationInput: type: object description: Open an `http` or `https` link in a new tab. required: - type - url properties: type: type: string enum: - link url: type: string description: 'An absolute `http://` or `https://` URL with a real host: `localhost`, an IP address, or a domain whose labels are letters, digits and hyphens and whose top-level domain has at least two letters (punycode allowed). `https://example.` and `https://example` are refused. `web-url` names this rule; `pattern` states the part of it a regular expression can.' maxLength: 2048 format: web-url pattern: ^https?://(?:[^\s/?#@]+@)?(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[0-9A-Fa-f:.]+\]|(?:[^\s/\\?#@:.\[\]]+\.)+[^\s/\\?#@:.\[\]\d][^\s/\\?#@:.\[\]]+)(?::\d{1,5})?(?:[/?#]\S*)?$ additionalProperties: false CreativeDestinationInput: oneOf: - $ref: '#/components/schemas/CreativeSurveyDestinationInput' - $ref: '#/components/schemas/CreativeLinkDestinationInput' discriminator: propertyName: type mapping: survey: '#/components/schemas/CreativeSurveyDestinationInput' link: '#/components/schemas/CreativeLinkDestinationInput' description: 'What the creative opens when pressed: a survey or a link. A creative opens exactly one Destination; many creatives may open the same survey.' CreateCreativeRequestBase: type: object description: Fields every create request takes, whatever the type. `type` selects the `content` and `styling` shape; see `CreateCreativeRequest`. required: - workspaceId - name - type properties: workspaceId: type: string format: cuid2 description: Workspace to create the creative in. Requires read/write access. name: type: string minLength: 1 maxLength: 255 type: $ref: '#/components/schemas/CreativeType' status: type: string enum: - draft - inProgress - paused - completed default: draft description: The author's status (see `CreativeStatus`). Omit to create a draft. Any other value publishes the creative straight away, so it must meet the publish rules (see `POST /api/v3/creatives`). defaultLanguage: type: string pattern: ^[a-z]{2}(?:-[A-Z][a-z]{3})?-[A-Z]{2}$ default: en-US description: The language every text map must hold, as a canonical locale code. languages: type: array maxItems: 50 items: $ref: '#/components/schemas/CreativeLanguageInput' description: Other languages the texts are written in. Omit for none. autoSelectLanguage: type: - boolean - 'null' default: null description: Show the texts in the visitor's browser language when the site sets none with `setLanguage` and it matches the default or an enabled language; otherwise the default language is used. `null` (never chosen) and `false` both mean off. destination: anyOf: - $ref: '#/components/schemas/CreativeDestinationInput' - type: 'null' description: Omit or `null` for none. distribution: $ref: '#/components/schemas/CreativeDistribution' description: Omit to show on every page view, to everyone, with no conditions. schedule: $ref: '#/components/schemas/CreativeSchedule' description: Omit for no start or end time. CreateFeedbackButtonCreativeRequest: allOf: - $ref: '#/components/schemas/CreateCreativeRequestBase' - type: object required: - content properties: type: type: string enum: - feedbackButton content: $ref: '#/components/schemas/FeedbackButtonContent' styling: $ref: '#/components/schemas/FeedbackButtonStyling' description: Omit for the right edge at 50 % (centred top to bottom) on every device, with custom styles off (the workspace Look & Feel). unevaluatedProperties: false description: Create a feedback button. CreateBannerCreativeRequest: allOf: - $ref: '#/components/schemas/CreateCreativeRequestBase' - type: object required: - content properties: type: type: string enum: - banner content: $ref: '#/components/schemas/BannerContent' styling: $ref: '#/components/schemas/BannerStyling' description: Omit for the top of the page, in page, on every device, with custom styles off (the workspace Look & Feel). unevaluatedProperties: false description: Create a banner. CreateCreativeRequest: oneOf: - $ref: '#/components/schemas/CreateFeedbackButtonCreativeRequest' - $ref: '#/components/schemas/CreateBannerCreativeRequest' discriminator: propertyName: type mapping: feedbackButton: '#/components/schemas/CreateFeedbackButtonCreativeRequest' banner: '#/components/schemas/CreateBannerCreativeRequest' description: A new creative. `type` selects the `content` and `styling` shape and can never change afterwards. Unknown fields are rejected with **400**. PauseCreativesRequest: type: object description: The survey whose creatives to pause, and the workspace it belongs to. Unknown fields are rejected with **400**. required: - workspaceId - surveyId properties: workspaceId: type: string format: cuid2 description: Workspace the survey belongs to. Requires read/write access. surveyId: type: string format: cuid2 description: The survey whose creatives to pause, the ones whose Destination it is. additionalProperties: false PausedCreatives: type: object description: The creatives one `POST /api/v3/creatives/pause` paused. required: - paused - creativeIds properties: paused: type: integer minimum: 0 description: How many creatives this request paused. creativeIds: type: array items: type: string format: cuid2 description: The ids of the creatives this request paused, in no particular order. additionalProperties: false PatchCreativeRequest: type: object description: | Changes to a creative. Each top-level field is a group that **replaces** the stored one as a whole: send the complete `content`, `styling`, `destination`, `distribution` or `schedule` you want, and omit a group to leave it untouched. `defaultLanguage` and `languages` replace the languages; the stored texts must still fit them, so send `content` alongside when they no longer do. `content` and `styling` must have the shape of the creative's own `type`, which never changes; `type` itself is not accepted. Publish, pause, close and reopen by setting `status`. Unknown fields are rejected with **400**. properties: name: type: string minLength: 1 maxLength: 255 status: $ref: '#/components/schemas/CreativeStatus' defaultLanguage: $ref: '#/components/schemas/LocaleCode' languages: type: array maxItems: 50 items: $ref: '#/components/schemas/CreativeLanguageInput' autoSelectLanguage: type: - boolean - 'null' description: Show the texts in the visitor's browser language when the site sets none with `setLanguage` and it matches the default or an enabled language; otherwise the default language is used. `null` (never chosen) and `false` both mean off. Omit to keep the stored value. content: oneOf: - $ref: '#/components/schemas/FeedbackButtonContent' - $ref: '#/components/schemas/BannerContent' styling: oneOf: - $ref: '#/components/schemas/FeedbackButtonStyling' - $ref: '#/components/schemas/BannerStyling' destination: anyOf: - $ref: '#/components/schemas/CreativeDestinationInput' - type: 'null' distribution: $ref: '#/components/schemas/CreativeDistribution' schedule: $ref: '#/components/schemas/CreativeSchedule' expectedUpdatedAt: type: string format: date-time description: 'Optimistic-concurrency precondition: the creative''s `updatedAt` from your last read. The change is applied only if the creative hasn''t changed since; otherwise nothing is written and the request answers **409**. Omit it to apply the change to the creative as it is stored when the request runs.' additionalProperties: false CreativeStatsCounts: type: object description: Impressions, Clicks and Click rate (Clicks divided by Impressions; `null` without Impressions). required: - impressions - clicks - clickRate properties: impressions: type: integer minimum: 0 clicks: type: integer minimum: 0 clickRate: type: - number - 'null' minimum: 0 additionalProperties: false CreativeStatsDay: type: object description: One UTC day's counts. required: - date - impressions - clicks - clickRate properties: date: type: string format: date impressions: type: integer minimum: 0 clicks: type: integer minimum: 0 clickRate: type: - number - 'null' minimum: 0 additionalProperties: false CreativeStatsPage: type: object description: One page's counts over the range. required: - page - impressions - clicks - clickRate properties: page: type: string description: Host and path, without query string or hash (`example.com/pricing`), or `(other)` for every page not listed. examples: - example.com/pricing impressions: type: integer minimum: 0 clicks: type: integer minimum: 0 clickRate: type: - number - 'null' minimum: 0 additionalProperties: false CreativeStatsDevice: type: object description: One device type's counts over the range. required: - deviceType - impressions - clicks - clickRate properties: deviceType: type: string enum: - phone - tablet - desktop description: The device type the SDK detected, as in the Show when `deviceType` condition. impressions: type: integer minimum: 0 clicks: type: integer minimum: 0 clickRate: type: - number - 'null' minimum: 0 additionalProperties: false CreativeStats: type: object description: | A creative's Impressions and Clicks over a range of UTC days. An **Impression** is one page view on which the creative showed (showing it again on the same page adds none; a new page, a single-page-app route change included, does). A **Click** is one press that opened its Destination; dismissing a banner, or pressing while its survey is already open, isn't one. `clickRate` is Clicks divided by Impressions, as a fraction (`0.05` is 5 %), or `null` without Impressions. `totals` and every breakdown count the same events, so `days`, `pages` and `devices` each add up to `totals`. required: - creativeId - from - to - groupBy - totals - days properties: creativeId: type: string format: cuid2 from: type: string format: date description: First UTC day counted, inclusive. to: type: string format: date description: Last UTC day counted, inclusive. groupBy: type: - string - 'null' enum: - page - device - null description: The breakdown requested, which adds `pages` or `devices`; `null` for none. totals: $ref: '#/components/schemas/CreativeStatsCounts' days: type: array description: One entry per UTC day from `from` to `to`, oldest first, days without counts included. items: $ref: '#/components/schemas/CreativeStatsDay' pages: type: array description: Only with `groupBy=page`. Where the Impressions and Clicks happened, most Impressions first, at most 100 pages. `(other)` sums every page not listed, including pages beyond the 1000 a creative counts per day; it comes last and only when it has counts. items: $ref: '#/components/schemas/CreativeStatsPage' devices: type: array description: Only with `groupBy=device`. The counts per device type, always `phone`, `tablet` and `desktop`, in that order. items: $ref: '#/components/schemas/CreativeStatsDevice' additionalProperties: false ActionClassResource: type: object description: A workspace action class that can be referenced from an app-survey trigger. required: - id - name - description - type - key properties: id: type: string format: cuid2 description: Use this value as `distribution.triggers[].actionClassId` on an app survey. name: type: string description: type: - string - 'null' type: type: string enum: - code - noCode key: type: - string - 'null' description: Code-action key for code-type action classes; null for no-code actions. additionalProperties: false ContactAttributeKeyResource: type: object description: | A workspace contact-attribute key. Use `key` as `targeting.filters[].root.contactAttributeKey` on an app survey; `dataType` indicates which operators apply (e.g. `number`/`date` attributes support arithmetic/date operators). required: - id - key - name - description - type - dataType - isUnique - createdAt - updatedAt properties: id: type: string format: cuid2 key: type: string description: The attribute key used in targeting filters (e.g. `plan`, `role`). name: type: - string - 'null' description: Human-readable display name, or null. description: type: - string - 'null' type: type: string enum: - default - custom description: Whether this is a built-in (`default`) or workspace-defined (`custom`) attribute. dataType: type: string enum: - string - number - date isUnique: type: boolean description: Whether each value of this attribute identifies at most one contact. createdAt: type: string format: date-time updatedAt: type: string format: date-time additionalProperties: false ContactResource: type: object description: | A workspace contact as the Contacts table renders it. `userId`, `email`, `firstName` and `lastName` are lifted out of `attributes` for convenience and are `null` when the contact has no value for them. `attributes` lists every attribute the contact has a value row for, ordered by `key`; a key the contact has no row for is absent. required: - id - createdAt - updatedAt - userId - email - firstName - lastName - attributes properties: id: type: string format: cuid2 createdAt: type: string format: date-time updatedAt: type: string format: date-time userId: type: - string - 'null' email: type: - string - 'null' firstName: type: - string - 'null' lastName: type: - string - 'null' attributes: type: array items: type: object required: - key - name - dataType - value properties: key: type: string description: The attribute key (`ContactAttributeKeyResource.key`). name: type: - string - 'null' description: The key's display name. dataType: type: string enum: - string - number - date value: type: string description: 'The value as text: a number attribute as its decimal string, a date attribute as an ISO 8601 date-time.' additionalProperties: false additionalProperties: false SegmentSurveyReference: type: object description: A survey a segment is attached to. required: - id - name properties: id: type: string format: cuid2 name: type: string additionalProperties: false SegmentResource: type: object description: | A workspace segment (a saved, reusable contact filter). Private segments — survey-local targeting drafts — are never listed. `surveys`, `activeSurveys` and `inactiveSurveys` only name surveys the caller is allowed to see. required: - id - title - description - isPrivate - filters - workspaceId - surveys - activeSurveys - inactiveSurveys - createdAt - updatedAt properties: id: type: string format: cuid2 title: type: string description: type: - string - 'null' isPrivate: type: boolean description: Always `false` in list results. filters: $ref: '#/components/schemas/SegmentFilters' workspaceId: type: string format: cuid2 surveys: type: array description: Ids of the visible surveys this segment is attached to. items: type: string format: cuid2 activeSurveys: type: array description: Visible attached surveys that are in progress. items: $ref: '#/components/schemas/SegmentSurveyReference' inactiveSurveys: type: array description: Visible attached surveys in any other status. items: $ref: '#/components/schemas/SegmentSurveyReference' createdAt: type: string format: date-time updatedAt: type: string format: date-time additionalProperties: false TagResource: type: object required: - id - name - workspaceId - createdAt - updatedAt - count properties: id: type: string format: cuid2 name: type: string description: Tag label as shown in the app. Unique within a workspace. workspaceId: type: string format: cuid2 createdAt: type: string format: date-time updatedAt: type: string format: date-time count: type: integer minimum: 0 description: Responses currently carrying this tag. `0` when the tag exists but has never been applied. WorkspaceCustomCssResource: type: object description: 'A workspace''s shared survey CSS (ENG-3641): editable source for authorized readers, never compiled output. It applies to every survey in the workspace, beneath each survey''s own CSS.' required: - workspaceId - customCss - previous - status - canEdit - planAllowed properties: workspaceId: type: string customCss: anyOf: - $ref: '#/components/schemas/CustomCssInput' - type: 'null' description: Current source; `null` when the workspace has no custom CSS. previous: anyOf: - $ref: '#/components/schemas/CustomCssInput' - type: 'null' description: The one recoverable previous revision. Restore it by sending this source back with PATCH; it is processed and authorized like any other save. status: type: string enum: - ok - stale - withheld description: '`stale`: compiled by an older processor and delivered by recompiling its source. `withheld`: the source no longer passes the current processor, so respondents receive none of it until it is fixed and saved again.' errors: type: array maxItems: 50 items: $ref: '#/components/schemas/CustomCssError' description: Present only when `status` is `withheld`. canEdit: type: boolean description: 'Whether this caller may PATCH it: organization owners and managers for signed-in users, a `manage` grant on this workspace for API keys.' planAllowed: type: boolean description: Whether the organization's plan allows additions and edits (Scale on Formbricks Cloud; every self-hosted plan). Removing CSS is always allowed. additionalProperties: false PatchWorkspaceCustomCssRequest: type: object required: - customCss properties: customCss: anyOf: - $ref: '#/components/schemas/CustomCssInput' - type: 'null' description: Replaces both fields. `null` clears all workspace CSS. Adding or editing CSS needs the Scale plan on Formbricks Cloud; clearing either field or all CSS never does. additionalProperties: false 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 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 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. 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`. 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. 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. CreateFeedbackRecordRequest: type: object description: A new feedback record. Exactly one `value_*` member should carry the answer, matching `field_type`. Strings must be valid UTF-8 without NULL bytes; violations answer **400**. additionalProperties: false required: - tenant_id - source_type - field_id - field_type - submission_id properties: tenant_id: type: string format: cuid2 description: The feedback dataset to write into (the dataset id). The gateway authorizes the request against this dataset, so it must be one the API key's workspace is assigned to. source_type: type: string minLength: 1 maxLength: 255 description: Type of feedback source, e.g. `survey`, `review`, `feedback_form`. source_id: type: - string - 'null' maxLength: 255 description: Reference to the survey / form / ticket the record came from. source_name: type: - string - 'null' maxLength: 255 description: Human-readable name of the source. submission_id: type: string minLength: 1 maxLength: 255 description: 'Identifier of the logical submission this record belongs to. Groups multi-field submissions and makes ingestion idempotent: records are unique per (`tenant_id`, `submission_id`, `field_id`) and a duplicate answers **409**. If a record has no logical submission, reuse `field_id`.' field_id: type: string minLength: 1 maxLength: 255 description: Identifier of the question / field. field_label: type: - string - 'null' maxLength: 2048 description: The question text. field_group_id: type: string maxLength: 255 description: Stable identifier grouping related fields (ranking, matrix and grid questions). field_group_label: type: - string - 'null' maxLength: 2048 description: Human-readable question text for the group. field_type: $ref: '#/components/schemas/FeedbackRecordFieldType' language: type: string maxLength: 10 description: ISO language code the feedback was given in. user_id: type: string maxLength: 255 description: 'End-user identifier (e.g. an anonymous id or an email hash). Set it on anything you may later have to erase: `DELETE /api/v3/feedbackRecords` matches on `user_id`.' collected_at: type: string format: date-time description: When the feedback was given. Defaults to now. Must be between 1970-01-01 and 2080-12-31. value_text: type: - string - 'null' maxLength: 30000 description: Text answer. The only member that feeds sentiment, emotion, translation and embedding enrichment. value_id: type: - string - 'null' maxLength: 255 description: Stable id of the selected option in the source system (e.g. a survey choice id or matrix column id), stored alongside `value_text`. Opaque, not validated. Omit for free-text, "other" and non-choice answers. value_number: type: number format: double minimum: -1000000000000000 maximum: 1000000000000000 description: Numeric answer (ratings, NPS scores, numbers). Must be between -1e15 and +1e15. value_boolean: type: boolean description: Yes / no answer. value_date: type: string format: date-time description: Date answer. Must be between 1970-01-01 and 2080-12-31. metadata: type: - object - 'null' additionalProperties: {} description: 'Arbitrary context for this record, stored as JSON: the dimensions you want to slice a dashboard by, such as channel, device, country, campaign, plan or tags. Values may be strings, numbers, booleans, `null`, or nested objects and arrays. Use stable `snake_case` keys — a key is what a dashboard groups by. Numbers keep full precision in storage. Metadata is **not** sent to the enrichment providers, but it **is** included in the `feedback_record.created` / `feedback_record.updated` webhook payloads.' DeleteFeedbackRecordsByUserResult: type: object additionalProperties: false required: - deleted_count - message properties: deleted_count: type: integer minimum: 0 description: Number of records deleted. `0` when the user had no remaining records — the operation is idempotent. message: type: string description: Human-readable status message. FeedbackRecordSemanticSearchRequest: type: object additionalProperties: false required: - query - tenant_id properties: query: type: string minLength: 1 description: Natural-language search text. It is embedded and compared to record embeddings by cosine similarity. tenant_id: type: string format: cuid2 description: The feedback dataset to search (the dataset id). Results never cross datasets. FeedbackRecordSimilarityItem: type: object additionalProperties: false required: - feedback_record_id - score - field_label - value_text properties: feedback_record_id: type: string format: uuid description: The matching record. Fetch it with `GET /api/v3/feedbackRecords/{id}` for the full payload. score: type: number format: double minimum: 0 maximum: 1 description: Cosine similarity, `0` unrelated to `1` identical. field_label: type: string description: The record's question text (part of what was embedded). Empty when the record has no label. value_text: type: string description: The record's text answer (the text that was embedded). Empty when it has since been cleared — only records with a non-empty `value_text` are embedded in the first place. FeedbackRecordSimilarityResult: type: object additionalProperties: false required: - data - limit properties: data: type: array maxItems: 100 items: $ref: '#/components/schemas/FeedbackRecordSimilarityItem' description: Matches ordered by descending `score`. 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 a full page was returned and there may be more results. Pass it back unchanged as `cursor`. FeedbackRecordDetail: allOf: - $ref: '#/components/schemas/FeedbackRecord' - type: object required: - taxonomy UpdateFeedbackRecordRequest: type: object description: A partial update. Only the members sent are changed; an empty body is accepted and changes nothing. Provenance (`tenant_id`, `source_*`, `field_*`, `submission_id`, `collected_at`) is immutable. additionalProperties: false properties: value_text: type: string maxLength: 30000 description: New text answer. Changing it clears the server-generated enrichment members (`sentiment`, `sentiment_score`, `emotions`, `value_text_translated`, `translation_lang_key`), queues re-enrichment and re-embedding, and the response reflects the cleared state. value_id: type: string maxLength: 255 description: New stable option id for a choice answer. Opaque, not validated. value_number: type: number format: double minimum: -1000000000000000 maximum: 1000000000000000 description: New numeric answer. Must be between -1e15 and +1e15. value_boolean: type: boolean description: New yes / no answer. value_date: type: string format: date-time description: New date answer. Must be between 1970-01-01 and 2080-12-31. language: type: string maxLength: 10 description: New ISO language code. Changing it clears and re-queues the translation pair only. user_id: type: string maxLength: 255 description: New end-user identifier. metadata: type: - object - 'null' additionalProperties: {} description: '**Replaces** the stored metadata wholesale — it is not merged, so send every key you want to keep. Same value rules and **400** rejections as on create.' responses: V3BadRequest: description: Bad Request — malformed JSON, invalid query/body/params, duplicate name, or unsupported field. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' V3Unauthorized: description: Not authenticated (no valid session or API key). headers: WWW-Authenticate: schema: type: string example: Bearer realm="formbricks" description: 'Authentication scheme challenge. RFC 9110 §15.5.2 asks a 401 for a challenge "applicable to the target resource", and Bearer is applicable here because these operations accept the API key as `Authorization: Bearer ` as well as `x-api-key`. Session-cookie-only operations send no challenge — see `V3UnauthorizedSession`. The MCP endpoints send a richer OAuth challenge carrying `resource_metadata` and `scope`.' X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' V3Forbidden: description: Forbidden — no workspace access, or resource does not exist (404 not used; avoids existence leak). headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' V3TooManyRequests: description: Rate limit exceeded. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store Retry-After: schema: type: integer description: Seconds until the current rate-limit window resets. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' V3InternalServerError: description: Internal Server Error. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' V3SurveyHasLinkedCreatives: description: 'Conflict — non-archived creatives open this survey (code `survey_has_linked_creatives`). `details.creatives` lists them. Delete them, then retry: pausing or closing a creative keeps it linked. Archived creatives never block: they are unlinked with the survey.' headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/LinkedCreativesProblem' example: title: Conflict status: 409 detail: Delete the creatives that open this survey before deleting it code: survey_has_linked_creatives requestId: req_01j8z5 details: creatives: - id: clcr1234567890123456789012 name: Feedback tab V3VisibilityForbidden: description: 'Forbidden, one of two codes. `forbidden`: the caller cannot see the survey, **or** sees it but may not change its visibility — workspace members without ownership, team-level managers, and every API key — with the same body as any unreadable survey. `visibility_not_enabled`: the organisation lacks the RBAC / access-control entitlement, or the deployment''s readiness marker for survey visibility is not set; this answer does not depend on the survey.' headers: X-Request-Id: schema: type: string Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: notAllowed: summary: Team-level manager, or an API key value: title: Forbidden status: 403 detail: You are not authorized to access this resource code: forbidden requestId: req_01j8z3 notEnabled: summary: Organisation is not entitled, or the deployment has not enabled visibility value: title: Forbidden status: 403 detail: Survey visibility is not enabled for this organization. code: visibility_not_enabled requestId: req_01j8z4 V3VisibilityBlocked: description: Conflict — `restricted` was requested while outbound connections depend on the survey. Remove or re-point them, then retry. `details.blockers` matches `blockers[]` from the `GET`. headers: X-Request-Id: schema: type: string Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/VisibilityBlockedProblem' example: title: Conflict status: 409 detail: Make this survey visible to the workspace to keep using it in 2 connections, or remove them before restricting it. code: visibility_blocked_by_connections requestId: req_01j8z5 details: blockers: - type: webhook id: clwh1234567890123456789012 name: Zapier — new responses - type: workflow id: clwf1234567890123456789012 name: 'Notify #product on completion' V3VisibilityNotAllowed: description: 'Unprocessable — `restricted` was requested on a survey with no owner (`owner: null`). Restricted means "the owner and the organisation''s administrators"; with no owner it would mean administrators only, which is not a state the product offers. Duplicate the survey to get a restricted copy you own.' headers: X-Request-Id: schema: type: string Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: title: Unprocessable Content status: 422 detail: This survey has no author and can't be restricted. Duplicate it to get a restricted copy you own. code: visibility_change_not_allowed requestId: req_01j8z6 V3ProjectionPending: description: 'Service Unavailable — only for a **grant** (`restricted → workspace`): the flag and a new `version` were stored, but the graph did not acknowledge the projection within the request. The survey stays restricted meanwhile (fail closed); the change is queued durably and lands within 60 seconds or the deployment fails closed. `GET …/visibility` shows `pending: "workspace"`. Retrying the `POST` is safe: it re-attempts the projection and answers **200** only when the graph holds this exact version. A `POST` for `restricted` cancels the pending grant. A restriction never answers 503 — it is enforced the moment it is stored. Chosen over **202 Accepted** so the pending state stays an exception, not the normal path every client must poll.' headers: Retry-After: schema: type: integer example: 5 X-Request-Id: schema: type: string Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' example: title: Service Unavailable status: 503 detail: The visibility change is queued but not yet enforced. Re-read the survey's visibility. code: projection_pending requestId: req_01j8z7 V3UnprocessableContent: description: Unprocessable Content — semantic validation failed or the requested lifecycle transition is invalid. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' WorkflowResourceResponse: description: Workflow returned successfully headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/WorkflowResource' V3Conflict: description: Conflict — an `Idempotency-Key` retry arrived while the original request was still being processed (IETF `draft-ietf-httpapi-idempotency-key-header` semantics). Retry after the original request completes to receive its stored result. headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' V3DashboardsForbidden: description: 'Forbidden. `code: forbidden` when the caller has no access to the workspace, the Feedback Dataset or the resource (which may also not exist; 404 is not used, to avoid leaking existence). `code: dashboards_plan_required` when the caller is authorized but the organization does not have the Dashboards add-on or a license that includes it — checked after authorization, so a caller without access never learns the organization''s plan.' headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' V3DashboardsConflict: description: 'Conflict (`code: conflict`) — the request clashes with the current state: a chart or dashboard name already used in the workspace, a chart already on the dashboard, a dashboard already holding its 250 widgets, a dashboard with no open slot left above its grid''s last row, or a dashboard changed since the `expectedUpdatedAt` the caller sent.' headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' V3ChartQueryTooManyRequests: description: 'Too Many Requests (`code: too_many_requests`). Chart data, preview and field-value lookups share one budget of 600 requests per minute per user or API key, separate from the 100 per minute of the rest of v3, so a dashboard can load every widget''s data. At most 20 of them run at once per user or key; past that the answer is 429 with `Retry-After: 1`. `Retry-After` says when to retry.' headers: X-Request-Id: schema: type: string description: Request correlation ID Retry-After: schema: type: integer description: Seconds until the budget refills. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' V3ChartQueryFailed: description: 'Bad Gateway (`code: bad_gateway`) — the chart query could not be run: the analytics engine was unreachable, refused it, or it ran longer than the one-minute limit and was stopped. The engine''s own error is never returned. Retrying later is safe; a query that timed out needs a narrower date range or grouping.' headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' V3CreativesForbidden: description: 'Forbidden, one of two codes. `forbidden`: the caller lacks the workspace access the operation needs, or the creative does not exist (404 is not used, so creative ids cannot be probed for existence). `creatives_not_entitled`: Creatives is not available on this instance (Formbricks Cloud, or a self-hosted license that does not include it). It is checked only after the caller''s own access, so it says nothing about a workspace the caller cannot see. A workspace that still has creatives is in wind-down: listing, reading, stats, a PATCH that sets nothing but `status` to `paused` or `completed`, pausing a survey''s creatives (`POST /api/v3/creatives/pause`), and DELETE keep working, and everything else answers this. A workspace without creatives answers it on every operation.' headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' examples: notAllowed: summary: No access to the workspace, or an unknown creative value: title: Forbidden status: 403 detail: You are not authorized to access this resource code: forbidden requestId: req_01j8z3 notEntitled: summary: Creatives is not available (Cloud, or no license with Creatives and no creatives) value: title: Forbidden status: 403 detail: Creatives is not available on this instance code: creatives_not_entitled requestId: req_01j8z4 windDown: summary: The license does not include Creatives, and the operation is not one wind-down allows value: title: Forbidden status: 403 detail: 'Creatives is not included in this instance''s license: existing creatives can only be listed, read, paused, closed or deleted' code: creatives_not_entitled requestId: req_01j8z5 V3UnauthorizedSession: description: Not authenticated (no valid session). headers: X-Request-Id: schema: type: string description: Request correlation ID Cache-Control: schema: type: string example: private, no-store content: application/problem+json: schema: $ref: '#/components/schemas/Problem' 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' FeedbackRecordsConflict: description: 'Conflict (`application/problem+json`). Either a **duplicate** — a record with this `tenant_id`, `submission_id` and `field_id` already exists (`code: conflict`, terminal; update the existing record instead) — or the dataset is being **purged** while you write to it (`code: tenant_write_conflict`, transient; retry unchanged once the purge completes).' content: application/problem+json: schema: $ref: '#/components/schemas/HubProblem' FeedbackRecordsPayloadTooLarge: description: Payload Too Large. The gateway buffers the request body in order to authorize it, and the proxy in front of it stops reading at 1 MiB — Envoy's `maxRequestBytes` on Formbricks Cloud, Traefik's `maxbodysize` in the Docker install. The refusal is therefore generated by that proxy rather than by the application, so neither its media type nor its body is part of this contract; branch on the status alone. FeedbackRecordsWriteConflict: description: 'Conflict (`application/problem+json`, `code: tenant_write_conflict`). The dataset is being **purged** while you write to it. Transient: retry the request unchanged once the purge completes. Only creates can conflict on a duplicate, so this operation never answers `code: conflict`.' content: application/problem+json: schema: $ref: '#/components/schemas/HubProblem' FeedbackRecordsRecordNotFound: description: 'Not Found from the feedback store (`application/problem+json`, `code: not_found`). The record was deleted after the gateway resolved its dataset: that lookup is cached for 60 seconds, so for up to a minute after a delete the call is still authorized and reaches the store, which no longer has the record. Once the cache entry expires the same id answers **403** instead. Treat both as "this record is not available to you".' content: application/problem+json: schema: $ref: '#/components/schemas/HubProblem' FeedbackRecordsNotFound: description: 'Not Found from the feedback store (`application/problem+json`, `code: not_found`), for one of two reasons. Usually the record exists but has **no embedding** for the current model: it has no text, or was created moments ago and is not embedded yet. It can also mean the record itself is **gone** — deleted after the gateway authorized the call against its cached dataset (see the `id` parameter).' content: application/problem+json: schema: $ref: '#/components/schemas/HubProblem' parameters: WorkspaceIdQuery: in: query name: workspaceId required: true schema: type: string format: cuid2 description: Workspace identifier. This is the canonical container ID for v3 APIs. LimitQuery: in: query name: limit schema: type: integer minimum: 1 maximum: 250 default: 20 description: Page size. A value above the maximum is rejected with **400** rather than silently reduced to it — v3 rejects unusable input instead of substituting its own, the same way it rejects unrecognized query parameters. The server may still return fewer items than requested, so treat `meta.nextCursor` as the only end-of-collection signal rather than comparing the page length against `limit`. CursorQuery: in: query name: cursor schema: type: string description: Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request. CreatedAtGteQuery: in: query name: filter[createdAt][gte] schema: type: string format: date-time description: Only records created at or after this instant (**inclusive**). Mutually exclusive with `filter[createdAt][gt]` — sending both is a **400**. CreatedAtGtQuery: in: query name: filter[createdAt][gt] schema: type: string format: date-time description: Only records created strictly after this instant (**exclusive**). Paired with `filter[createdAt][lt]` this is a fully open `(start, end)` window. For a half-open `[start, end)` window — the one whose consecutive pages tile without dropping a record that sits exactly on a boundary instant — use `filter[createdAt][gte]` with `filter[createdAt][lt]`. Mutually exclusive with `filter[createdAt][gte]` — sending both is a **400**. CreatedAtLteQuery: in: query name: filter[createdAt][lte] schema: type: string format: date-time description: Only records created at or before this instant (**inclusive**). Mutually exclusive with `filter[createdAt][lt]` — sending both is a **400**. CreatedAtLtQuery: in: query name: filter[createdAt][lt] schema: type: string format: date-time description: Only records created strictly before this instant (**exclusive**). The upper bound of a half-open `[start, end)` window, which is what makes consecutive windows tile without overlapping or dropping a record on the boundary. Mutually exclusive with `filter[createdAt][lte]` — sending both is a **400**. ResponseIdPath: in: path name: responseId required: true schema: type: string format: cuid2 description: Response identifier. WorkflowRunIdPath: in: path name: runId required: true schema: type: string format: cuid2 description: Workflow run identifier. WorkflowIdPath: in: path name: workflowId required: true schema: type: string format: cuid2 description: Workflow identifier. DatasetIdQuery: in: query name: datasetId required: true schema: type: string format: cuid2 description: 'The Feedback Dataset the chart reads. Access to a dataset is granted per workspace it is assigned to, so it is always authorized together with `workspaceId`: a dataset that is not assigned to the workspace, or that the caller may not use, answers 403.' ChartIdPath: in: path name: chartId required: true schema: type: string format: cuid2 description: Chart identifier. DashboardIdPath: in: path name: dashboardId required: true schema: type: string format: cuid2 description: Dashboard identifier. WidgetIdPath: in: path name: widgetId required: true schema: type: string format: cuid2 description: The widget's id, from the dashboard's `widgets`. CreativeIdPath: in: path name: creativeId required: true schema: type: string format: cuid2 description: Creative identifier. WorkspaceIdPath: in: path name: workspaceId required: true schema: type: string format: cuid2 description: Workspace identifier. 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.' SimilarityLimitQuery: in: query name: limit schema: type: integer default: 10 description: 'Page size, at most 100. Unlike the `limit` on `GET /api/v3/feedbackRecords`, this one is never rejected: the search endpoints **clamp** instead of answering 400. A value above 100 is reduced to 100, and `0`, a negative number or a non-numeric value falls back to the default of 10. Read the `limit` in the response to see what was actually applied.' SimilarityCursorQuery: 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 malformed cursor answers **400**. MinScoreQuery: in: query name: min_score schema: type: number format: float default: 0.7 description: 'Only matches with `score >= min_score` are returned. Like `limit`, this value is **clamped rather than rejected**: above `1` it becomes `1`, below `0` it becomes `0`, and a non-numeric value falls back to the default of `0.7`. That default is conservative — on the embedding model we measured, query→record similarities for genuinely relevant text often landed around `0.55–0.7` — so if a good query returns nothing, lower it (e.g. `0.5`) and filter on the returned `value_text`. Score distributions differ per model, so treat those numbers as an observation rather than a guarantee.' FeedbackRecordIdPath: in: path name: id required: true schema: type: string format: uuid description: Feedback record id (UUID). The dataset is resolved from the record itself, so the caller cannot choose which dataset it is authorized against. An id that is not a UUID answers **400**, and an id whose dataset the API key cannot reach answers **403** rather than 404, so ids cannot be probed. The gateway caches the record→dataset lookup for 60 seconds, so for up to a minute after a delete the id is still authorized and the store answers **404**; after that the same id answers **403**.