{"openapi":"3.1.0","info":{"title":"Strella API","version":"v1","description":"## Changelog\n\n### [v1.14.0] - 2026-10-29\n\n#### Changed\n\n#### Projects\n\n- **A human-moderated project takes any `device_restriction`.** `POST /projects` and `PATCH /projects/{project_id}` accept `mobile_only` and `no_restriction` on a project with `moderation_mode: human`, where they returned `422`. Reads return the stored `device_restriction` on a human-moderated project, where they always returned `desktop_only`. A participant on a phone joins a human-moderated interview in the browser, never in the Strella app, whatever `screen_sharing_requirement` says. Human-moderated projects created before v1.14.0 read `desktop_only` until you change them.\n\n#### Questionnaire\n\n- **Marking a multiple choice option exclusive turns `multi_select` on.** An `exclusive` choice on a single-select `multiple_choice` item used to be dropped with no error. On `PUT /projects/{project_id}/questionnaire/items` and the single-item `POST`, `PUT` and `PATCH`, Strella now turns `multi_select` on and keeps the flag, unless the same request turns `multi_select` off.\n  - A request turns it off with `false` on a new item, `false` in a `PATCH`, or `false` on an item stored with `multi_select: true`.\n  - A bulk `PUT` that sends back a single-select item's stored `false` does not turn it off, so a read sent back with one option marked exclusive saves as a multi-select question.\n  - On any other item type, or when a request turns `multi_select` off, Strella still drops the flag with no error.\n\n#### Recruitment\n\n- **A recruitment order Strella paused reads `paused`.** Strella pauses a recruitment order on its panel while the project is full or closed, and resumes it once the project has room. `GET /projects/{project_id}/recruitment`, its `status` filter and `GET /projects/{project_id}/recruitment/{order_id}` return `paused` for that order, where they returned `recruiting`. A filled or closed order still reads `filled` or `closed`. `PATCH` with `{ \"status\": \"recruiting\" }` resumes a User Interviews order Strella paused, and Strella pauses it again while the project is still full.\n\n#### Fixed\n\n#### Projects\n\n- **Project reads return edits made in Strella on the next request.** A Researcher can change a project's settings, name, workspace, guide or custom fields in Strella. They can also create a project from the dashboard, duplicate it or delete it. `GET /projects`, `GET /projects/search`, `GET /projects/{project_id}` and every read under a project then return the change on the next request. They returned the cached copy for up to 300 seconds. A guide draft saved in Strella clears the cache once the save has responded.\n\n#### Highlight Reels\n\n- **Saved clips get a little room around their words.** `POST /projects/{project_id}/highlight-reels` and new clips in `PATCH /projects/{project_id}/highlight-reels/{reel_id}` store each clip widened into the silence around it: up to 0.3s before its first word and 0.6s after its last, stopping 0.1s short of other speech and at the recording's ends. Strella plays and downloads a clip exactly between its stored times, so a range ending on a word's last syllable no longer cuts it. Reads return the stored, widened times. The create response echoes the times as sent. Clips saved before this change keep their times.\n\n### [v1.13.0] - 2026-10-27\n\n#### Breaking Changes\n\n#### Questionnaire\n\n- **A bulk questionnaire `PUT` clears `min_selections`, `max_selections` and `rating_style` when an item leaves them out.** `PUT /projects/{project_id}/questionnaire/items` replaces each item whole, so it treats these three fields like every other optional item field. The API stores a `rating_scale` sent without `rating_style` as a Likert scale with the Likert labels. **Migration:** send the three fields on every item that uses them.\n\n#### Participants\n\n- **A participant who gave no name reads as their participant number.** Every participant read returns `name` as `Participant #N`, numbered per project, where it returned `Anonymous`. This covers both shapes of `GET /projects/{project_id}/participants`, `GET /projects/{project_id}/participants/{participant_id}` and the `PATCH` response. Participants created before numbering still read `Anonymous`. The `name` filter still matches the stored name. **Migration:** match on `Participant #` as well as `Anonymous` when your code looks for nameless participants.\n\n#### Added\n\n#### Questionnaire\n\n- **Mark a multiple choice option exclusive, such as \"None of the above\".** Each entry in `choices` accepts an optional `exclusive` boolean on `PUT /projects/{project_id}/questionnaire/items` and the single-item `POST`, `PUT` and `PATCH`. Reads return it as `true` where it is set. When a participant picks an exclusive option, Strella clears every other pick, and picking another option clears the exclusive one. The flag applies only to a `multiple_choice` item with `multi_select: true`. Strella drops it with no error on any other item, or once `multi_select` is turned off. Exclusive options are listed last. `min_selections` and `max_selections` count only the options that are not exclusive, since an exclusive pick is a complete answer on its own.\n- **Reload an embed or Figma prototype at another page when the interview reaches an item.** Questionnaire items accept `media_redirect_url`, the guide editor's \"Redirect to URL for this question\", on `PUT /projects/{project_id}/questionnaire/items` and the single-item `POST`, `PUT` and `PATCH`. When the interview reaches the item, the media block's embed or prototype reloads at that `http` or `https` address. Later items keep showing it until another item sets one.\n  - The field is valid only on an item inside a `media_block` whose `media_type` is `embed`, or `figma` with no prototype imported in Strella. The API returns `422` for a value anywhere else.\n  - Inside a `figma` block the value must be a Figma prototype, design, board, slides or deck link. Strella stores it in the `embed.figma.com` form the block content uses.\n  - The limit is 2048 characters. `\"\"` clears the value on every write, and `PATCH` also takes `null`. Reads return the field only where it applies.\n  - A bulk `PUT` that omits the field keeps the stored value while the item stays in such a block, as it does for `block_media_interaction`.\n- **Read a Figma block's imported prototype, and set the task type, start screen and goal screen of a task inside it.** Every questionnaire read returns `figma_prototype` on a `media_block` whose `media_type` is `figma`. It holds the prototype's `name` and its `screens`, each an `id` and a `name`, and is `null` when no prototype is imported in Strella. A Researcher imports a prototype in the Strella guide builder, which needs a Figma sign-in. The API never imports one.\n  - On a `task` inside such a block, the bulk `PUT` and the single-item `POST`, `PUT` and `PATCH` accept `figma_task_type` (`free_explore` or `goal_based`, the guide editor's \"Task type\"), `figma_start_screen` and `figma_goal_screen`. Reads return them.\n  - A screen is an `id` from `figma_prototype.screens`. A goal screen is valid only with `goal_based`. `\"\"` clears a screen, and `PATCH` also takes `null`.\n  - The API returns `422` for these fields on any other item, for a screen the prototype does not have, and for a goal screen on a `free_explore` task.\n  - The API stores `free_explore` on a task with no type, as the guide editor does, and reads return `free_explore` for such a task.\n  - Reads leave out a screen the prototype no longer has, and a goal screen on a `free_explore` task, so a read sent back does not return `422`.\n  - A bulk `PUT` that omits these fields keeps the stored values while the task stays in the same block.\n- **Set a question's blur, verbatim and participant-controlled settings.** Questionnaire items accept `blur_screen_recording`, `verbatim` and `participant_controlled`, the guide editor's \"Blur recording for privacy\", \"Ask question verbatim\" and \"Participant-controlled\". They work on the bulk `PUT` and the single-item `POST`, `PUT` and `PATCH`, and reads return them.\n  - `blur_screen_recording` blurs the participant's screen share in the recording while the item is asked.\n  - `verbatim` makes the moderator read the item as written. Follow-up questions still adapt to the participant's answers.\n  - `participant_controlled` makes the moderator wait after the item until the participant clicks to move on.\n  - The API accepts `blur_screen_recording` and `verbatim` on a question, task, statement or survey item, and `participant_controlled` on `question`, `free_response`, `multiple_choice`, `ranking`, `rating_scale` and `slider` items. It returns `422` for `true` on any other item.\n  - A write that omits a setting keeps the stored value while the item's type still offers it. A retype drops a setting the new type does not offer. `null` on `PATCH` clears a setting.\n  - Reads return a setting only on an item whose type offers it, so a read sent back does not return `422`.\n\n#### Share links\n\n- **Read whether a share link has its own participant limit, and read or set its first-name-only mode.** Share link reads return `participant_limit_enabled` and `first_name_only`: `GET /projects/{project_id}/share-links`, the single-link `GET`, and the `POST` and `PATCH` responses.\n  - `participant_limit_enabled` is `true` only when the link has its own cap: its limit is turned on and a number is set.\n  - It is `false` when the link uses the project participant target, and `participant_limit` then holds that target. It is also `false` when the link's limit is turned off, and `participant_limit` is then `null`.\n  - `first_name_only` is `true` when the link asks participants for a first name and no last name. It reads `false` while the link does not collect names.\n  - `PATCH /projects/{project_id}/share-links/{share_link_id}` accepts `first_name_only` as `true` or `false`, never `null`, because the setting has no project value to inherit.\n  - `true` returns `422` with `code: first_name_requires_names` unless the link collects names after the patch. The API reads `collect_names` from the same body first, then the link's own setting, then the project's.\n  - Sending `collect_names: false` turns off `first_name_only`.\n\n- **Let a tall image scroll at the participant's screen width.** Image `media_block` items accept and return `image_fit` on `PUT /projects/{project_id}/questionnaire/items` and the single-item `POST`, `PUT` and `PATCH`: `contain` (the whole image fits the screen, the default) or `fit-width` (the image spans the screen width and the participant scrolls down it). Reads return it only on image blocks set to `fit-width`, since `contain` is stored as the default. A write that omits it keeps the stored value, `contain` resets it, and a `PATCH` can also clear it with `null`. It is ignored on every other item, and a block retyped away from `image` drops it.\n\n#### Changed\n\n#### Questionnaire\n\n- **Moving an item or changing a media block's type clears the media settings the item can no longer use.** When a write moves an item out of an `embed` or `figma` block, or changes the block's `media_type`, the API clears the item's `block_media_interaction` and `media_redirect_url` where they no longer apply, as the guide editor does. Changing a `figma` block to another `media_type` also removes the prototype imported for it in Strella. Before this release, a bulk `PUT` kept an item's redirect address after a move or a block type change. A single-item `PUT` or `PATCH` that changed a block's type also kept its items' redirect addresses.\n- **A bulk questionnaire `PUT` keeps a task's Figma task type and screens only while the task stays in the same block with an imported prototype.** Before this release, `PUT /projects/{project_id}/questionnaire/items` copied them from the stored task wherever the task was, so a task moved to another block kept screens from a prototype it no longer showed. The API drops them when the task moves, stops being a `task`, or when its block loses its prototype or changes `media_type`. It also drops a kept screen the prototype no longer has. The single-item `PUT` and `PATCH` do the same for the item they write and for the tasks of a block they retype.\n\n#### Screener\n\n- **Screener reads return an Other option and a slider scale only on the question types that use them.** `GET /projects/{project_id}/screener` and the `PUT` response return `slider_config: null` on every question type except `slider_scale`. They return `include_other_option: false` and `other_option_answer_value: \"1\"` on every type except `radio` and `checkbox`. A question whose type changed in Strella returns no scale or Other option from its old type. `PUT /projects/{project_id}/screener` still accepts these fields on any type except `matrix` and stores them only on the types that use them, so a body built from an earlier read still saves. Field types are unchanged.\n\n#### Fixed\n\n#### Questionnaire\n\n- **Resending a Figma link keeps it unchanged.** Strella stored the query values of a `figma` block's `content` link that already carried `scaling` in one order. When the next write sent the read value back, Strella stored them in another order. Strella stores one order for both, so a get-modify-put returns the link it read. A link stored in the old order changes once, on its next write.\n- **Rewriting a guide keeps a variant block that shows every variant.** A Researcher can set a variant block in Strella to show all of its variants in random order. The API has no field for that setting, so `PUT /projects/{project_id}/questionnaire/items` and the item `PUT` replaced it with the default, and the block showed one variant. The API keeps the stored setting, unless the write sets `variant_subset_size`, which makes the block show that many variants.\n\n#### Security\n\n#### Highlight reels\n\n- **A reel's public share link requires access to participant recordings.** Anyone who holds the link can play the reel's recordings. `share_url` is `null` for a caller whose PII access hides recordings, on `GET /projects/{project_id}/highlight-reels`, `GET /projects/{project_id}/highlight-reels/{reel_id}` and both write responses. A create or update from such a caller that sends `create_share_link: true` returns `403` with `code: recordings_access_required` and writes nothing. The API caches the reel list separately for each PII access level.\n\n#### Participants\n\n- **Filtering participants by age requires access to participant age.** An `age_min` or `age_max` filter tells the caller who falls in the range. `GET /projects/{project_id}/participants` with either filter returns `403` with `code: age_access_required`, on both list shapes, for a caller whose PII access hides age.\n- **Filtering participants by name follows name access.** A contains match on the full name confirms parts of a name the caller cannot see. `GET /projects/{project_id}/participants?name=` matches the first name only for a caller with first-name-only access. It returns `403` with `code: name_access_required` for a caller with no name access.\n- **Quota membership requires access to every demographic field.** A quota names a demographic group, such as \"Women 25-34\", so its name, and its id looked up on `GET /projects/{project_id}/quotas`, reveal demographics. For a caller whose PII access hides any demographic field, participant detail, `?detail=full` and the `PATCH /projects/{project_id}/participants/{participant_id}` response return `quota: null` and `quota_ids: []`. A `PATCH` from such a caller that sends `quota_ids` returns `403` with `code: demographics_access_required` and writes nothing, because `quota_ids` replaces the whole list. Response shapes are unchanged.\n\n### [v1.12.0] - 2026-10-19\n\n#### Breaking Changes\n\n#### Screener\n\n- **A screener question sent without `is_required` is now required.** On `PUT /projects/{project_id}/screener`, `is_required` defaults to `true`, the same as a question added in the Strella app, where it previously defaulted to `false`. The default applies to every question in the body, including an existing question sent with its `id`, because the write replaces the whole screener. Strella does not rewrite questions stored earlier, and reads return the same values. **Migration:** to keep a question optional, send `is_required: false` on it in every write.\n\n#### Added\n\n#### Projects\n\n- **Create human-moderated projects.** `POST /projects` accepts an optional `moderation_mode`: `ai` (the default) or `human`, where a Researcher runs each interview live on a video call the participant books. It is set at creation and cannot be changed. `GET /projects` and `GET /projects/{project_id}` return `moderation_mode` on every project. A human-moderated project is desktop only: create and `PATCH` return `422` for any `device_restriction` other than `desktop_only`, and reads report `desktop_only` whatever value is stored. Setting availability and publishing still happen in Strella, on the project's Scheduling and Build tabs, so a human-moderated project stays a draft through API writes (screener, settings) until it is published there.\n- **Read and change a project's custom fields.** `GET /projects/{project_id}`, `GET /projects?detail=full`, `GET /projects/search?detail=full` and the `PATCH` response return `custom_fields`. The list holds every field on the project's participants table, including Tags and AI-coded categories, in column order, so an integrator can read the fields no participant has filled in yet. Each item has `id`, the same id participant `custom_fields` use, plus `name` and `type` (`text`, `single_select` or `multi_select`). It also has `options`, the `{ value, label }` pairs a select field offers or `null` for `text`, and `is_match_key`, whether the field is the participant identifier. `PATCH /projects/{project_id}` accepts `custom_fields` as the whole list, so an integrator can send a read list back. An entry with an `id` changes that field, and an entry without one adds a field. The list order becomes the column order. The API deletes a field left out of the list, along with every participant's value in it. Within an entry, `options` left out or `null` keeps the field's options, and `is_match_key` left out keeps its role. An option's `value` defaults to its label in lower case with spaces as underscores. Send an existing `value` to keep the participants who hold it when you relabel the option. Names must differ ignoring case (`409`, `code: name_conflict`). An id the project does not have returns `422` (`code: unknown_custom_field`). Only one `text` field can be the participant identifier (`422`), and the API refuses the role while active participants share a value in the field (`409`). The API does not convert participants' values when `type` changes, and reads return a value stored in the old shape in the field's new shape. Removing an option leaves participants' values as they are. When the API refuses the list, it writes nothing else in the request. Scope `projects:write`.\n\n#### Participants\n\n- **Rename a participant and set whether they are excluded from analysis.** `PATCH /projects/{project_id}/participants/{participant_id}` accepts `name` (1-100 characters, trimmed) and `excluded_from_analysis` (boolean), alone or alongside `status`; `status` is now optional. A rename needs full name access (`pii_acl.name` is `full`), otherwise it returns `403` with `code: full_name_access_required`, and it returns `422` with `code: project_anonymizes_names` on a project whose names are anonymized aliases. An empty body, or `payment_method`, `amount_cents` or `reason` sent without `status`, returns `422`. Setting `excluded_from_analysis` to `false` asks the project's Summary to count the participant again.\n- **Reassign a participant's quotas.** `PATCH /projects/{project_id}/participants/{participant_id}` accepts `quota_ids`, the participant's whole quota list: read `quota_ids` from the participant, change it, and send it back. A quota left out of the list loses the participant, and `[]` removes them from every quota. A participant whose interview counts carries that completion into each quota they are added to, and removing them releases it, as when a researcher moves them in Strella. Screener criteria are not checked. The list takes at most one quota per quota group, and at most one quota on a project with quota groups turned off (`422` with `code: two_quotas_in_one_group` or `several_quotas_without_quota_groups`); a repeated id returns `422`, and an id that is not a live quota on the project returns `404` with `code: quota_not_found`. Nothing is written when the request is refused, and quota reads reflect the move straight away. Scope: `participants:write`.\n- **`excluded_from_analysis` on every participant read.** `GET /participants` (both shapes), `GET /participants/{participant_id}` and the `PATCH` response carry `excluded_from_analysis`, whether the participant is left out of the project's analysis.\n- **Bookings on every participant read.** `GET /participants` (both shapes), `GET /participants/{participant_id}` and the `PATCH` response carry `booked_interviews`, the interview slots the participant booked on a human-moderated project, newest start first. Each booking has `id`, `start_at` (RFC 3339, UTC), `status` (`booked`, `cancelled`, `no_show` or `completed`) and `join_url`, the moderator's link to the call, which opens only for a signed-in member of the organization and is `null` unless the booking is `booked`. A cancel and rebook adds a row, so earlier rows are the history. The array is `[]` on AI-moderated projects. The detail endpoint accepts `booked_interviews` in `?exclude=`.\n- **Custom fields on participant detail.** `GET /participants/{participant_id}`, `GET /participants?detail=full` and the `PATCH` response return `custom_fields`: the project's participants table columns, including Tags and AI-coded categories, that hold a value for the participant, in column order. Each item has `id`, which stays the same when a Researcher renames the field, plus `name`, `type` (`text`, `single_select` or `multi_select`) and `value`. `value` is a string, or an array of strings for `multi_select`. A select value is the option's stored value, not its display label. The API leaves out a field the participant holds no value in, so `[]` means the participant holds no values. The API returns the values whatever the caller's PII access. The detail endpoint accepts `custom_fields` in `?exclude=`.\n- **Set a participant's custom field values.** `PATCH /projects/{project_id}/participants/{participant_id}` accepts `custom_fields`, a map from field id to the value the field should hold. A `text` field takes a string, a `single_select` field one option `value`, and a `multi_select` field the full list of option `value`s. `null` clears a field, and fields left out keep their values. An unknown field id, a value that is not an option, or the wrong shape returns `422` with `code` `unknown_custom_field`, `custom_field_value_not_an_option` or `custom_field_value_wrong_shape`. A participant identifier value another participant holds returns `409` (`code: match_key_value_taken`). The API checks and writes the values before any other change in the request, so a refused value changes nothing. Scope `participants:write`, no PII scope needed.\n- **Filter participants by upcoming booking.** `GET /participants` accepts `booking=upcoming`, which keeps only participants holding a booked slot that has not started yet. It combines with the other filters and matches no one on an AI-moderated project.\n\n#### Screener\n\n- **Read and write matrix screener questions.** A matrix asks one question across several rows that share an answer scale, such as \"How often do you use each tool?\" with rows Slack and Zoom and columns Daily and Never. `question_type: \"matrix\"` carries its grid in a new `matrix` field: `selection_mode` (`single`, one column per row, or `multiple`), `qualify_when` (`all_rows` or `any_row`), `columns` and `rows`. Each row's `cells` maps column text to how picking that cell counts, using the radio and checkbox codes: `\"1\"` qualify and `\"2\"` disqualify on single select, `\"1\"` may select, `\"2\"` must select and `\"3\"` disqualify on multiple selection. A cell left out counts as `\"1\"`. Writes follow the Strella builder's rules: 1 to 30 rows, 2 to 10 columns, labels filled in and unique, and every single-select row keeps at least one qualifying column. Echo a row or column `id` to keep it; the server assigns ids to the rest. `GET /projects/{project_id}/screener` now returns matrix questions, and every other type returns `matrix: null`. Every caller can add, edit and remove matrix questions, org API keys included.\n- **Quotas and section logic can target matrix cells.** A quota's `answers` and a questionnaire section's `audience_rules` name a matrix cell as `\"Row: Column\"`, for example `\"Slack: Daily\"`, the same labels the Strella app lists. A screener write that removes a cell a quota or `audience_rules` uses returns `409`, as it does for a removed answer.\n- **Matrix answers name their row and column.** In `GET /projects/{project_id}/screener-responses` and participant detail, each answer to a matrix question adds `row` and `column`, each an `{id, text}` pair matching the grid from `GET /projects/{project_id}/screener`. `text` stays the `\"Row: Column\"` label.\n- **Skip logic on matrix cells.** The new `matrix_condition` skip logic type routes on cells named by `row_text` and `column_text`, joined with `and` or `or`. Like `slider_threshold`, `destination_question_id: null` ends the screener and an empty string continues to the next question; `next` and `end` are refused as question references. Strella stores the rule by row and column id, so it survives renames made in the app, and a write that removes a row or column drops the conditions on it.\n\n#### Questionnaires\n\n- **A refused image block says why in a `code`.** The `422` for an image block's `content` that is not this organization's upload carries `code`: `hosted_elsewhere` (a link to an image anywhere else) or `not_this_organization` (a Strella address another organization owns, or one no longer served). Branch on `code` rather than the `detail` text.\n- **Block interaction with an embed or prototype until an item is answered.** Questionnaire items accept `block_media_interaction` (boolean) on `PUT /projects/{project_id}/questionnaire/items`, the single-item `POST`, `PUT` and `PATCH` (where `null` clears it). When `true`, the media block's embed or Figma prototype is covered while that item is being asked, so the participant answers before interacting with it. It is only valid on an item inside a `media_block` whose `media_type` is `embed` or `figma`; `true` anywhere else returns `422`. Reads return it only on items inside such a block. A bulk `PUT /projects/{project_id}/questionnaire/items` that omits it keeps the stored value, as it did before the field was exposed, so a write that predates the field does not turn off a toggle set in the guide editor; send `false` to turn it off. Changing a block's `media_type` to anything other than `embed` or `figma` turns it off on the block's items.\n- **New `tree_test` questionnaire item type.** A tree test gives the participant a findability task (the item `content`) and a bare label hierarchy to navigate; the recorded answer is the confirmed node plus the full timestamped navigation path. Items carry `tree_test_nodes` (the hierarchy as a depth-first flat list of `{id?, label, depth}`; provide `id` to keep node identity across updates) and `tree_test_tasks` (exactly one `{id?, correct_node_ids}` in this release, whose single entry must reference a provided node `id`). Like `card_sort`, a `tree_test` item may hold non-media follow-up `children`, lives at the root or inside `section_block`/`variant_child`, and supports AI follow-ups. Reads return the same flat shapes with ids always present. Every caller with write access can author tree tests, org API keys included. The single-item `PUT /projects/{project_id}/questionnaire/items/{item_id}` has no fields for a tree, so it returns `422` for a `tree_test` body: write the whole item with the bulk `PUT /projects/{project_id}/questionnaire/items`, or change only its text and follow-up settings with `PATCH`.\n\n#### Changed\n\n#### Questionnaires\n\n- **The guide is called the question guide.** Descriptions and guides now say \"question guide\" where they said \"interview guide\", matching the Strella app. One error message changed with them: the `422` for a questionnaire write on a human-moderated project now reads \"Human-moderated projects have no question guide: …\". Its status, `type` and fields are unchanged, so match on those, not on the message.\n\n#### Quotas\n\n- **Quota group moves name the reason they were refused.** A refused `group_id` on `PATCH /projects/{project_id}/quotas/{quota_id}` now carries `code` in the error body (`quota_not_found`, `quota_groups_disabled`, `group_not_in_project`, `has_grouped_assignments`, `double_booked` or `overlapping_group_siblings`), as participant `quota_ids` refusals do. Status codes and messages are unchanged.\n\n#### Screener\n\n- **Slider Next destinations read back as an empty string.** `GET /projects/{project_id}/screener` and screener write responses return `destination_question_id: \"\"` for a stored Next rule, where reads previously returned `null`. When you rewrite a cached `null`, send `\"\"` to keep Next; sending `null` selects Exit. Read the latest screener before rewriting saved routing values.\n- **`PUT /projects/{project_id}/screener` no longer returns `409` when the screener has a matrix question.** It replaces the screener like any other, so a matrix question left out of the body is removed, the same as a question of any other type.\n\n#### Projects\n\n- **Projects created through the MCP connector subscribe their creator to project emails.** A project created through the MCP connector adds its creator's primary email to the project's email notifications, as a project created in Strella does. A project created with an org API key subscribes nobody.\n\n#### Questionnaires\n\n- **An image `media_block` only accepts an image of your own organization.** On `PUT /projects/{project_id}/questionnaire/items`, the single-item `POST`, `PUT` and `PATCH`, a new image address must be a Strella upload whose project belongs to the calling organization and is not deleted, or an organization-level upload of that organization. An address from another organization's project, a partner logo, or any other address returns `422`, as a link to an image on another site already did. An address the questionnaire already holds keeps saving unchanged.\n- **An image `media_block` may be written without its image.** `PUT /projects/{project_id}/questionnaire/items`, the single-item `POST` and the single-item `PUT` accept a `media_block` whose `media_type` is `image` with empty `content`, so a guide can carry the image step, its label and its questions before the image exists. The image is uploaded in the Strella guide builder, which shows an error on the empty block until it is. Every other item still returns `422` for empty `content`, now with the message `content must not be empty`.\n- **Interview guide writes on a human-moderated project return `422`.** `PUT /projects/{project_id}/questionnaire/items` and the single-item create, replace, patch and delete endpoints refuse a project whose `moderation_mode` is `human`, which has a screener but no interview guide. Write its screener with `PUT /projects/{project_id}/screener` instead. Before this, a guide write on such a project also published it and created its share link, skipping the in-app check that a bookable slot exists.\n\n#### Recruitment\n\n- **User Interviews orders are refused for human-moderated projects.** Creating or submitting a User Interviews order on a project whose `moderation_mode` is `human` returns `422`, matching the Strella app, which offers only Respondent for these projects.\n- **Respondent orders for human-moderated projects are submitted in Strella.** A draft can still be created over the API and is priced at the human-moderated rate. Submitting it over the API returns `422` before any credits move, where it previously failed at the panel and returned `503` after a refund.\n\n#### Share Links\n\n- **Redirect URLs no longer inherit a project value.** `call_finished_redirect_url`, `screenout_redirect_url`, `quota_full_redirect_url` and `call_dropout_redirect_url` on a share link now return the link's own value, or `null` when the link leaves it unset. Previously an unset field returned the project-level redirect URL, which Strella has retired; existing project values were copied onto their share links first.\n- **Redirect URLs need a full hostname.** `call_finished_redirect_url`, `screenout_redirect_url`, `quota_full_redirect_url` and `call_dropout_redirect_url` on share link create and update now return `400` for a URL whose hostname has no dot, such as `https://localhost` or `https://intranet`. This matches the rule the Strella app already applies when a researcher saves a redirect URL.\n\n#### Fixed\n\n- **A write naming another organization's record returns `404`.** A create or update whose body names a project, participant, session or other record that belongs to a different organization returns `404 not-found`, the same answer as an id that does not exist, and writes nothing. It returned `500` before.\n\n#### Participants\n\n- **A repeated `?detail=full` list request keeps its detail fields.** `GET /projects/{project_id}/participants?detail=full` served from the cache returned only the default list fields, dropping `profile`, `quota`, `quota_ids`, `summary`, `recordings`, `screener_responses` and `survey_responses`, for up to 60 seconds after the first request for the same page. Cached and uncached pages are identical. The OpenAPI spec documents `summary`, `recordings`, `screener_responses` and `survey_responses` on `ParticipantDetailListItem`, and the generated SDK types them.\n\n#### Projects\n\n- **A repeated `?detail=full` list request keeps its detail fields.** `GET /projects?detail=full` served from the cache returned only the default list fields, dropping every `ProjectDetail` field such as `close_date`, `auto_invite`, `share_url`, `participant_target_count` and the moderator and device settings, for up to 60 seconds after the first request for the same page. Cached and uncached pages are identical.\n\n#### Questionnaires\n\n- **A single-item replace no longer saves an empty card sort.** `PUT /projects/{project_id}/questionnaire/items/{item_id}` has no fields for cards, so a body with `item_type: \"card_sort\"` stored the item with no cards. It returns `422` instead, naming the bulk `PUT /projects/{project_id}/questionnaire/items` for writing the whole item and `PATCH` for changing only its text and follow-up settings.\n- **Reads show a guide or screener published in Strella right away.** Publishing an interview guide or a screener in Strella now clears the cached `GET /projects/{project_id}/questionnaire`, `GET /projects/{project_id}`, `GET /projects` and `GET /projects/{project_id}/screener` responses for that organization. Before this, those reads kept returning the guide from before the publish for up to 5 minutes. A client that honors the `Cache-Control: private, max-age=300` header can still hold its own copy for that long.\n\n#### Recruitment\n\n- **A repeated `?detail=full` list request keeps its targeting.** `GET /projects/{project_id}/recruitment?detail=full` served from the cache returned only the default list fields, dropping `targeting` and `target_market`, for up to 30 seconds after the first request for the same page. Cached and uncached pages are identical.\n\n#### Screener\n\n- **Deleting an uninitialized screener keeps it empty.** `DELETE /projects/{project_id}/screener` records the clear even when the project's questions exist only in its legacy screener data, so opening the screener later cannot restore them. Deletion also serializes with screener saves and rejects older autosaves after a successful clear. Cached project, screener and quota reads refresh whether addressed by project ID or slug.\n- **Slider routing preserves next and end across API/MCP edits.** Slider `destination_question_id: null` exits the screener as documented, and an empty string continues to the next question. Reading and rewriting stored next/end rules preserves the participant routing decision, including legacy sentinel values. Explicit slider routes to `skip_logic_id` aliases `next` or `end` return `422` with a remedy to use the question row `id` or another alias. Radio routes to `next` or `end` are valid only when that value identifies one real, later question in the replacement; these strings are not Next or Exit commands for radio rules.\n- **Screener writes validate retained skip logic.** `PUT /projects/{project_id}/screener` and MCP edits return `422` if an omitted `skip_logic` would retain a rule incompatible with the resulting type, answers, destinations, or order. Clear or replace the rule in the same write. Guide writes reject invalid pending screener rules before changing live guide content or share links; publication promotes only the validated question identities. An intentionally empty saved draft clears the previous published screener, including broken rules that the researcher removed.\n- **A screener written over the API now shows in Strella's Build tab.** `PUT /projects/{project_id}/screener`, `DELETE /projects/{project_id}/screener`, and a questionnaire write that publishes pending screener drafts now also replace any screener draft a researcher saved in the app. Before this, the Build tab kept showing that older draft, and publishing from the app, which every human-moderated project needs, replaced the screener the API wrote.\n\n### [v1.11.0] - 2026-10-01\n\n#### Added\n\n#### Projects\n\n- **Spanish (Colombia) is available as an interview language.** Use `es-CO` in `default_language`, `allowed_languages` and `analysis_language` when you create or update a project. Two voices speak it, Mateo (`default-3dg6epbg8sg15fev8rk7fw__design-voice-8a36c00e`) and Sofia (`default-3dg6epbg8sg15fev8rk7fw__design-voice-eb6f66cc`). Both appear in `GET /projects/settings`.\n\n#### Participants\n\n- **See which variant each participant was shown.** Participant responses now include `variant_assignments`. It lists each variant block in the interview guide with the variant (or variants) the participant was assigned, for example `[{ \"vb_pricing\": [\"vc_monthly\"] }]`. The ids match the ones in `GET /projects/{project_id}/questionnaire`. The list is empty until the interview starts, and on projects without variant blocks. Being assigned a variant does not mean the participant reached it in the interview.\n\n#### Transcripts\n\n- **See which interview guide question each transcript line belongs to.** `GET /projects/{project_id}/participants/{participant_id}/transcripts` adds:\n\n  - `item_id` on each line: the id of the guide question being discussed, matching `GET /projects/{project_id}/questionnaire`. It is `null` for the introduction, the wrap-up, and interviews without question data.\n  - `meta.questions[]`: each question with its text and the time range it covers in the transcript (`start_ms`, `end_ms`).\n  - `has_question_data` on each recording, which is `false` for human-moderated and uploaded interviews.\n\n  This works for interviews run by the AI moderator. To leave these fields out, add `?exclude=questions`.\n\n- **See whether each transcript line is the main question or a follow-up.** On the same endpoint:\n\n  - Each line has `turn_kind` (`main` or `follow_up`) and `follow_up_number` (1 for the first follow-up, 2 for the second, and so on). A participant's answer gets the same label as the question it answers.\n  - Each entry in `meta.questions[]` has `follow_up_count` and `turn_source`. `agent` means the AI moderator recorded its follow-ups directly. `inferred` means the labels were worked out from the conversation, for older interviews and for questions without follow-ups. If the conversation is unclear, `turn_kind` is `null`.\n\n  Both fields are `null` outside a question and for human-moderated or uploaded interviews. More `turn_kind` values may be added later, so treat values you don't recognize as unlabelled. See the \"Building Data Sync Pipelines\" guide for details.\n\n### [v1.10.0] - 2026-09-24\n\n#### Breaking Changes\n\n#### Projects\n\n- **`video_sharing_requirement` is now on/off: `required_to_join` or `disabled`.** `always_required` and `optional` are removed. `POST /projects` and `PATCH /projects/{project_id}` reject them with 422, and `GET /projects/settings` no longer lists them. Both already recorded the participant's camera, as `required_to_join` does, while the dashboard showed the camera as off. Projects already holding either value now read back as `required_to_join` wherever a project is returned, which is what participants get. Send `disabled` to turn the camera off.\n\n#### Added\n\n#### Projects\n\n- **The two quota enforcement toggles are now readable and writable.** `GET /projects/{project_id}` returns `reject_when_quota_full` and `require_quota_match` (UI labels \"Reject participants when a quota is full\" and \"Require participants to match a quota\"), and `PATCH /projects/{project_id}` accepts both. Unlike most interview settings, these are `Project` columns rather than `Questionnaire` ones, so they are plain required booleans, never `null`, even on a project with no questionnaire yet. Both toggles only take effect once the project has at least one active quota. Additive, non-breaking.\n\n#### Highlight reels\n\n- **A saved highlight reel can carry one public share link, requested on save.** `POST /projects/{project_id}/highlight-reels` and `PATCH /projects/{project_id}/highlight-reels/{reel_id}` accept an optional `create_share_link: true`, and both responses now carry `share_url`: the reel's public `/share/player/{id}` link, or `null` when the reel has none. The flag only creates the reel's first link. Once a reel has one, every save through these endpoints refreshes that same link to the reel's current clips, whether or not the flag is sent, so a link already handed out never plays a stale cut. On `PATCH`, the flag may be sent on its own to get a link for an existing reel without changing it. The link step never fails the save: when no link can be made (the reel has no playable clip), the save succeeds with `share_url: null`. `GET /projects/{project_id}/highlight-reels` rows and `GET /projects/{project_id}/highlight-reels/{reel_id}` carry the same `share_url` field, reporting the reel's current link or `null`; reads never create one. Additive, non-breaking.\n\n#### Participants\n\n- **Each recording now reports the device the participant used.** The `Recording` shape (embedded in `GET /participants/{participant_id}`'s `recordings[]`, and in `interview_samples[]` on `strella_project_detail` over MCP) gains `device_type`: `desktop_web`, `mobile_web`, `mobile_app`, or `null` when no device signal survives (uploads, human-moderated, or pre-capture sessions). Derived from the session's self-reported platform and recorded user agent, the same classification the in-app Analysis/Sessions \"Device\" filter already uses. Additive, non-breaking.\n\n#### Questionnaires\n\n- **`text` media type.** `media_type` on a `media_block` accepts a new value, `text`, which shows researcher-written copy on the participant's screen to read and react to while the block's child items are asked, with the same placement, timing and child behaviour as an `image`. It is the one media type whose `content` is not a URL: `content` carries the statement itself as HTML, so a URL written there is stored as literal text and never fetched. The markup is restricted to `p`, `br`, `b`, `strong`, `i`, `em`, `u`, `s`, `h1`, `h2`, `h3`, `ul`, `ol`, `li`, `blockquote`, `hr`, `a` and is **sanitized on write**: the API strips tags and attributes outside that list (scripts, event handlers, images, `javascript:` links), so read the item back to see exactly what was stored. `label` still names the block in the guide editor. Like every media type except `video`, a concept block requires at least one child. Any caller with `projects:write` can author one on `POST`, `PUT`, `PATCH` and the bulk `PUT` alike.\n\n#### Changed\n\n- **A `rating_scale` item now always stores its full label set.** On `PUT /projects/{project_id}/questionnaire/items`, the single-item `POST` and `PUT`, and the matching MCP writes, a `rating_scale` written without `choices`, with an empty array, or with entries whose `content` is empty is stored with the style's default label in every position: the 4-, 5- or 7-point agreement labels for `likert` (\"Strongly disagree\" through \"Strongly agree\"), \"Very bad\" through \"Very good\" for `faces`, and \"1 star\" through \"5 stars\" for `stars`. Reads therefore return `choices` with non-empty `content` for every rating scale written from now on, where before they returned the empty entries or no `choices` at all. Labels a caller provides are kept as written, and provided `id`s are preserved. The request schema is unchanged: empty and omitted `choices` are still accepted, and the 4/5/7 size rule still applies. Items stored earlier are not rewritten. Participants see a stored label translated into their interview language, as they do for every `multiple_choice` option, and their answer persists as the stored label rather than the translated one, which keeps `GET /projects/{project_id}/survey-responses` comparable against the item's `choices`.\n- **Enabling the `media_block` watermark is no longer internal-only.** `watermark: true` on a questionnaire write (`PUT /projects/{project_id}/questionnaire/items`, and the single-item `POST`) previously returned 422 \"The stimulus watermark is not currently available\" for an org-scoped API key, since the field was released in an internal-only rollout. Any caller with `projects:write` can now turn it on. Reads, the field's shape, and its behavior are unchanged; the restriction that it applies to `media_block` items other than `website` still holds and is still advisory rather than enforced.\n- **A project scheduled for permanent deletion by a Strella admin no longer appears anywhere on the `/projects` resource.** While an admin data-removal request is active on a project (or on its whole organization), the project is treated like a deleted one: `GET /projects` leaves it out of the rows and `meta.total`, both search endpoints stop returning it, `GET /projects/{project_id}` answers 404, and so do `PATCH` and `DELETE`, so reads and writes on the resource always agree on which projects exist. If the removal request is aborted, the project reappears unchanged. Sub-resource endpoints under a saved project id (participants, transcripts, quotas, …) still answer during the window, so an export a customer started before requesting removal can finish. This matches the in-app dashboard, which has hidden these projects since the removal window was released.\n- **An image `media_block` must use an image the guide already holds.** `POST`, `PUT` and `PATCH` on `/projects/{project_id}/questionnaire/items/...` and the bulk `PUT /projects/{project_id}/questionnaire/items` return 422 when a `media_block` with `media_type: \"image\"` carries a `content` link to an image hosted elsewhere, or when a `PATCH` retypes a block to `image` while it still holds one. The guide builder has always taken image uploads only, and there is no upload endpoint here, so a caller can only reuse an address it read back. An image address the guide already holds is accepted unchanged, so reading a guide and sending it back still works, older guides included. To show an image hosted elsewhere, upload it in the guide builder; `media_type: \"embed\"` is for a link to a web page that allows framing, not an image file. The OpenAPI spec is unchanged.\n- **A targeting criterion must now carry a non-empty option id.** `POST /projects/{project_id}/recruitment/user-interviews` and its `PATCH` already rejected an unknown characteristic slug or an operator the characteristic does not support, but accepted an empty value behind either: `{\"single_select--gender\": {\"$eq\": \"\"}}` and `{\"multi_select--skill\": {\"$in\": [\"\"]}}` both passed validation and were refused by the panel vendor instead, on an order the caller had already been charged for. Both shapes now return 422 at the request. To apply no filter on a characteristic, omit its criterion.\n- **Date range filters now require a full ISO 8601 timestamp including seconds.** The `created_after`, `created_before`, `updated_after`, and `updated_before` query parameters validate datetimes more strictly: the value must carry seconds, so `2026-01-01T00:00:00Z` is accepted while the minute-only `2026-01-01T00:00Z` is now rejected with 422. Nearly every client already sends seconds. If yours sends minute precision, add `:00`. This follows the underlying validation library's stricter ISO 8601 parsing.\n- **An `audience_rules` shape the interview cannot evaluate is now rejected with 422 instead of being stored with a different meaning.** The interview reads a section's conditions left to right, so at most one group may hold several filters, unless every multi-filter group shares a filter (`[[A, C], [B, C]]`). A body like `[[A, B], [C, D]]` was previously accepted and then showed the section only to participants matching \"((A and B) or C) and D\". Every rule the editor can build still reads and writes back unchanged. The error names the supported shapes and what to do instead.\n- **Quota `criteria` the matcher cannot apply are now rejected with 422, and mixed criteria read back with their real meaning.** Quota matching checks criteria left to right with no parentheses, the same rule as section logic above, so `POST`/`PATCH /projects/{project_id}/quotas` now accept at most one multi-filter group, unless every multi-filter group shares a filter (`[[A, C], [B, C]]`). A body like `[[A, B], [C, D]]` was previously accepted and then matched participants as \"((A and B) or C) and D\"; when each segment can have its own target, create one quota per group. `GET` now returns a quota built in the app as \"A or B, and C\" as `[[A, C], [B, C]]` rather than `[[A], [B, C]]`. An \"or\" between single-filter groups was never affected.\n- **Quota `criteria` now accepts at most 20 filter groups and at most 20 filters per group.** Larger bodies return 422 naming the limit; before, any criteria that fit the 1 MB request body were accepted. `GET` is unaffected, and the limit is checked at the request rather than published as `maxItems` in the OpenAPI schema.\n\n#### Fixed\n\n- **A Respondent recruitment order that fails to reach the panel vendor now always refunds the credits.** Submitting an order charges credits, then sends the order to the recruitment panel provider; when that send fails, the charge is refunded so the credits are not lost. The refund already ran when the vendor returned a failure, but a vendor call that threw an error skipped it. Both failure shapes now refund. No request or response field changed.\n- **An `audience_rules` \"or\" now behaves as \"or\" in the interview.** A rule written with two or more `filter_groups` was stored in a way the interview read as \"and\", so a participant who matched only one group never saw the section, even though the editor displayed the rule as \"or\". Rules written from now on show the section to participants who match the rule as written. A multi-group rule saved through the API before this fix keeps the old behavior until it is written again. Reads were wrong in the mirror direction: a multi-condition rule built in the editor with \"or\" came back as a single AND group, and now comes back as the groups the interview actually evaluates, so a rule built as \"(A or B) and C\" reads as `[[A, C], [B, C]]`.\n- **A User Interviews vendor outage no longer caches empty search results for an hour.** `GET /recruitment/user-interviews/characteristics/{slug}/options` returns an empty option list when the User Interviews vendor errors, the same shape as a genuine no-match. That empty answer was cached for the full hour TTL, so one brief vendor error returned zero results to every caller until it expired. The API no longer caches an empty list caused by a vendor failure, so results return as soon as the vendor does. No request or response field changed.\n- **Project participant status counts no longer include preview walkthroughs.** The status breakdown on `GET /projects` and `GET /projects/{id}` (`unqualified_count`, `qualified_count`, `invited_count`, `in_review_count`, `accepted_count`, `rejected_count`) counted a Researcher's builder-preview sessions as participants (most visibly, a completed preview raised `in_review_count`, \"For review\" in MCP's project detail), even though previews never appear in the participant list endpoints or the in-app participant table. Preview interviewees are now excluded from every status count, and from the `min_in_review_count` / `min_accepted_count` filter matching. Integrators caching these counts may see them drop on projects whose researchers used the interview preview.\n- **`meta.total` is no longer 0 when filtering by `min_in_review_count` or `min_accepted_count`.** The stable-total query behind those filters compared participant statuses against values the platform never writes, so every project computed an in-review and accepted count of 0 there: a request like `GET /projects?min_in_review_count=1` returned matching projects in `data` while `meta.total` reported 0. The total now counts the same statuses the returned data does.\n\n#### Security\n\n- **A request body with a `__proto__` key is now rejected with 422 instead of silently dropped.** Every write endpoint validates its body with a strict schema, and the underlying validation library now reports and strips a `__proto__` property rather than passing it through unexamined. No legitimate integrator sends this key, so no working integration is affected; this closes a prototype-pollution path for one that tries.\n\n#### Recruitment\n\n- **Submitting the same recruitment order twice can no longer charge it twice.** When two submits of one draft order race, or a client retries a submit that is still running, the API charges the order at most once. The first submit claims the order before charging; a concurrent or repeat submit returns `409` instead of charging again. A submit that failed and was refunded can still be retried.\n\n### [v1.9.0] - 2026-08-31\n\n#### Added\n\n#### Quotas\n\n- **Quota groups.** Quotas can now be organized into mutually exclusive groups: one group per screening dimension (for example every gender option), with each participant counted in at most one quota per group. New resource: `GET`/`POST /projects/{project_id}/quota-groups` and `PATCH`/`DELETE /projects/{project_id}/quota-groups/{group_id}` list, create, rename and delete a project's groups (schema `QuotaGroup`: `id`, `name`, `quota_count`, `created_at`). Names are unique per project (409 on a duplicate), a group must be empty to delete (409), and writes require quota groups to be turned on in the project's settings (422 otherwise). Scopes: `projects:read` / `projects:write`.\n- **`quota_groups` on the quota list.** `GET /projects/{project_id}/quotas` now also returns `quota_groups`, every live quota group on the project (oldest first, empty groups included), so one call reads the whole sample plan. `meta.total` still counts quotas. Empty array when the project has no groups.\n- **`group` on quota reads.** Every quota returned by `GET /projects/{project_id}/quotas`, `GET …/quotas/{quota_id}`, and the `POST`/`PATCH` write echoes carries `group: { id, name } | null` (schema `QuotaGroupRef`), or `null` for an ungrouped quota. The field is read-only; placement is written through `group_id`.\n- **`group_id` on quota writes.** `POST /projects/{project_id}/quotas` accepts `group_id` to create a quota inside a group; `PATCH …/quotas/{quota_id}` accepts `group_id` to move a quota into a group or `null` to ungroup it. The criteria must not overlap any quota already in the target group (409 naming the siblings), and a quota whose participants are assigned under its group cannot be ungrouped (409). A `group_id` that names no live group on the project returns 404.\n\n#### Participants\n\n- **`quota_ids` on participant reads.** `GET /projects/{project_id}/participants/{participant_id}` and `GET /projects/{project_id}/participants?detail=full` now return `quota_ids: string[]`: every quota the participant belongs to, primary first, completed quotas included. On a project using quota groups this can hold several ids; otherwise it mirrors the existing `quota` field as `[quota.id]` or `[]`. Always included (not excludable via `?exclude=`). The `ParticipantDetailListItem` schema now also declares the `quota` field that `?detail=full` has always returned.\n\n#### Questionnaires\n\n- **Matrix question type.** New `matrix` value for `item_type`: a grid question with rows of items and columns of mutually exclusive options; the participant picks exactly one column per row, and the response stores one JSON string per row (`{\"rowId\",\"row\",\"columnId\",\"column\"}`). New optional fields on item reads and writes: `matrix_rows` (array of `{id?, content}`, required 1 to 30 for matrix items) and `matrix_columns` (array of `{id?, content}`, required 2 to 10). The API keeps row and column ids across a read and a write. Survey responses refer to rows and columns by those ids. Both fields are rejected on non-`matrix` items with 422. `randomize` shuffles row order, and the dynamic follow-up fields apply. Matrix is an ordinary leaf: it places like any question (including inside a `media_block` or as a `card_sort` child) and cannot carry `children`.\n- **NPS question type.** New `nps` value for `item_type`: a fixed 0 to 10 \"how likely are you to recommend\" scale; the response stores the picked score as a single string (`\"0\"` to `\"10\"`). One optional field, `nps_style` (`buttons` | `slider`, default `buttons`), chooses how the scale renders and is rejected on non-`nps` items with 422; the score is identical either way. The dynamic follow-up fields apply. NPS is an ordinary leaf, placing like any question and carrying no `children`.\n- **`tree_style` on `tree_test` items.** One optional field, `tree_style` (`focused` | `full`, default `focused`), chooses how the participant's outline reveals the hierarchy: `focused` drills down, hiding the siblings of every opened section, while `full` keeps every top-level section on screen and expands sections in place. Presentation only: the recorded navigation events are identical either way. A second optional field, `tree_branches_selectable` (boolean, default `false`), says whether participants may answer with a branch; off, branches only open and close, the answer is a leaf, and the correct destination must be a leaf. Both are rejected on non-`tree_test` items with 422. Like the rest of `tree_test`, writes are internal-only during rollout.\n- **`label` on `media_block` items.** A media block stores two distinct values, the media URL and the display label the guide editor shows, and the API carried only one field for both. `content` is now always the URL, and the new optional `label` carries the label, on reads and on every write path. On `PUT`/`POST` an omitted `label` keeps the stored label and `\"\"` clears it; on `PATCH`, absent leaves it unchanged and `null` clears it. `label` is rejected with 422 on any other item type; a section's title is its `content`. Additive and optional.\n- **`watermark` on `media_block` items (internal-only rollout).** Media blocks accept and return a `watermark` boolean on questionnaire reads and writes. When `true`, the in-call media is marked with a tiled per-participant watermark during the interview. Ignored on non-`media_block` items. Enabling it (`watermark: true`) is internal-only during rollout: an org-scoped API key write returns 422.\n\n#### Changed\n\n- **Closing an order that spent any credits refunds the whole unused amount as credits.** `POST /projects/{project_id}/recruitment/{vendor}/{order_id}/close` previously refunded nothing automatically for an order paid with both credits and a card (the response carried no `refund`). It now refunds every unused seat, at the price the order was bought at, to the organization's credits, and the response carries `refund` as it does for a credits-only order. Card-only orders are unchanged. No request or response field changed.\n- **Respondent orders on a human-moderated project are priced at the human-moderated tier.** `POST /projects/{project_id}/recruitment/respondent/{order_id}/submit` and the add-participants extension now charge $25 (B2B) / $15 (B2C) per participant, on top of the incentive, when the project runs human-moderated interviews; AI-moderated projects keep $10 / $6. The tier is fixed on the order when it is created, and refunds on close pro-rate what the order was actually charged. No request or response field changed.\n- **Rejecting a participant removes the data their interviews generated, not only the interview rows.** `PATCH /projects/{project_id}/participants/{participant_id}` with `{ status: 'rejected' }` already removed every interview the participant held; it now also removes the notes those interviews produced, their transcript entries, Figma prototype clicks, and eye-tracking gaze chunks. Previously a rejected participant's clips could remain in saved reels and their gaze in the aggregate heatmap. Reject now runs the same cascade the in-app delete does.\n- **Highlight reel reads drop clips whose interview was deleted or archived, instead of returning them flagged.** `GET /projects/{project_id}/highlight-reels` (including `?include=clips`) and `GET …/highlight-reels/{reel_id}` return clips for live interviews only; such clips no longer appear in `clips` and no longer count toward `clip_count`. Clips hidden for eligibility, completeness, or playability reasons still return with `playable: false` and a `hidden_reason`. Note a clip removed this way is absent from the ids a `GET` returns, so a `PATCH` echoing that list drops it from the reel permanently, matching what the reel already renders.\n- **`PATCH /projects/{project_id}/quotas/{quota_id}` rejects criteria that overlap a quota-group sibling.** When the target quota belongs to a group, a `criteria` update whose segments are not provably disjoint from every other quota in the same group returns 409 naming the conflicting quotas. Quotas in a group must be mutually exclusive, and previously a criteria edit could silently break that invariant, which then caused participant-facing screener errors.\n- **`audience_rules` validation messages no longer name the item type they assumed.** Three 422 `detail` strings changed wording because the same checks now cover more question types: a value outside a numeric question's bounds reads `Value 99 is outside the range [0, 10] for question \"X\"` (was \"outside the slider range\"); a numeric filter on a question with no scale reads `question \"X\" has no numeric scale`; and the unknown / non-referenceable `question_id` messages now list `multiple_choice, rating_scale, nps, or slider`. Status codes, error paths, and the RFC 9457 shape are unchanged; only the human-readable `detail` text changed. If your integration string-matches `detail`, update it. An integration that reads `status` and the error path needs no change.\n\n#### Fixed\n\n- **Quota counts include every quota-group membership.** On a project with quota groups enabled, `completed_count`, `qualified_count`, `invited_count` and the derived `status` on quota reads counted only each participant's primary quota, so every other bucket a participant belonged to was under-reported and could read `open` while full. The counts now count every membership, the same way the check that refuses to start a recording on a full quota does (that check additionally counts live recordings, so a quota's API status can read `open` while a recording start is refused). `invited_count` also no longer includes a participant who has already completed: on every project, a self-recruited participant's completion was previously counted twice. The response shape is unchanged.\n- **Checkbox `skip_logic` written through the API no longer breaks Respondent recruitment.** The API stored condition operators with its own labels where the dashboard editor stores `Selected` / `Not Selected` / `AND` / `OR`, so paying for a Respondent audience on a project whose screener skip logic came from the API failed with a 400 and the screener never reached the panel. The API now stores the same labels the editor does, and screeners already saved the old way recover without an edit. No request or response shape changes: `match` is still `selected` / `not_selected` and `join` is still `and` / `or`.\n- **`audience_rules` referencing an in-call slider, rating scale, or NPS question no longer 422s.** Only `multiple_choice` items were registered as referenceable, so reading a questionnaire that conditioned a section on any other supported type and writing the identical body back was rejected. All four in-call source types now resolve: `slider` accepts `kind: \"numeric\"` filters bounds-checked against its range; `rating_scale` accepts `kind: \"choice\"` filters over its rendered labels; and `nps` accepts either: `kind: \"choice\"` over its scores (`\"0\"` to `\"10\"`, with segment names written as the scores they cover, so `Promoter` is `[\"9\", \"10\"]`) or `kind: \"numeric\"` with a threshold on the fixed 0 to 10 scale. A numeric threshold the editor had namespaced as `<item_id>-<n>` was also silently dropped from reads; the prefix is now stripped on both paths.\n- **`PUT /questionnaire/items` now records the publish in version history.** API and MCP publishes changed the live guide without writing a version history entry, so the dashboard's History panel never showed them and the published state had no restore point. The bulk write now snapshots a `published` history row after the publish commits, attributed to the calling user for OAuth/MCP callers (unattributed for API keys). No request or response shape changes.\n- **Figma prototype links are normalized to embed form on write.** A figma-typed `media_block` written with a raw embeddable `figma.com` link is now stored as the `embed.figma.com` embed URL the in-call iframe requires, the same transformation the builder UI applies, and reads return that normalized URL. Previously the raw link was stored verbatim and participants saw Figma's error page instead of the prototype. A `PATCH` that switches an existing block's `media_type` to `figma` without resending `content` re-normalizes the stored URL. Non-Figma URLs, non-embeddable Figma pages, and `*.figma.site` links are unaffected.\n- **A questionnaire write no longer destroys every media block's URL.** A labeled `media_block` read back its label in `content`, and every write wrote the single `content` input into both stored fields, so any GET → modify → PUT round-trip wrote each block's label over its URL project-wide. The blocks then rendered \"Invalid URL\" and broke the live interview. `content` now always carries the URL and the new `label` field carries the label, so a read followed by a write loses nothing. For labeled media blocks this changes the value of `content` from the label to the URL, with no schema change, and it restores the contract the endpoint always documented. `content_hash` / `base_hash` now covers media URLs.\n- **`PUT /questionnaire/items` no longer strips internal state from nested items.** The bulk write carries internal state (a media block's Figma prototype link, variant behavior, and now its `label`) over from the item each incoming item replaces, but it only did so for top-level items. Nesting rules put a `media_block` under a `section_block` and never at the root, so a round-trip PUT dropped a nested block's Figma prototype association without an error. The carry-over now covers items at every nesting level, matching items by `id`. Fields you send always win, as before.\n\n### [v1.8.0] - 2026-08-11\n\n#### Added\n\n#### Projects\n\n- **`greet_participant_by_name` on project settings.** The moderator's opening\n  greeting can now be read and written over the API. `GET /projects/{project_id}`\n  and `GET /projects?detail=full` return the nullable boolean, `POST /projects`\n  and `PATCH /projects/{project_id}` accept it, and `GET /projects/settings`\n  reports its default. When it is off the moderator opens with \"Hi there\" instead\n  of the participant's name. The real name is still stored and still keys results\n  and transcripts, which is what separates this setting from\n  `anonymize_participant_names`. Optional and additive.- **`has_recordings` filter on `GET /projects`.** Narrows the list to projects\n  holding at least one session with a playable recording, or with\n  `has_recordings=false`, to those holding none. Optional query parameter,\n  additive.\n\n#### Participants\n\n- **`anonymize_participant_names` on participants responses and project list\n  items.** A key can hold `participants:read:email` and still receive no emails,\n  because the project's anonymization setting overrides the grant. Every\n  participants response now says so.\n  `GET /projects/{project_id}/participants` carries a top-level\n  `anonymize_participant_names` boolean in both detail modes, and the participant\n  detail (and the `PATCH` response) carries the same field alongside `pii_acl`.\n  `pii_acl` continues to report what the caller was granted; this field explains\n  when the project setting overrides it. `GET /projects` returns it per item as\n  well, where it was previously detail-only. The MCP participant tools pass it\n  through. Additive.\n- **Raw recording layout URLs.** `recordings[]` now exposes each underlying\n  recording separately, where previously only the coalesced `recording_url` was\n  exposed and it hid which layout you actually received.\n\n  Three of them are viewing layouts, and these are the ones to play:\n  `split_screen_url` (participant camera plus shared screen, null when the\n  participant did not share their screen), `screen_share_url`, and\n  `participant_camera_url`. Anonymization can replace a layout with an audio-only\n  track, so each of these three is checked and returns null rather than handing\n  you audio. The check matches on file extension, which holds for every track the\n  platform produces today.\n\n  The fourth, `video_url`, is not a viewing layout. Do not play it. It is the\n  recording used for transcription and analysis, whichever layout suits\n  processing best, and it can be audio-only on an anonymized session. It is also\n  the field that still carries the audio the three layout fields withhold.\n\n  Additive, with no change to existing fields.\n\n#### Share links\n\n- **Share links resource.** Five new endpoints manage a project's participant\n  share links: `GET` and `POST /projects/{project_id}/share-links`, and `GET`,\n  `PATCH` and `DELETE /projects/{project_id}/share-links/{share_link_id}`. Reads\n  return every setting fully resolved (a per-link override falling back to the\n  project value), plus the participant-facing `share_url`, `status`, the resolved\n  `participant_limit`, redirect URLs, and a per-link `participant_count`. The\n  list is cursor-paginated (`?limit=` 1 to 100, default 50, and `?cursor=`) and\n  sortable (`?sort=created_at:desc` by default, or `updated_at`). On write,\n  per-link settings accept `null` to clear an override and inherit the project\n  value, following RFC 7396 merge patch. A project must keep at least one live\n  link, so deleting the last one returns 409, as does a duplicate name.\n\n#### Questionnaires\n\n- **Media upload question type.** New `media_upload` value for `item_type`.\n  Participants upload a file (an image, a video, or a document) as their answer,\n  and the response stores a durable media URL. The new optional\n  `media_upload_kinds` field, an array of `image`, `video` and `document`,\n  restricts which kinds are accepted, and each kind carries a fixed type\n  allowlist (documents are pdf, doc and docx, xls and xlsx, ppt and pptx, txt,\n  and csv). Omitting the field accepts every kind. An empty array is rejected\n  with 422, and the field is rejected on any other item type. Any key with write\n  access can create these items.\n- **Card sort question type.** New `card_sort` value for `item_type`.\n  Participants drag cards into groups during a call, and the response stores one\n  JSON string per group. Three new optional fields: `card_sort_type` (`open`,\n  `closed` or `hybrid`, defaulting to `open`), `card_sort_cards` (required and\n  non-empty for card sort items), and `card_sort_groups` (required and non-empty\n  for closed and hybrid sorts, rejected for open sorts). All three are rejected\n  on any other item type with 422. A card sort item may carry `children`, which\n  are non-media follow-up questions asked after the sort while the board stays on\n  screen. The existing `randomize` field shuffles the card order each participant\n  sees on the sorting board, and the dynamic follow-up fields\n  (`dynamic_followups`, `follow_up_depth`, `follow_up_guidance`) let the AI\n  moderator probe the participant's final groupings. Any key with write access\n  can create these items.\n\n#### Recruitment\n\n- **`auto_invite` on recruitment orders.** Recruitment order responses on both\n  panels now include `auto_invite`, and `POST` and `PATCH` recruitment order\n  bodies accept it as an optional field. Setting it to `true` turns on Strella's\n  own AI auto-approval, so a screener passer rated 4 or above is invited without\n  manual review. Recruitment panels run a separate auto-invite of their own, and\n  this field does not touch it. Strella holds that one off on every submission to\n  either panel, regardless of what you send here. On create, an omitted\n  `auto_invite` inherits the project's setting. `require_approval` remains\n  rejected on request bodies.\n\n#### Errors and rate limiting\n\n- **`ip-temporarily-blocked` problem type (429).** An address is blocked for a\n  short period after repeated rejected credentials, which is a different thing\n  from exceeding a request rate. Both used to return `ip-rate-limit-exceeded`, so\n  neither the response nor a dashboard could tell them apart. A block now returns\n  its own type, and `ip-rate-limit-exceeded` now means only an over-budget\n  rejection. `X-RateLimit-Limit` reads `0` on a block, deliberately: publishing\n  the real threshold would tell a caller guessing credentials how many guesses\n  fit underneath it. Read `Retry-After` instead, which reports the block's real\n  remaining time. Existing handling of the 429 status is unaffected.\n\n#### Idempotency\n\n- **`Idempotency-Replayed: true` response header.** When a write reuses an\n  `Idempotency-Key` whose stored response is replayed (an identical body, inside\n  the 24 hour window), the response now carries `Idempotency-Replayed: true`. A\n  fresh write omits it. This lets a client tell a no-op replay from a new write\n  without diffing bodies. It applies to every write endpoint. Additive.\n\n#### Changed\n\n- **`rating_scale` items no longer require `choices`.** Omitted or empty choices\n  mean the platform renders the standard labels for the item's `rating_style`,\n  localized to the participant's language, on a 5 point scale. Send 4, 5, or 7\n  entries to pick a size or to supply custom labels. This is the exact state the\n  dashboard builder stores for a rating scale left at its default labels, so a\n  questionnaire containing one no longer fails every\n  `PUT /projects/{project_id}/questionnaire/items` with a 422 naming an item the\n  request never touched. That failure permanently write-blocked affected\n  projects. The per-item\n  `PUT /projects/{project_id}/questionnaire/items/{item_id}` gains the matching\n  rules: the choices requirement no longer applies to `rating_scale`, and a\n  present choices array must have exactly 4, 5, or 7 entries, previously\n  unvalidated there, so a single-item write cannot store a scale size the bulk\n  endpoint rejects.\n- **A `media_block` with `media_type: video` no longer requires `children`.** A\n  video is a valid stimulus-only step: the participant watches it, says they are\n  done, and the interview advances. The other media types (`image`, `website`,\n  `figma`, `embed`) still require at least one child item. This is a relaxation,\n  so existing payloads are unaffected.\n- **`PUT /projects/{project_id}/screener` validates skip-logic references.** A\n  skip-logic rule pointing at an answer or a question the screener does not\n  contain now returns 422, where it previously saved with 200 and then failed to\n  branch participants correctly, showing up in the app as a \"Skip logic\n  references deleted answer\" warning. Two cases are rejected. First, an\n  `answer_routing` destination key (radio) or a `condition` `answer_text`\n  (checkbox) that does not match one of the question's `answers[].text`; the\n  error names the unknown answer and lists the valid ones, and the free-text\n  \"Other\" option is not a routable answer. Second, a `destination_question_id`\n  that does not resolve to the `skip_logic_id` of a question still present after\n  the write. To keep cross-question routes intact across a full replacement, send\n  each question's `id`, which preserves its `skip_logic_id` automatically. `null`\n  and `\"\"` destinations continue to mean \"next question\" and \"end the screener\"\n  and are never rejected. This matches the validation the screener editor already\n  applies.\n- **The address block counts distinct credentials, not failed attempts.** The\n  block exists to stop someone guessing API keys, and guessing means trying many\n  credentials, so that is what it measures. One wrong credential retried any\n  number of times counts once and never triggers a block; enough different ones\n  still do. A caller sharing an outbound address with colleagues, or running many\n  jobs at once, can no longer lock itself out by retrying a single wrong key. A\n  request carrying no credential at all no longer counts toward the block either,\n  because the per-address request rate already caps those.\n- **A valid credential turned away on policy no longer counts toward the block.**\n  Four refusals return `401` for a credential that is entirely valid: an API key\n  of the wrong kind, an OAuth bearer missing its companion MCP token header, an\n  MCP token issued without an organization claim, and a token type this API does\n  not serve. None of them is someone guessing secrets, so none escalates now. A\n  caller whose configuration is wrong sees the same `401` as before and can keep\n  retrying while they fix it.\n- **`participants_count` now includes uploaded text transcripts.** A pasted or\n  uploaded interview transcript counts toward a project's participant total the\n  same way an uploaded video does, because it is the researcher's own collected\n  data filling a slot. The endpoints reporting `participants_count`\n  (`GET /projects` and `GET /projects/{project_id}`) and the\n  `min_interviews_count` and `max_interviews_count` filters may therefore return\n  a higher number for projects with transcript uploads. This is a value change,\n  not a schema change: no field was added, removed, or retyped.\n- **`interview_count` now matches the participant-total rule.** A participant's\n  `interview_count` on `GET /projects/{project_id}/participants` is computed from\n  the one canonical resolved-session rule that `participants_count` and the app's\n  Completed count already use, instead of a separate predicate that no longer matched it.\n  Two effects follow. Uploaded and pasted text transcripts now count as\n  interviews, matching `participants_count`. And the recording arm is now\n  consistent with every other count, so some numbers shift by a small amount in\n  either direction: a self-recruited recording counts once it is past the\n  not-started state even if analysis is still finishing, and an abandoned panel\n  partial no longer counts. `total_duration_seconds` and the `recordings` array\n  are unchanged and stay recording-only, because a text upload has no media, so\n  it adds no duration and no recording. Value change only.\n\n#### Deprecated\n\n- **`recordings[].recording_url`.** Read `split_screen_url` for the split-screen\n  layout, and `video_url` for the recording used in processing. `recording_url`\n  returns the stored split-screen value as-is, falling back to `video_url` only\n  when nothing split-screen is stored, and is retained for back-compat. It skips\n  the check the layout fields apply, so on an anonymized or legacy session whose\n  split-screen slot holds an audio-only track, `recording_url` hands you that\n  audio while `split_screen_url` returns null. A non-null `recording_url` is\n  therefore not evidence that you received a video. The layout fields under Added\n  tell the two apart. Non-breaking.\n\n#### Fixed\n\n- **`GET /projects` no longer returns 500 when a project filter is combined with\n  a participant-count filter.** Pairing any project filter (`name`, `status`,\n  `created_after`, and the rest) with `min_interviews_count` or\n  `max_interviews_count` failed every time. Either filter on its own was\n  unaffected. The two now compose. No schema change.\n- **Questionnaire validation errors name their rule again.** The 422 `detail` on\n  a questionnaire item write had degraded to a bare `Invalid input` for every\n  rule-specific check: choice minimums, rating-scale sizes, slider\n  configuration, card-sort fields, nesting rules, and more. It now names the\n  rule, for example\n  `items.2.children.1.choices: rating_scale items must have exactly 4, 5, or 7 choices`.\n- **Participant `email` is withheld when the project has participant\n  anonymization enabled.** With `anonymize_participant_names` on, every\n  participants endpoint (`GET /projects/{project_id}/participants`, the same with\n  `?detail=full`, `GET /projects/{project_id}/participants/{participant_id}`, and\n  the `PATCH` response) now omits the `email` field even for a key granted\n  `participants:read:email`. This matches the app, which already hid emails on\n  anonymized projects. The MCP participant tools inherit the same behavior. Names\n  were always safe, because the stored name is an anonymized alias; only email\n  was exposed. No schema change: `email` was already optional, and is now\n  conditionally absent. Read the new `anonymize_participant_names` field to tell\n  this case apart from a missing grant.\n- **`Retry-After` on a block reports the real remaining time.** It previously\n  assumed a full window from the moment of rejection, so it could ask a caller to\n  wait up to a minute longer than the block actually lasts.\n- **A rate-limiting fault on our side no longer reads as your fault.** When our\n  own rate limiter is unavailable, the response now reports the service as\n  unavailable (503) rather than telling you that you had sent too many requests\n  (429).\n- **Recording URLs stay playable after a project moves to the private recordings\n  bucket.** On a private-bucket project, the recording URLs on\n  `GET /projects/{project_id}/participants/{participant_id}` and on the\n  `?detail=full` list (`recordings[].recording_url`, `split_screen_url`,\n  `screen_share_url`, `participant_camera_url`, and `video_url`) are now returned\n  as absolute `https://<app-host>/api/media/<bucket>/...` URLs, host-rooted like\n  every other URL field the API returns, instead of raw private-bucket URLs that\n  would return 403. Playback requires an authenticated app session, because the\n  media route re-signs on each read so the URL never expires: an API key can read\n  recording metadata but cannot fetch the video itself. Public-bucket projects\n  are unchanged and their URLs pass through byte-identical. This is a value\n  change, not a schema change: the field type and shape are identical.\n- **The transcripts endpoint now returns uploaded text transcripts.** A pasted or\n  uploaded interview transcript, which has no video recording, was excluded from\n  `GET /projects/{project_id}/participants/{participant_id}/transcripts`, from\n  participant session payloads, and from project session stats, because the\n  processed-session filter required a completed video upload. These now include\n  analyzed text transcripts. The `recordings` array stays video-only.\n\n### [v1.7.0] - 2026-07-07\n\n#### Fixed\n\n- **`status=qualified` now returns participants who passed the screener.**\n  Filtering `GET /projects/{project_id}/participants?status=qualified` returns\n  participants who passed the screener and are awaiting an invite, matching the\n  Qualified tab in the app. The `qualified_count` rollup on `GET /projects` and\n  `GET /projects/{project_id}` reports the same set. `status=unqualified` returns\n  participants who started the screener but have not submitted it, and\n  `unqualified_count` matches. The per-participant `status` field reports\n  `qualified` for these participants. No status values were added or removed.\n\n### [v1.6.0] - 2026-06-25\n\n#### Added\n\n- **Per-field participant PII scopes for API keys.** API keys can now be minted\n  with granular participant PII grants instead of the all-or-nothing\n  `participants:read:pii`. Each axis is its own scope: `participants:read:name:first`,\n  `participants:read:name:full`, `participants:read:email`,\n  `participants:read:recordings`, `participants:read:gender`,\n  `participants:read:age`, `participants:read:race-ethnicity`,\n  `participants:read:education`, `participants:read:household-income`,\n  `participants:read:job-title`, `participants:read:seniority`,\n  `participants:read:company-size`, `participants:read:city`,\n  `participants:read:survey-responses`, `participants:read:screener-responses`,\n  and `participants:read:transcript-bodies`. Scopes are set at key creation and\n  are returned on `auth().scopes`. A key minted with any per-field scope receives only\n  the fields it names; every other PII axis is withheld. When more than one name\n  scope is present, the more permissive one wins. A key minted with none of these\n  scopes keeps the legacy \"data on, identity off\" behavior unchanged (see\n  Changed). These additions are non-breaking.\n\n#### Changed\n\n- **`GET /projects/{project_id}/survey-responses` and\n  `GET /projects/{project_id}/screener-responses` are now governed by the PII\n  ACL.** A caller without the `participants:read:survey-responses` /\n  `participants:read:screener-responses` grant receives an empty list with HTTP\n  200, the same way the transcripts endpoint handles a denied axis. Existing keys\n  are unaffected: a key with no per-field PII scope maps to the legacy \"data on\"\n  access vector and sees no change, so only keys minted with per-field scopes that\n  omit these axes are restricted. Responses for both endpoints are now cached per\n  access vector, so two keys with different access can never share a cached page.\n\n#### Deprecated\n\n- **`participants:read:pii` is deprecated.** It continues to work indefinitely as\n  a full-vector alias that grants every PII axis at once. On new keys, request\n  the per-field scopes above to grant only the PII the key needs. No\n  migration is required for existing keys.\n\n### [v1.5.0] - 2026-06-22\n\n#### Breaking Changes\n\n- **Panel participants who never completed an interview are no longer returned as participants.** A \"panel partial\" is a participant recruited through a panel provider (Respondent or User Interviews) whose every recording was abandoned before the interview reached the end. They were never included in `participants_count` and were never billable, but the participant read endpoints still exposed them. They are now hidden everywhere a participant is read:\n\n  - `GET /projects/{project_id}/participants` (including `?detail=full`) omits them, and `meta.total` no longer counts them.\n  - `GET /projects/{project_id}/participants/{participant_id}` returns 404.\n  - `PATCH /projects/{project_id}/participants/{participant_id}` returns 404, so they can no longer be accepted or rejected.\n  - `GET /projects/{project_id}/participants/{participant_id}/transcripts` returns 404.\n  - `GET /projects/{project_id}/screener-responses` and `GET /projects/{project_id}/survey-responses` no longer include their rows; filtering either endpoint by such a participant's `participant_id` returns an empty list.\n\n  Unaffected: self-recruited participants are always returned, including their partials. Panel participants who completed an interview, are mid-interview (live), or were invited but never joined (no-shows) remain fully visible. This aligns the participant read endpoints with `participants_count`, which already excluded these participants (see the v1.4.0 fix below), and with the Strella dashboard's analysis views. The MCP server proxies these same endpoints, so its participant, transcript, and recording tools inherit the change automatically. No sunset window: these participants carried no completed interview data, so the change only removes empty or abandoned records.\n\n#### Added\n\n- **`pii_acl` on participant detail responses.** `GET /projects/{project_id}/participants/{participant_id}` (and the participant detail returned by `PATCH`) now include a `pii_acl` object reporting exactly which identity fields the caller may see: `name` (`full` / `first` / `none`), `email` (boolean), and `profile_picture_url` (boolean). This makes the previously all-or-nothing identity access inspectable and allows the three identity fields to be granted independently, for example first name plus email but no picture. The existing `pii_level` field stays and is now derived from `pii_acl`, rounding down: it reports `full` only when all three identity fields are visible. Additive and non-breaking: existing integrations receive the same `pii_level` and the same redacted fields they did before.\n\n- **AI-readable documentation endpoints.** An AI agent (or any HTTP client) can now read the API docs as plain markdown instead of the JavaScript-rendered reference UI. Three new public, unauthenticated endpoints: `GET /llms.txt`, a compact index that links every guide and the OpenAPI spec, following the [llms.txt](https://llmstxt.org/) convention; `GET /llms-full.txt`, all guides, conventions, and this changelog concatenated into one document for bulk reads; and `GET /docs/guides/{slug}.md`, any single guide as standalone markdown (for example `/docs/guides/02-authentication.md`), so an agent can fetch just the one it needs. The content is generated from the same guide sources and OpenAPI spec as the reference docs, so the two always match. `robots.txt` now permits compliant crawlers to reach these documentation paths while the rest of the app stays un-indexed.\n\n#### Changed\n\n- **Participant data fields are now returned only when the caller's PII permissions allow them.** The demographic `profile` (each field independently), `recordings`, `survey_responses`, and `screener_responses` on participant detail responses (`GET /projects/{project_id}/participants/{participant_id}` and `?detail=full` on the participant list), plus transcript bodies on the transcript endpoints, are returned only when the caller's PII access vector grants that axis. A field the caller may not see is absent from the response rather than null. Non-breaking for existing API keys: a key with no per-field PII scopes resolves to the legacy \"data on, identity off\" access vector, which keeps every one of these data fields exactly as it received them before. A key minted with any per-field PII scope instead starts from \"all off\" and receives only the fields it names. To withhold a specific data field (a demographic, recordings, responses, or transcripts) from a new key, leave its scope out when you create the key.\n- **`device_restriction` no longer constrains `screen_sharing_requirement`** on `POST /projects` and `PATCH /projects/{id}`. This reverses the v1.4.0 restrictions: `no_restriction` is accepted again, and `mobile_only` may be combined with `screen_sharing_requirement=enabled`. All three values (`desktop_only`, `mobile_only`, `no_restriction`) are now accepted regardless of screen sharing. When screen sharing is required and mobile devices are allowed, mobile participants are directed to the native Strella app at join time (mobile browsers cannot share a screen). This is a loosening: requests that previously returned a 422 now succeed.\n- **`screen_share_display_surface` and `require_screen_share_verification` no longer return a 422 for mobile device restrictions.** These settings apply to desktop participants who share in-browser and are ignored for participants routed to the app, so the prior incompatibility checks were removed.\n- `POST /projects/{project_id}/recruitment/respondent` and `POST /projects/{project_id}/recruitment/user-interviews` no longer require the project to be specially enabled for API recruitment. Orders can now be created on any project. Calls that previously failed with `422 recruitment-not-enabled` will now succeed.\n- **Clarified the `project_context` field documentation** on `POST /projects`, `PATCH /projects/{project_id}`, and `GET /projects/{project_id}` (and the `project_context` response include). The description now states that `project_context` is secure, business-internal research context that informs the question guide and is never shown to participants. It does **not** drive the live AI moderator's behavior. To change how the moderator behaves during interviews, set `general_moderator_prompt`. Field names, types, and runtime behavior are unchanged; this corrects previously misleading wording.\n\n#### Deprecated\n\n- **`pii_level` on participant detail responses is deprecated. Read `pii_acl` instead.** `pii_level` is still returned and unchanged, now derived from `pii_acl` by rounding down, so existing integrations keep working. It is a single label and cannot express the per-field combinations `pii_acl` now supports (for example a first name and email but no picture). Read `pii_acl` in new integrations. No removal date is set; `pii_level` will be retired in a future major version with a sunset window.\n\n- **`device_restriction: mobile_app` is deprecated as an input value.** It is still accepted on `POST /projects` and `PATCH /projects/{id}` as an alias and stored as `mobile_only`. It no longer appears in project responses or in the `GET /projects/settings` allowed-values enum; existing projects that stored it have been migrated to `mobile_only`. The mobile-app experience is now derived from `screen_sharing_requirement` plus `device_restriction` rather than being a stored device restriction.\n\n#### Removed\n\n- The `recruitment-not-enabled` problem type is removed. It is no longer returned by any endpoint (see the recruitment create change above).\n\n### [v1.4.0] - 2026-06-16\n\n#### Breaking Changes\n\n- **`device_restriction=no_restriction` is no longer accepted** on `POST /projects` and `PATCH /projects/{id}`. A request that sets it now returns a 422. Choose `desktop_only`, `mobile_only`, or `mobile_app` instead. Screen sharing adds one further constraint: when `screen_sharing_requirement=enabled`, `device_restriction` cannot be `mobile_only` (mobile web cannot share a screen), which leaves `desktop_only` or `mobile_app` as the valid choices.\n- **`voice_speed` now accepts only a fixed set of values** on `POST /projects` and `PATCH /projects/{id}`: `0.5, 0.7, 0.75, 0.8, 0.9, 1.0, 1.1, 1.2, 1.25, 1.5, 1.75, 2.0`. Any other value (for example `1.44`) now returns a 422. These are the speeds at least one voice provider supports. Call `GET /projects/settings` for the current set, and round any out-of-set value to the nearest listed one.\n- **The transcript `speaker` field is now access-controlled** on `GET /projects/{project_id}/participants/{participant_id}/transcripts`. Callers with the `participants:read:pii` scope still receive the participant's real name. Callers without it now receive a generic label (`Speaker A`, `Speaker B`, and so on) instead of the name. If your integration depends on real names in transcripts, request the `participants:read:pii` scope on your API key.\n- **Recruitment now requires a published screener** before an order can be created or launched. `POST /projects/{project_id}/recruitment/{panel}` (draft) and `POST /projects/{project_id}/recruitment/{panel}/{order_id}/submit` (launch) both return a 422 when the project has no published screener questions. This applies to both the Respondent and User Interviews panels. No order is created and no credits are charged when this 422 is returned. Publish a screener with `PUT /projects/{project_id}/screener` before creating a recruitment order.\n\n#### Added\n\n#### Projects\n\n- 10 new optional device and interview settings on `POST /projects` and `PATCH /projects/{id}`, also returned in project detail: `push_to_talk`, `blur_participant_camera`, `blur_participant_screen_share`, `allow_disabling_screen_recording`, `screen_share_display_surface`, `require_screen_share_verification`, `voice_modulation_enabled`, `default_captions_enabled`, `session_timeout`, and `anonymize_participant_names`.\n- Compatibility validation for the new settings, matching what the Strella app enforces. For example, `push_to_talk` requires `device_restriction=desktop_only`; `blur_participant_camera` requires video sharing enabled and `desktop_only`; the screen share sub-settings require `screen_sharing_requirement=enabled`. Incompatible combinations return a 422 that names the offending field.\n- `GET /projects/settings` now returns the defaults and allowed values for these new settings.\n\n#### Changed\n\n- `PATCH /projects/{project_id}` with `anonymize_participant_names: true` now backfills anonymous names for every existing participant as part of the same write that sets the flag. Previously the flag and the participant names could fall out of step if one write failed. When the flag changes, Strella also updates the speaker labels on existing transcripts to match.\n- `GET /projects/{project_id}/participants/{participant_id}` responses are now cacheable for callers with the `participants:read:pii` scope (`private, max-age=300`) instead of `no-store`. Cached responses are kept separate by access level, so a caller without PII access never receives a body cached for a PII caller. The response shape is unchanged.\n- Voice validation errors now name the rejected value, list a sample of valid voice IDs, and give the total count, so it is clearer why a value was rejected.\n\n#### Fixed\n\n- `GET /projects/{project_id}`: `draft_orders_count` now reports the project's actual number of draft recruitment orders. It was previously always `0` on the single-project detail endpoint: the API computed the count for the `GET /projects` list but never added it to the detail response. The list endpoint was already correct; only project detail is affected. No response shape change.\n- `GET /projects/{project_id}/participants/{participant_id}`: `screener_responses` no longer repeats a question. When a participant retakes a screener, the earlier answers are kept as history; this endpoint was returning that history alongside the current answer, so a single question could appear several times. It now returns only the current answer per question, matching `GET /projects/{project_id}/participants?detail=full` and `GET /projects/{project_id}/participants/{participant_id}/screener-responses`, which already excluded the superseded answers. The response shape is unchanged; affected participants simply see fewer, non-duplicated entries.\n- `GET /projects` and `GET /projects/{project_id}`: `participants_count` now counts only genuinely counted participants. It previously over-counted two cases: panel participants (Respondent and User Interviews) whose recording ended before the interview was completed, and participants who had since been removed from the project. The same correction applies to the `min_interviews_count` and `max_interviews_count` filters on `GET /projects`. Most projects are unchanged; projects with abandoned panel recordings or removed participants may see a slightly lower count, which now matches the number shown in the Strella dashboard.\n- `GET /projects/{project_id}/participants?detail=full`: panel participants with a numeric `age` in `custom_fields` are no longer dropped from the response. The previous value type accepted only text, so numeric values were rejected and those participants went missing even though `meta.total` still counted them. `custom_fields` values may now be text or numbers.\n- `PUT /projects/{project_id}/questionnaire/items`: `rating_scale` is now accepted inside a `media_block`, matching the questionnaire editor in the Strella app. It was previously rejected with `media_block can only contain leaf items`.\n- `PUT /projects/{project_id}/questionnaire/items`: `rating_scale` choices may now have empty `content`. The editor stores empty strings on Likert positions so the inputs show style-appropriate default labels; these were previously rejected, which blocked reading back any project that contains a default-labeled Likert. `multiple_choice` and `ranking` choices still require non-empty `content`.\n\n#### Improved\n\n- Lower latency on the quota detail and questionnaire write endpoints by running independent database queries together, plus faster repeat generation of the API docs.\n- Panel notifications on participant accept are now sent after the response is returned, removing roughly 100 to 200 ms from the request.\n- Response bodies are now checked against their published shape before they are sent. A mismatch raises an internal alert but never changes the response; this is observability only, with no change to any request or response shape.\n\n### [v1.3.0] - 2026-05-19\n\n#### Added\n\n#### Workspaces\n\n- `POST /workspaces`: create a workspace. The slug is generated automatically from the name. Requires the new `workspaces:write` scope.\n- `PATCH /workspaces/{workspace_id}`: rename a workspace. The slug is not updated on rename.\n- `DELETE /workspaces/{workspace_id}`: soft-delete a workspace. Projects assigned to the deleted workspace are unassigned (`workspace_id` set to null).\n- `workspaces:write` scope is required for all workspace mutations. Granted to admin and member roles; excluded from viewer.\n\n#### Questionnaires\n\n- New `rating_scale` item type with a `rating_style` field (`likert`, `faces`, or `stars`). Likert renders researcher-authored text labels (4, 5, or 7 positions). Faces renders 5 colored emoji. Stars renders 5 filled stars. Faces and stars generate their own labels automatically.\n- `min_selections` and `max_selections` on `multiple_choice` items with `multi_select: true`. Both bounds are enforced during the interview: participants cannot continue below the minimum and cannot select beyond the maximum.\n\n#### Recruitment\n\n- Sandbox mode on `POST /projects/{id}/recruitment/respondent` and `POST /projects/{id}/recruitment/user-interviews`. Pass `\"sandbox\": true` in the request body or send the `X-Sandbox: true` header to create a test order that skips the panel integration and generates a synthetic participant and session. Sandbox orders include `\"sandbox\": true` in all responses; real orders omit the field.\n\n#### API Lifecycle\n\n- Endpoints scheduled for removal now emit RFC 8594 sunset headers (`Sunset`, `Deprecation`, `Link`). The discovery endpoint (`GET /api/public/v1/`) shows deprecated endpoints with sunset metadata. See [Managing API Upgrades](https://app.strella.io/docs/api#tag/Managing-API-Upgrades) for guidance on detecting and handling deprecations.\n\n#### Fixed\n\n- `GET /projects/{id}/participants`: `interview_count` no longer inflated by screener and survey response counts. Previously returned the product of sessions, screener responses, and survey responses instead of the qualifying session count.\n- `GET /projects` with `min_in_review_count` or `min_accepted_count` filters: `meta.total` no longer inflated by per-participant session counts.\n\n### [v1.2.0] - 2026-05-14\n\n#### Fixed\n\n- Audience rules: `PUT /projects/{id}/questionnaire/items` with choice filters now correctly populates answer texts, fixing Question Guide editor validation errors on API-created rules\n- Section block titles: `GET /projects/{id}/questionnaire` now returns correct titles for section_block, media_block, and variant_block items created in the app\n\n#### Changed\n\n- Pagination: `meta.total` is now optional. Search endpoints omit it; list endpoints continue to return it\n- `PUT /projects/{id}/screener`: clarified `answer_value` description: radio uses \"1\" qualify / \"2\" disqualify\n\n### [v1.1.0] - 2026-05-08\n\n#### Added\n\n- Audience rules: `kind: \"numeric\"` filter for slider screener questions with `match_rule` operators (`lt`, `lte`, `eq`, `gte`, `gt`)\n- Screener skip logic: `skip_logic` and `skip_logic_id` now writable on `PUT /projects/{id}/screener`. Supports `answer_routing`, `condition`, and `slider_threshold` types\n- Questionnaire: `variant_subset_size` on variant_block items controls how many variants each participant sees\n- Filtering: `?updated_after=` and `?updated_before=` date filters on all list endpoints\n- Sorting: `?sort=updated_at:asc|desc` alongside existing `created_at` sort\n- Pagination: `total` field in `meta` object on all paginated responses\n- Recruitment: optional `title` field on Respondent orders, `name` field auto-disambiguated on all orders\n- Audience rules: delete-time reference checks return 409 when deleting quotas, screener questions, or questionnaire items still referenced by `audience_rules`\n- Credits: `GET /credits/balance` returns organization credit balance in cents\n- Credits: `GET /credits/transactions` returns paginated ledger with type, status, and date filters\n\n#### Breaking Changes\n\n- Screener `answer_value`: radio now accepts only `\"1\"` (qualify) or `\"2\"` (disqualify); `\"3\"` rejected with 422\n- Recruitment lifecycle: `POST .../recruitment/{id}/{submit|close|pause|unpause}` removed. Use `PATCH .../recruitment/{id}` with `{ \"status\": \"recruiting\" | \"paused\" | \"closed\" }`\n- Respondent options: `GET /recruitment/respondent/options` uses `{ code, label }` shape for all lists. `household_income` renamed to `household_incomes`\n- Respondent targeting: `targeting.household_income` renamed to `household_incomes` on create and detail\n- User Interviews options: `GET /recruitment/user-interviews/options` returns `characteristics` as a slug-keyed map instead of array\n- Participant status: `for_review` renamed to `in_review`, `completed` renamed to `accepted`. Affects status field values, filter params, and project count fields (`in_review_count`, `accepted_count`)\n\n### [v1.0.0] - 2026-03-30\n\n#### Added\n\n- Projects: create, read, update, list with filtering and pagination\n- Project settings: read platform defaults and valid configuration values\n- Participants: list, detail, screener responses (per-participant and per-project)\n- Workspaces: list, detail\n- Questionnaire: read, write items, write screener\n- OpenAPI 3.1 spec at `/api/public/v1/openapi.json`\n- Interactive API docs at `/docs/api` (publicly accessible)\n- RFC 9457 Problem Details error responses\n- Cursor-based pagination on all list endpoints\n- Rate limit headers on all responses (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`)\n- Idempotency support via `Idempotency-Key` header on write endpoints"},"servers":[{"url":"/api/public/v1"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Quickstart Guide","description":"Strella's API gives you programmatic access to research projects, participants, transcripts, screener and survey responses, recruitment orders, and credit balances. You can automate project creation, sync research data to a warehouse, trigger recruitment, and accept or reject participants without logging into Strella.\n\nThis quickstart guide ends with a working API call.\n\nWe will create an API key, make an authenticated request to list your projects, and create a new project. We will also cover error handling and pagination conventions.\n\n### Prerequisites\n\n- A Strella account with at least `org:member` role.\n- An HTTP client: cURL for the shell examples, `fetch` for TypeScript, and `requests` for Python.\n- The base URL: `https://app.strella.io/api/public/v1`.\n\n### Authentication\n\n1. Open **Settings > Developer > API keys** in Strella. Only organization admins see this page.\n2. Click **Create key**, give the key a name, and select the scopes it needs.\n3. Copy the key: Strella shows it once. Store it in a secret manager. All keys begin with `ak_`.\n\nAll requests require an `Authorization` header with a Bearer token:\n\n```bash\ncurl https://app.strella.io/api/public/v1/projects \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\nFor the full scopes reference, PII behavior, key rotation, and security best practices, see the **Authentication** guide.\n\n### Base URL\n\nAll endpoints are under `https://app.strella.io/api/public/v1`.\n\n### Your First Request\n\n#### List your projects\n\nThe following examples list the first page of projects in your organization.\n\n```bash\ncurl https://app.strella.io/api/public/v1/projects \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```typescript\nconst response = await fetch('https://app.strella.io/api/public/v1/projects', {\n  headers: {\n    Authorization: 'Bearer ak_your_key_here',\n  },\n})\n\nconst data = await response.json()\nconsole.log(data.projects)\n```\n\n```python\nimport requests\n\nresponse = requests.get(\n    \"https://app.strella.io/api/public/v1/projects\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n)\n\ndata = response.json()\nprint(data[\"projects\"])\n```\n\nThe API returns a response like this:\n\n```json\n{\n  \"projects\": [\n    {\n      \"id\": \"cm7a9qk2x0001jv04hn3w8m5p\",\n      \"project_name\": \"Q4 Onboarding Research\",\n      \"participant_facing_name\": \"Product Experience Interview\",\n      \"slug\": \"vk7d0kv4VXPkbNjP8sTj8QrYF\",\n      \"organization_id\": \"org_2abc123def456\",\n      \"created_at\": \"2025-10-15T14:30:00.000Z\",\n      \"participants_count\": 15,\n      \"survey_responses_count\": 42,\n      \"has_screener\": true,\n      \"questionnaire_items_count\": 8,\n      \"has_section_blocks\": true,\n      \"has_unpublished_changes\": false,\n      \"updated_at\": \"2025-11-20T09:15:00.000Z\"\n    }\n  ],\n  \"meta\": {\n    \"has_more\": true,\n    \"next_cursor\": \"dGVzdA\",\n    \"total\": 10\n  }\n}\n```\n\n#### Understand the response\n\nList endpoints wrap results in an object where the key is the resource name: `\"projects\"`, `\"participants\"`, `\"transcripts\"`, and so on. The `meta` object contains pagination state.\n\nWhen `has_more` is `true`, pass `meta.next_cursor` as the `cursor` query parameter on the next request to retrieve the following page. When `has_more` is `false`, you have received all results.\n\n#### Make a write request\n\nCreate a project with `POST /projects`. The `project_name` and `research_objectives` fields are required. The response status is `201` on success.\n\n```bash\ncurl -X POST https://app.strella.io/api/public/v1/projects \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"project_name\": \"Q1 Checkout Usability\",\n    \"research_objectives\": \"Understand where users drop off in the new checkout flow\\nValidate that the order summary screen reduces cart abandonment\"\n  }'\n```\n\n```typescript\nconst response = await fetch('https://app.strella.io/api/public/v1/projects', {\n  method: 'POST',\n  headers: {\n    Authorization: 'Bearer ak_your_key_here',\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({\n    project_name: 'Q1 Checkout Usability',\n    research_objectives:\n      'Understand where users drop off in the new checkout flow\\nValidate that the order summary screen reduces cart abandonment',\n  }),\n})\n\nconst project = await response.json()\n```\n\n```python\nimport requests\n\nresponse = requests.post(\n    \"https://app.strella.io/api/public/v1/projects\",\n    headers={\n        \"Authorization\": \"Bearer ak_your_key_here\",\n        \"Content-Type\": \"application/json\",\n    },\n    json={\n        \"project_name\": \"Q1 Checkout Usability\",\n        \"research_objectives\": (\n            \"Understand where users drop off in the new checkout flow\\n\"\n            \"Validate that the order summary screen reduces cart abandonment\"\n        ),\n    },\n)\n\nproject = response.json()\n```\n\nThe API returns the full project detail with status `201`. The creation response omits `share_url`. The API returns it only after the first `PUT /projects/{id}/questionnaire/items` call.\n\n### Pagination\n\nAll list endpoints paginate by cursor: each response includes a `next_cursor` you pass to fetch the next page. Pass `limit` (1-100, default 50) and `cursor` as query parameters. When `meta.has_more` is `true`, pass `meta.next_cursor` as `cursor` on the next request.\n\nFor the complete pagination patterns, including incremental sync and full extraction loops, see the **Building Data Sync Pipelines** guide. For pagination format details, see the **Conventions** reference.\n\n### Error Handling\n\nAll errors follow [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457):\n\n```json\n{\n  \"type\": \"insufficient-scope\",\n  \"title\": \"Insufficient Scope\",\n  \"status\": 403,\n  \"detail\": \"The API key does not have the 'projects:write' scope required for this request.\",\n  \"instance\": \"/api/public/v1/projects\"\n}\n```\n\nBranch your error handling on the `type` field. The `detail` field contains a human-readable explanation specific to the error.\n\nFor the complete list of error types, status codes, retry strategies, and rate limiting details, see the **Conventions** reference.\n\n### Known Limitations\n\n| Limitation                          | Impact                                                                                   | Workaround                                                                                  |\n| ----------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |\n| No webhooks or event stream         | All state change detection requires polling                                              | Poll with `?updated_after=` filter and checkpoint the sync start time                       |\n| Analysis outputs are not in the API | Objective Summaries, Recommendations, Highlights, and Reports are not accessible in v1   | Export raw transcripts and survey responses for analysis in your own tooling                |\n| Recruitment counts may be stale     | User Interviews order counts refresh only when new sign-ups arrive (cron, not real-time) | Check the `counts.as_of` timestamp on recruitment orders to know when counts last refreshed |\n\n### Next Steps\n\n- Extract transcripts for NLP analysis using the paginated transcripts endpoint\n- Set up a data sync pipeline using sort-and-checkpoint pagination\n- Automate participant acceptance and payment via the participant lifecycle endpoints\n- Build a recruitment order workflow to launch studies programmatically\n- Scope API keys for PII-safe integrations using the `participants:read:pii` discriminator"},{"name":"Setting Up Authentication","description":"Strella's API uses API keys for authentication. Each key is scoped to an organization and carries specific permissions.\n\n### Creating a Key\n\nCreate API keys in Strella under **Settings > Developer > API keys**. Only organization admins see this page. When you create a key, Strella asks you to:\n\n1. Give the key a name.\n2. Select the scopes it needs (see Scopes Reference).\n3. Optionally set an expiration date.\n\nStrella shows the key once, at creation. Copy it immediately and store it in a secret manager. Strella cannot display it again.\n\n### Key Format\n\nAll API keys begin with `ak_`. A key is org-scoped: it can only access data belonging to the organization it was created in.\n\n### Using a Key\n\nPass the key as a bearer token in the `Authorization` header.\n\n```bash\ncurl https://app.strella.io/api/public/v1/projects \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```typescript\nconst response = await fetch('https://app.strella.io/api/public/v1/projects', {\n  headers: {\n    Authorization: 'Bearer ak_your_key_here',\n  },\n})\nconst data = await response.json()\n```\n\n```python\nimport requests\n\nresponse = requests.get(\n    \"https://app.strella.io/api/public/v1/projects\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n)\ndata = response.json()\n```\n\n### Expiration\n\nYou can give a key a fixed expiration date at creation. An expired key returns `401 invalid-api-key`. Strella does not send advance expiration warnings, so set a calendar reminder when you create a key with a fixed lifetime.\n\n### Scopes Reference\n\nScopes control what a key can read or write. Assign the minimum set of scopes needed for your integration (see Least Privilege).\n\n#### Scope Implication\n\nA `:write` scope implies the corresponding `:read` scope on the same resource. A key with `recruitment:write` can call read endpoints for recruitment orders without a separate `recruitment:read` grant.\n\nPII access is not implied by any write scope. `participants:read:pii` must be granted explicitly.\n\n#### Available Scopes\n\n| Scope                       | Grants                                                                 | Notes                                                                       |\n| --------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- |\n| `projects:read`             | Read project list and details                                          |                                                                             |\n| `projects:write`            | Create and update projects                                             | Implies `projects:read`                                                     |\n| `participants:read`         | Read participant list and non-PII fields                               |                                                                             |\n| `participants:read:<field>` | Read one PII field (name, email, a demographic, recordings, responses) | Per-field grant. See Controlling Participant PII Access.                    |\n| `participants:read:pii`     | Read every PII field on participants                                   | Legacy alias granting all PII at once. Deprecated; prefer per-field scopes. |\n| `participants:write`        | Create and update participants                                         | Implies `participants:read`                                                 |\n| `sessions:read`             | Read session list and details                                          |                                                                             |\n| `workspaces:read`           | Read workspace list                                                    |                                                                             |\n| `credits:read`              | Read credit balance and transactions                                   |                                                                             |\n| `recruitment:read`          | Read recruitment orders                                                |                                                                             |\n| `recruitment:write`         | Create and manage recruitment orders                                   | Implies `recruitment:read`                                                  |\n\n#### PII Scopes\n\nParticipant PII is granted field by field. A key reads only the personal fields its scopes name, and a denied field is absent from the response rather than null. No error is raised for a field a key cannot see: the endpoint succeeds and returns the data it is authorized to show. Controlling Participant PII Access covers the full per-field scope list, the `pii_acl` response object, and the deprecation of `pii_level`.\n\nGrant only the PII fields a key needs.\n\n#### Least Privilege\n\nGrant each key only the scopes it needs. A key used to export project data for analysis needs `projects:read` and `sessions:read`. It does not need any write scopes or `participants:read:pii`.\n\nPractical steps:\n\n- Start with the narrowest set of scopes.\n- Add scopes incrementally if an endpoint returns `403 insufficient-scope`.\n- Create separate keys for separate integration concerns: one key for your data pipeline, another for your recruiting automation.\n- Rotate any key that may have been exposed rather than broadening scopes to recover from an integration error.\n\n### Error Handling\n\nAll error responses follow [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) (Problem Details for HTTP APIs). The response body contains `type`, `title`, and `status` fields.\n\n#### Authentication Errors\n\n| `type`               | Status | Cause                                                     | Resolution                                                                                     |\n| -------------------- | ------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |\n| `invalid-api-key`    | 401    | Missing token, malformed key, or expired key              | Check the key is copied correctly and has not expired.                                         |\n| `insufficient-scope` | 403    | The key does not have the scope required by this endpoint | Add the missing scope to the key in Org Settings, or create a new key with the correct scopes. |\n\n#### Example Error Response\n\n```bash\ncurl -i https://app.strella.io/api/public/v1/projects \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n\n# HTTP/1.1 403 Forbidden\n# Content-Type: application/problem+json\n#\n# {\n#   \"type\": \"insufficient-scope\",\n#   \"title\": \"Insufficient Scope\",\n#   \"status\": 403\n# }\n```\n\n```typescript\nconst response = await fetch('https://app.strella.io/api/public/v1/projects', {\n  headers: { Authorization: 'Bearer ak_your_key_here' },\n})\n\nif (!response.ok) {\n  const problem = await response.json()\n  // problem.type === 'insufficient-scope'\n  // problem.status === 403\n  console.error(problem.type, problem.title)\n}\n```\n\n```python\nimport requests\n\nresponse = requests.get(\n    \"https://app.strella.io/api/public/v1/projects\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n)\n\nif not response.ok:\n    problem = response.json()\n    # problem['type'] == 'insufficient-scope'\n    print(problem['type'], problem['title'])\n```\n\n### Security Best Practices\n\n**Store keys in a secret manager.** Do not hardcode keys in source files, environment variable files committed to version control, or client-side code. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler, or equivalent).\n\n**Rotate keys regularly.** Create a replacement key, update your integration to use it, then delete the old key. Aim for rotation at least every 90 days for long-lived keys.\n\n**Rotate immediately on suspected exposure.** If a key appears in a log, error report, or repository, delete it and create a replacement before investigating the exposure.\n\n**Use one key per integration.** With separate keys, you can revoke access for one integration without affecting the others.\n\n**Set expiration dates on temporary keys.** For one-off scripts or short-lived integrations, set an expiration at key creation so a lost key stops working on that date.\n\n**Audit key usage.** Strella logs every API call against the key that made it. If you see unexpected activity, rotate the key and review which systems have access to it."},{"name":"Building Data Sync Pipelines","description":"Extract research data from Strella's API into your own data infrastructure. We will cover incremental sync patterns, transcript extraction, participant metadata, survey and screener responses, credit reconciliation, and recording archival.\n\n### Overview\n\nStrella's API exposes the following resources for data pipeline use:\n\n- **Projects**: research studies with metadata, status, and participant counts\n- **Participants**: study subjects with recruitment status, demographic profiles, and optional PII\n- **Transcripts**: utterance-level interview content across all recordings\n- **Survey responses**: structured in-interview answers\n- **Screener responses**: pre-interview qualification answers\n- **Credits**: balance and transaction history for billing reconciliation\n- **Recordings**: signed video URLs accessible via participant detail\n\n### API Limitations to Plan For\n\nStrella's API does not publish events, so all change detection requires polling. We provide `updated_after` and `updated_before` filters on list endpoints, so an incremental sync fetches only the records that changed in a time range.\n\n### Incremental Sync Pattern\n\nThe core pattern for any resource:\n\n1. Store the timestamp of your last successful sync as a checkpoint.\n2. Fetch records with `?updated_after=<checkpoint>`.\n3. Process all returned records.\n4. Store the current time as the new checkpoint.\n\nThis returns only records modified since your last sync. The cost of each sync grows with the number of changed records, not the total number of records.\n\n#### Complete TypeScript Example\n\n```typescript\nconst API_BASE = 'https://app.strella.io/api/public/v1'\nconst API_KEY = 'ak_your_key_here'\n\ninterface Participant {\n  id: string\n  status: string\n  updated_at: string\n  interview_count: number\n  total_duration_seconds: number\n}\n\ninterface ListResponse {\n  participants: Participant[]\n  meta: {\n    has_more: boolean\n    next_cursor: string | null\n    total: number\n  }\n}\n\nasync function syncParticipants(\n  projectId: string,\n  checkpoint: string | null,\n): Promise<Participant[]> {\n  const collected: Participant[] = []\n  let cursor: string | undefined\n\n  while (true) {\n    const url = new URL(`${API_BASE}/projects/${projectId}/participants`)\n    url.searchParams.set('limit', '100')\n    if (checkpoint) url.searchParams.set('updated_after', checkpoint)\n    if (cursor) url.searchParams.set('cursor', cursor)\n\n    const res = await fetch(url.toString(), {\n      headers: { Authorization: `Bearer ${API_KEY}` },\n    })\n\n    if (!res.ok) {\n      throw new Error(`API error ${res.status}: ${await res.text()}`)\n    }\n\n    const data: ListResponse = await res.json()\n    collected.push(...data.participants)\n\n    if (!data.meta.has_more || data.meta.next_cursor === null) break\n    cursor = data.meta.next_cursor\n  }\n\n  return collected\n}\n\n// Usage\nasync function runSyncLoop(projectId: string) {\n  let checkpoint: string | null = null // load from your store\n\n  while (true) {\n    const syncStart = new Date().toISOString()\n    const participants = await syncParticipants(projectId, checkpoint)\n\n    if (participants.length > 0) {\n      await upsertToDataWarehouse(participants)\n    }\n\n    checkpoint = syncStart // advance checkpoint to sync start time\n    // persist checkpoint to your store\n\n    await new Promise((r) => setTimeout(r, 5 * 60 * 1000)) // poll every 5 minutes\n  }\n}\n```\n\n### Extracting Transcripts\n\nStrella's API returns transcripts per participant. The endpoint returns utterances paginated across all of a participant's recordings in chronological order.\n\n**Endpoint:** `GET /projects/{project_id}/participants/{participant_id}/transcripts`\n\n**Scope required:** `sessions:read`\n\nEach utterance includes:\n\n- `recording_id`: which recording the utterance belongs to\n- `speaker`: the participant's name when the API key can read names (`participants:read:name:first`, `participants:read:name:full` or `participants:read:pii`), otherwise a generic label (`Speaker A`, `Speaker B`)\n- `speaker_role`: `\"moderator\"`, `\"participant\"`, or `null` for legacy interviews\n- `start_ms`, `end_ms`: timing in milliseconds\n- `text`: spoken content\n- `confidence`: transcription confidence score (0-1), or `null`\n\n`speaker_role` is `null` for interviews processed before role classification was introduced. Handle a `null` `speaker_role` in your code rather than filtering those utterances out.\n\n#### Pagination\n\nDefault page size is 500 utterances; maximum is 2000. Use `meta.pagination.next_cursor` to advance pages.\n\nFor full extraction in one request, pass `?full=true`. The API then skips pagination and returns all utterances. Use it for NLP jobs that need the complete transcript at once.\n\nTo extract only participant speech, pass `?speaker_role=participant`.\n\n```bash\n# Paginated (default 500 utterances per page)\ncurl \"https://app.strella.io/api/public/v1/projects/PROJECT_ID/participants/PARTICIPANT_ID/transcripts\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n\n# Full extraction (all utterances, no pagination)\ncurl \"https://app.strella.io/api/public/v1/projects/PROJECT_ID/participants/PARTICIPANT_ID/transcripts?full=true\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n\n# Participant utterances only\ncurl \"https://app.strella.io/api/public/v1/projects/PROJECT_ID/participants/PARTICIPANT_ID/transcripts?speaker_role=participant\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```typescript\n// TypeScript: full transcript extraction\nconst res = await fetch(\n  `${API_BASE}/projects/${projectId}/participants/${participantId}/transcripts?full=true`,\n  { headers: { Authorization: 'Bearer ak_your_key_here' } },\n)\nconst { utterances, meta } = await res.json()\n// meta.recordings contains per-recording utterance counts and durations\n// meta.total_utterance_count is the total across all recordings\n```\n\n```python\nimport requests\n\nresponse = requests.get(\n    f\"https://app.strella.io/api/public/v1/projects/{project_id}/participants/{participant_id}/transcripts\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n    params={\"full\": \"true\"},\n)\ndata = response.json()\nutterances = data[\"utterances\"]\n```\n\nThe response `meta.recordings` array lists each recording with its `recording_id`, `utterance_count`, and `duration_seconds`. Use this to correlate utterances back to specific interviews without re-fetching.\n\n#### Attributing Utterances to Guide Questions\n\nBy default, every response tells you which guide question each utterance was said under, whether paginated, `?full=true` or filtered by `?speaker_role`.\n\n- Each utterance carries `item_id`: the guide item's id, which matches `id` in `GET /projects/{project_id}/questionnaire`. It is `null` for speech before the first question (the introduction), after the last question ended (the wrap-up), or in a recording with no question data.\n- Each `meta.recordings[]` entry carries `has_question_data`. It is `false` for human-moderated and uploaded recordings, whose utterances are always `null`: question attribution covers AI-moderated interviews only.\n- `meta.questions[]` lists each question window as `item_id`, `recording_id`, `question_text` (the question text the AI moderator worked from, in the guide's language; for an interview conducted in another language it will not match the transcript's language), `start_ms`, `end_ms` and `start_estimated`. An utterance belongs to the window where `start_ms <= utterance.start_ms < end_ms`; `end_ms` is `null` when the recording ended mid-question. `start_estimated` is `true` when the start could not be anchored to the moderator line asking the question (about 1% of questions), so utterances right at that boundary may belong to the neighbouring question; drop or review those if you need strict attribution.\n\nA question's follow-ups share its window, so a follow-up's utterances carry the main question's `item_id`. A question the moderator returned to appears once per visit. A moderator utterance often holds both a short reaction to the previous answer and the next question; it is attributed to the question it asks.\n\n#### Separating Main-Question Answers from Follow-Ups\n\nEach utterance also says which exchange of its question it belongs to. `turn_kind` is `main` or `follow_up`, and `follow_up_number` is 1, 2, … on follow-ups (else `null`). A participant's answer takes the label of the question before it, so \"everything answering follow-up 2 of item X\" is the utterances where `item_id` is X and `follow_up_number` is 2.\n\nEach `meta.questions[]` entry says how the labels were made:\n\n- `turn_source: \"agent\"`: the AI moderator recorded which follow-up it asked and when it started, and lines are placed by time, the same way question windows are.\n- `turn_source: \"inferred\"`: interviews recorded before that, and questions without dynamic follow-ups. Each moderator turn (what it says between two participant replies) counts as the next follow-up, so a repeated question counts as one. When the turns do not match the questions the moderator asked, the question returns `turn_kind: null` rather than a guess.\n- `follow_up_count` is how many follow-ups were asked in that window.\n\n`turn_kind` may gain values in future, so treat unknown values as unlabelled rather than failing.\n\nIf you only need the transcript text, pass `?exclude=questions`. The response then leaves out `item_id`, `turn_kind`, `follow_up_number`, `has_question_data` and `meta.questions`, and skips the question lookup. Any other `exclude` value returns `422`.\n\n### Participant Data\n\n**Endpoint:** `GET /projects/{project_id}/participants`\n\n**Scope required:** `participants:read`\n\n#### Fields Available Without PII Scope\n\nEvery response includes:\n\n| Field                     | Description                                                                                     |\n| ------------------------- | ----------------------------------------------------------------------------------------------- |\n| `id`                      | Participant identifier                                                                          |\n| `status`                  | Recruitment status (`unqualified`, `qualified`, `invited`, `in_review`, `rejected`, `accepted`) |\n| `created_at`              | ISO 8601 creation timestamp                                                                     |\n| `updated_at`              | ISO 8601 last modification timestamp                                                            |\n| `interview_count`         | Number of completed interviews                                                                  |\n| `total_duration_seconds`  | Total interview duration across all sessions                                                    |\n| `survey_response_count`   | Number of in-interview survey responses                                                         |\n| `screener_response_count` | Number of screener question re#### PII Fields                                                   |\n\nA key reads each personal field only when its scopes grant it, and the response leaves out every field the key cannot read. Name, email and profile picture each have their own rule:\n\n| Field                 | Description                  | Granted by                                                                 |\n| --------------------- | ---------------------------- | -------------------------------------------------------------------------- |\n| `name`                | Participant display name     | `participants:read:name:first` (first name), `participants:read:name:full` |\n| `email`               | Email address (nullable)     | `participants:read:email`                                                  |\n| `profile_picture_url` | Profile photo URL (nullable) | `participants:read:pii` only                                               |\n\nThe deprecated `participants:read:pii` scope grants every personal field at once. The full scope list is in the Controlling Participant PII Access guide.\n\n#### The `pii_acl` Object\n\nEvery participant response includes a `pii_acl` object that reports what the key was allowed to read, field by field. Read `pii_acl` in your sync code to decide which columns to fill. A field the key cannot read is absent from the response, so store it as unknown, not as empty.\n\nThe response also carries a `pii_level` field (`\"none\"`, `\"first-name-only\"` or `\"full\"`). It is deprecated: one label cannot describe a key that reads email but not name. Do not branch on it.\n\nne rather than checking whether individual fields are present.\n\n### Survey and Screener Responses\n\n#### In-Interview Survey Responses\n\nSurvey responses are structured answers collected during the interview via the questionnaire.\n\n**Endpoint:** `GET /projects/{project_id}/survey-responses`\n\n**Scope required:** `participants:read`\n\nFilter by `participant_ids` (comma-separated) or `questionnaire_item_id` to narrow results.\n\n#### Pre-Interview Screener Responses\n\nScreener responses are qualification answers collected before the interview.\n\n**Endpoint:** `GET /projects/{project_id}/screener-responses`\n\n**Scope required:** `participants:read`\n\nEach screener response includes `participant_id`, `question_text`, `question_type`, and a `response` array. Question types are: `radio`, `checkbox`, `sliderScale`, `singleLineTextbox`, `multiLineTextbox`, `matrix`. These are not the `type` values the screener endpoint accepts: a question written as `slider_scale` is read back here as `sliderScale`.\n\nA `matrix` response holds one entry per cell the participant picked. Its `text` is the cell as `\"Row: Column\"` (for example `\"Slack: Daily\"`), and `row` and `column` each carry the `id` and `text` of that row and column, matching the grid that `GET /projects/{project_id}/screener` returns.\n\n```bash\n# Survey responses for specific participants\ncurl \"https://app.strella.io/api/public/v1/projects/PROJECT_ID/survey-responses?participant_ids=ID1,ID2\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n\n# All screener responses for a project\ncurl \"https://app.strella.io/api/public/v1/projects/PROJECT_ID/screener-responses\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```typescript\n// Survey responses\nconst surveyRes = await fetch(\n  `${API_BASE}/projects/${projectId}/survey-responses?participant_ids=${participantIds.join(',')}`,\n  { headers: { Authorization: 'Bearer ak_your_key_here' } },\n)\n\n// Screener responses\nconst screenerRes = await fetch(\n  `${API_BASE}/projects/${projectId}/screener-responses`,\n  { headers: { Authorization: 'Bearer ak_your_key_here' } },\n)\n```\n\n```python\n# Survey responses\nsurvey_res = requests.get(\n    f\"https://app.strella.io/api/public/v1/projects/{project_id}/survey-responses\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n    params={\"participant_ids\": \",\".join(participant_ids)},\n)\n\n# Screener responses\nscreener_res = requests.get(\n    f\"https://app.strella.io/api/public/v1/projects/{project_id}/screener-responses\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n)\n```\n\n### Credit Reconciliation\n\n#### Current Balance\n\n**Endpoint:** `GET /credits/balance`\n\n**Scope required:** `credits:read`\n\nReturns `balance_cents` (integer, USD cents) and `currency: \"usd\"`.\n\n#### Transaction History\n\n**Endpoint:** `GET /credits/transactions`\n\n**Scope required:** `credits:read`\n\nEach transaction includes `transaction_type`, `status`, `amount_cents`, `balance_after_cents`, and `project_id` (nullable). Transaction types:\n\n- `credit_purchase`\n- `credit_refund`\n- `audience_purchase`\n- `incentive_payment`\n- `manual_adjustment`\n\nFilter by `type` or `status`. Use `created_after` and `created_before` for date-range reconciliation.\n\n```bash\n# Balance\ncurl \"https://app.strella.io/api/public/v1/credits/balance\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n\n# Transactions in a date range\ncurl \"https://app.strella.io/api/public/v1/credits/transactions?created_after=2025-01-01T00:00:00Z&created_before=2025-02-01T00:00:00Z\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```typescript\nconst [balanceRes, txRes] = await Promise.all([\n  fetch(`${API_BASE}/credits/balance`, {\n    headers: { Authorization: 'Bearer ak_your_key_here' },\n  }),\n  fetch(`${API_BASE}/credits/transactions?created_after=2025-01-01T00:00:00Z`, {\n    headers: { Authorization: 'Bearer ak_your_key_here' },\n  }),\n])\nconst { balance_cents, currency } = await balanceRes.json()\nconst { transactions, meta } = await txRes.json()\n```\n\n```python\nbalance = requests.get(\n    \"https://app.strella.io/api/public/v1/credits/balance\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n).json()\n\ntransactions = requests.get(\n    \"https://app.strella.io/api/public/v1/credits/transactions\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n    params={\"created_after\": \"2025-01-01T00:00:00Z\"},\n).json()\n```\n\n### Recording URLs\n\nThe participant detail endpoint returns recording URLs.\n\n**Endpoint:** `GET /projects/{project_id}/participants/{participant_id}`\n\n**Scope required:** `participants:read` + `sessions:read`\n\nThe response includes a `recordings` array. Each recording returns the interview's\nlayouts separately, so your pipeline picks the layout it needs instead of taking\nwhichever one the platform composited.\n\nThree fields are viewing layouts. Archive and play these:\n\n- `split_screen_url`: the participant's camera and their shared screen\n  composited together. Null when the participant did not share their screen, and\n  null when only an audio-only track remains, as on an anonymized legacy\n  interview, where `video_url` carries that audio.\n- `screen_share_url`: the shared screen with the camera picture-in-picture. Null\n  when the participant did not share their screen.\n- `participant_camera_url`: the participant's camera. Null when video was\n  disabled.\n\nTwo fields are not viewing layouts:\n\n- `video_url`: the recording used for transcription and analysis, whichever\n  layout suits processing best. It can be audio-only on an anonymized interview,\n  so do not feed it to a video player (nullable).\n- `recording_url`: DEPRECATED, read `split_screen_url` and `video_url` instead.\n  It returns the stored split-screen value as-is, falling back to `video_url`\n  only when nothing split-screen is stored. It skips the check the three viewing\n  layouts apply, so on an anonymized or legacy interview it can return an\n  audio-only track while `split_screen_url` returns null. A non-null\n  `recording_url` is not evidence that you received a video (nullable).\n\nOn projects using the private recordings bucket, these are absolute `https://<app-host>/api/media/...` URLs that require an authenticated app session to play: an API key can read the recording metadata but cannot fetch the video bytes (playback happens in the Strella app, or for a user-session caller). Public-bucket projects return direct, stable URLs.\n\nThere is no bulk recording list endpoint. Only the participant detail endpoint returns recordings, one participant per request.\n\n```bash\ncurl \"https://app.strella.io/api/public/v1/projects/PROJECT_ID/participants/PARTICIPANT_ID\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```typescript\nconst res = await fetch(\n  `${API_BASE}/projects/${projectId}/participants/${participantId}`,\n  { headers: { Authorization: 'Bearer ak_your_key_here' } },\n)\nconst participant = await res.json()\n\nfor (const recording of participant.recordings ?? []) {\n  if (recording.split_screen_url) {\n    await archiveToStorage(recording.split_screen_url)\n  }\n}\n```\n\n```python\nres = requests.get(\n    f\"https://app.strella.io/api/public/v1/projects/{project_id}/participants/{participant_id}\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n)\nparticipant = res.json()\n\nfor recording in participant.get(\"recordings\", []):\n    if recording.get(\"split_screen_url\"):\n        archive_to_storage(recording[\"split_screen_url\"])\n```\n\n### Polling\n\nFor polling intervals, rate limit handling, idempotent processing, and notification delivery patterns, see the **Polling for Changes** guide."},{"name":"Polling for Changes","description":"Detecting any state change requires polling (see Known Limitations in the Quickstart). Build reliable polling loops that respect the rate limit with the patterns below.\n\n### Prerequisites\n\n- A working API key (`ak_your_key_here`) with at minimum `participants:read` and `projects:read` scopes, plus `sessions:read` to fetch transcripts.\n- Familiarity with cursor-based pagination from the Quickstart Guide.\n\n### Overview\n\nStrella's API has no push mechanism, so our integration asks it repeatedly which records changed since the last check. We do this with the `updated_after` filter and a stored checkpoint timestamp.\n\n### Checkpoint Pattern\n\nA checkpoint is the timestamp of your last successful poll. On each run we:\n\n1. Record the current time as `syncStart`.\n2. Query with `?updated_after=<checkpoint>` to get all records modified since the last poll.\n3. Process all returned records.\n4. Store `syncStart` as the new checkpoint.\n\nThis returns only records that changed since your last poll.\n\n```bash\n# Fetch participants updated since checkpoint\ncurl \"https://app.strella.io/api/public/v1/projects/{project_id}/participants?updated_after=2025-01-15T00:00:00Z&limit=100\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```typescript\nconst BASE = 'https://app.strella.io/api/public/v1'\nconst AUTH = { Authorization: 'Bearer ak_your_key_here' }\n\nasync function fetchUpdatedParticipants(\n  projectId: string,\n  checkpoint: string | null,\n): Promise<Participant[]> {\n  const participants: Participant[] = []\n  let cursor: string | undefined\n\n  do {\n    const url = new URL(`${BASE}/projects/${projectId}/participants`)\n    url.searchParams.set('limit', '100')\n    if (checkpoint) url.searchParams.set('updated_after', checkpoint)\n    if (cursor) url.searchParams.set('cursor', cursor)\n\n    const response = await fetch(url.toString(), { headers: AUTH })\n    if (!response.ok) throw new Error(`HTTP ${response.status}`)\n\n    const data = await response.json()\n    participants.push(...data.participants)\n\n    cursor = data.meta.has_more ? data.meta.next_cursor : undefined\n  } while (cursor)\n\n  return participants\n}\n```\n\n```python\nimport requests\n\nBASE = \"https://app.strella.io/api/public/v1\"\nHEADERS = {\"Authorization\": \"Bearer ak_your_key_here\"}\n\ndef fetch_updated_participants(project_id: str, checkpoint: str | None):\n    participants = []\n    cursor = None\n\n    while True:\n        params = {\"limit\": 100}\n        if checkpoint:\n            params[\"updated_after\"] = checkpoint\n        if cursor:\n            params[\"cursor\"] = cursor\n\n        response = requests.get(\n            f\"{BASE}/projects/{project_id}/participants\",\n            headers=HEADERS,\n            params=params,\n        )\n        response.raise_for_status()\n        data = response.json()\n        participants.extend(data[\"participants\"])\n\n        if not data[\"meta\"][\"has_more\"]:\n            break\n        cursor = data[\"meta\"][\"next_cursor\"]\n\n    return participants\n```\n\n### Polling Intervals\n\nPoll no faster than your use case demands. For most CRM sync scenarios, a 60-second interval is sufficient. For near-real-time review queues, 15 seconds is a reasonable floor.\n\n### Rate Limit Handling\n\nRate limit headers are present on every response:\n\n- `X-RateLimit-Limit`: maximum requests per window.\n- `X-RateLimit-Remaining`: requests left in the current window.\n- `X-RateLimit-Reset`: Unix timestamp when the window resets.\n- `Retry-After`: on a denied request, the seconds to wait before retrying.\n\nA poller running on a correct credential sees the organization rate limit and nothing else, so `X-RateLimit-Reset` is enough to pace it. The one case where the budget headers stop being useful is an `ip-temporarily-blocked` response, which follows repeated rejected credentials rather than request volume. There `X-RateLimit-Limit` and `X-RateLimit-Remaining` both read `0`, while `Retry-After` and `X-RateLimit-Reset` both carry the real wait. See the Handling Errors guide for how to tell which defence returned a given 429.\n\n#### Proactive Budget Check\n\nBefore starting a batch of requests, check whether sufficient budget remains. If `X-RateLimit-Remaining` is low, wait until `X-RateLimit-Reset`. This prevents a run of 429 responses partway through the batch.\n\n```typescript\nasync function ensureRateLimitBudget(minRequired = 100): Promise<void> {\n  const probe = await fetch(`${BASE}/projects?limit=1`, { headers: AUTH })\n\n  if (probe.status === 429) {\n    await waitForReset(probe.headers)\n    return ensureRateLimitBudget(minRequired)\n  }\n\n  const remaining = parseInt(probe.headers.get('X-RateLimit-Remaining') ?? '0')\n  if (remaining < minRequired) {\n    await waitForReset(probe.headers)\n  }\n}\n\nfunction waitForReset(headers: Headers): Promise<void> {\n  const resetEpoch = parseInt(headers.get('X-RateLimit-Reset') ?? '0', 10)\n  const waitMs =\n    resetEpoch > 0 ? Math.max(0, resetEpoch * 1000 - Date.now()) + 1000 : 60_000\n  return new Promise((resolve) => setTimeout(resolve, waitMs))\n}\n\n// Usage: call before each sync batch\nasync function runSyncBatch(projectId: string, checkpoint: string | null) {\n  await ensureRateLimitBudget(100)\n  return fetchUpdatedParticipants(projectId, checkpoint)\n}\n```\n\n```python\nimport time\nimport requests\n\ndef ensure_rate_limit_budget(min_required: int = 100) -> None:\n    probe = requests.get(f\"{BASE}/projects\", headers=HEADERS, params={\"limit\": 1})\n\n    if probe.status_code == 429:\n        wait_for_reset(probe.headers)\n        return ensure_rate_limit_budget(min_required)\n\n    remaining = int(probe.headers.get(\"X-RateLimit-Remaining\", 0))\n    if remaining < min_required:\n        wait_for_reset(probe.headers)\n\n\ndef wait_for_reset(headers) -> None:\n    reset_epoch = int(headers.get(\"X-RateLimit-Reset\", 0))\n    wait_seconds = max(0, reset_epoch - time.time()) + 1 if reset_epoch else 60\n    time.sleep(wait_seconds)\n```\n\n#### Reactive 429 Handling\n\nWhen the API returns `429`, stop all requests until after `X-RateLimit-Reset`. Apply exponential backoff for `500` and `503` responses.\n\n```bash\n# Inspect rate limit headers\ncurl -I \"https://app.strella.io/api/public/v1/projects\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n# X-RateLimit-Limit: 600\n# X-RateLimit-Remaining: 598\n# X-RateLimit-Reset: 1748304000\n```\n\n```typescript\ninterface RateLimitState {\n  retryAfter: number | null\n}\n\nconst rateLimitState: RateLimitState = { retryAfter: null }\n\nasync function rateLimitAwareFetch(url: string): Promise<Response> {\n  if (rateLimitState.retryAfter && Date.now() < rateLimitState.retryAfter) {\n    const wait = rateLimitState.retryAfter - Date.now()\n    await new Promise((resolve) => setTimeout(resolve, wait))\n  }\n\n  const response = await fetch(url, { headers: AUTH })\n\n  if (response.status === 429) {\n    const reset = response.headers.get('X-RateLimit-Reset')\n    // Add 1 second of buffer after the reset window\n    rateLimitState.retryAfter = reset\n      ? parseInt(reset) * 1000 + 1000\n      : Date.now() + 60_000\n    throw new Error('Rate limit exceeded')\n  }\n\n  rateLimitState.retryAfter = null\n  return response\n}\n\nasync function withExponentialBackoff<T>(\n  fn: () => Promise<T>,\n  maxAttempts = 5,\n): Promise<T> {\n  let attempt = 0\n  while (true) {\n    try {\n      return await fn()\n    } catch (err) {\n      attempt++\n      if (attempt >= maxAttempts) throw err\n      const delay = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 30_000)\n      await new Promise((resolve) => setTimeout(resolve, delay))\n    }\n  }\n}\n```\n\n```python\nimport time\nimport random\nimport requests\n\nBASE = \"https://app.strella.io/api/public/v1\"\nHEADERS = {\"Authorization\": \"Bearer ak_your_key_here\"}\n\ndef rate_limit_aware_get(url: str, params: dict = None, max_attempts: int = 5):\n    attempt = 0\n    while True:\n        response = requests.get(url, headers=HEADERS, params=params)\n\n        if response.status_code == 429:\n            reset = response.headers.get(\"X-RateLimit-Reset\")\n            wait = (int(reset) - time.time() + 1) if reset else 60\n            time.sleep(max(wait, 0))\n            attempt += 1\n            if attempt >= max_attempts:\n                response.raise_for_status()\n            continue\n\n        if response.status_code in (500, 503):\n            attempt += 1\n            if attempt >= max_attempts:\n                response.raise_for_status()\n            delay = min(1 * 2**attempt + random.random(), 30)\n            time.sleep(delay)\n            continue\n\n        response.raise_for_status()\n        return response\n```\n\n### Participant Status Monitoring\n\nParticipants recruited via Strella enter `in_review` status before anyone accepts or rejects them. We poll for `in_review` participants to trigger downstream acceptance workflows.\n\nUse `?status=in_review` combined with `sort=updated_at:desc` to page through only the participants who need review. Read the `in_review_count` field on the project list endpoint first: if it is zero, we can skip the participants fetch entirely.\n\n```bash\n# Pre-flight: find projects with participants needing review\ncurl \"https://app.strella.io/api/public/v1/projects?min_in_review_count=1\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n\n# Fetch in_review participants for a specific project\ncurl \"https://app.strella.io/api/public/v1/projects/{project_id}/participants?status=in_review&sort=updated_at:desc\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```typescript\nasync function getProjectsNeedingReview(): Promise<string[]> {\n  const url = new URL(`${BASE}/projects`)\n  url.searchParams.set('min_in_review_count', '1')\n\n  const response = await fetch(url.toString(), { headers: AUTH })\n  if (!response.ok) throw new Error(`HTTP ${response.status}`)\n\n  const data = await response.json()\n  return data.projects.map((p: { id: string }) => p.id)\n}\n\nasync function getInReviewParticipants(\n  projectId: string,\n): Promise<Participant[]> {\n  const participants: Participant[] = []\n  let cursor: string | undefined\n\n  do {\n    const url = new URL(`${BASE}/projects/${projectId}/participants`)\n    url.searchParams.set('status', 'in_review')\n    url.searchParams.set('sort', 'updated_at:desc')\n    url.searchParams.set('limit', '100')\n    if (cursor) url.searchParams.set('cursor', cursor)\n\n    const response = await fetch(url.toString(), { headers: AUTH })\n    if (!response.ok) throw new Error(`HTTP ${response.status}`)\n\n    const data = await response.json()\n    participants.push(...data.participants)\n    cursor = data.meta.has_more ? data.meta.next_cursor : undefined\n  } while (cursor)\n\n  return participants\n}\n```\n\n```python\ndef get_projects_needing_review() -> list[str]:\n    response = requests.get(\n        f\"{BASE}/projects\",\n        headers=HEADERS,\n        params={\"min_in_review_count\": 1},\n    )\n    response.raise_for_status()\n    return [p[\"id\"] for p in response.json()[\"projects\"]]\n\ndef get_in_review_participants(project_id: str) -> list[dict]:\n    participants = []\n    cursor = None\n\n    while True:\n        params = {\"status\": \"in_review\", \"sort\": \"updated_at:desc\", \"limit\": 100}\n        if cursor:\n            params[\"cursor\"] = cursor\n\n        response = requests.get(\n            f\"{BASE}/projects/{project_id}/participants\",\n            headers=HEADERS,\n            params=params,\n        )\n        response.raise_for_status()\n        data = response.json()\n        participants.extend(data[\"participants\"])\n\n        if not data[\"meta\"][\"has_more\"]:\n            break\n        cursor = data[\"meta\"][\"next_cursor\"]\n\n    return participants\n```\n\n### Interview Completion Detection\n\nA participant moves to `in_review` when their interview ends, and to `accepted` once a Researcher accepts and pays them. To sync accepted interviews, poll for `accepted` participants and check that `interview_count` is greater than zero.\n\nTo confirm transcript availability, query the participant's transcripts endpoint. The participant detail endpoint never includes transcripts.\n\n```bash\n# Poll for newly accepted participants\ncurl \"https://app.strella.io/api/public/v1/projects/{project_id}/participants?status=accepted&sort=updated_at:desc\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n\n# Fetch transcripts for a specific participant\ncurl \"https://app.strella.io/api/public/v1/projects/{project_id}/participants/{participant_id}/transcripts\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```typescript\nasync function getCompletedParticipants(\n  projectId: string,\n  checkpoint: string | null,\n): Promise<Participant[]> {\n  const participants: Participant[] = []\n  let cursor: string | undefined\n\n  do {\n    const url = new URL(`${BASE}/projects/${projectId}/participants`)\n    url.searchParams.set('status', 'accepted')\n    url.searchParams.set('sort', 'updated_at:desc')\n    url.searchParams.set('limit', '100')\n    if (cursor) url.searchParams.set('cursor', cursor)\n\n    const response = await fetch(url.toString(), { headers: AUTH })\n    if (!response.ok) throw new Error(`HTTP ${response.status}`)\n\n    const data = await response.json()\n\n    for (const participant of data.participants) {\n      if (checkpoint && participant.updated_at <= checkpoint) {\n        return participants\n      }\n      if (participant.interview_count > 0) {\n        participants.push(participant)\n      }\n    }\n\n    cursor = data.meta.has_more ? data.meta.next_cursor : undefined\n  } while (cursor)\n\n  return participants\n}\n```\n\n```python\ndef get_completed_participants(project_id: str, checkpoint: str | None) -> list[dict]:\n    participants = []\n    cursor = None\n\n    while True:\n        params = {\"status\": \"accepted\", \"sort\": \"updated_at:desc\", \"limit\": 100}\n        if cursor:\n            params[\"cursor\"] = cursor\n\n        response = requests.get(\n            f\"{BASE}/projects/{project_id}/participants\",\n            headers=HEADERS,\n            params=params,\n        )\n        response.raise_for_status()\n        data = response.json()\n\n        for participant in data[\"participants\"]:\n            if checkpoint and participant[\"updated_at\"] <= checkpoint:\n                return participants\n            if participant[\"interview_count\"] > 0:\n                participants.append(participant)\n\n        if not data[\"meta\"][\"has_more\"]:\n            break\n        cursor = data[\"meta\"][\"next_cursor\"]\n\n    return participants\n```\n\n### Idempotent Processing\n\nIdempotent means your code produces the same result whether it runs once or multiple times for the same input. This matters because polling can deliver duplicates: a participant whose `updated_at` falls exactly on the checkpoint boundary may appear in consecutive runs.\n\nUse the participant `id` as a deduplication key. Before acting on a record, check whether you have already processed that ID.\n\n```typescript\n// Bad: sends a notification every time the participant appears in poll results\nasync function processParticipant(participant: Participant) {\n  await sendNotification(participant)\n}\n\n// Good: skips participants we have already processed\nasync function processParticipantIdempotent(\n  participant: Participant,\n  processedIds: Set<string>,\n) {\n  if (processedIds.has(participant.id)) return\n  await sendNotification(participant)\n  processedIds.add(participant.id)\n}\n```\n\nFor durable storage, persist processed IDs in a database or cache keyed by `participant_id`. Set an expiration time of at least two polling windows to allow for clock skew.\n\n### Notification Delivery\n\nCombining the checkpoint pattern with idempotent processing gives us a notification delivery loop:\n\n1. Pre-flight: fetch projects with `min_in_review_count=1`.\n2. For each project, poll `?status=in_review&updated_after=<checkpoint>`.\n3. For each participant, check whether we have already sent a notification for their ID.\n4. If not, send the notification and record the participant ID as notified.\n5. Advance the checkpoint.\n\nThe complete example below shows the full loop with rate limit handling and idempotency.\n\n```typescript\n// Persistent state: store this in your database between runs\ninterface NotificationState {\n  checkpoint: string | null // timestamp of last poll\n  notifiedIds: Set<string> // participant IDs already notified\n}\n\nasync function runNotificationLoop(\n  projectId: string,\n  state: NotificationState,\n  notify: (participant: Participant) => Promise<void>,\n): Promise<NotificationState> {\n  const syncStart = new Date().toISOString()\n  const newlyNotified = new Set<string>()\n  let cursor: string | undefined\n\n  do {\n    const url = new URL(`${BASE}/projects/${projectId}/participants`)\n    url.searchParams.set('status', 'in_review')\n    url.searchParams.set('limit', '100')\n    if (state.checkpoint)\n      url.searchParams.set('updated_after', state.checkpoint)\n    if (cursor) url.searchParams.set('cursor', cursor)\n\n    const response = await rateLimitAwareFetch(url.toString())\n    const data = await response.json()\n\n    for (const participant of data.participants) {\n      // Skip duplicates within this run and across runs\n      if (state.notifiedIds.has(participant.id)) continue\n\n      await notify(participant)\n      newlyNotified.add(participant.id)\n    }\n\n    cursor = data.meta.has_more ? data.meta.next_cursor : undefined\n  } while (cursor)\n\n  return {\n    checkpoint: syncStart,\n    notifiedIds: new Set([...state.notifiedIds, ...newlyNotified]),\n  }\n}\n```\n\n```python\nfrom datetime import datetime, timezone\n\ndef run_notification_loop(\n    project_id: str,\n    checkpoint: str | None,\n    notified_ids: set[str],\n    notify,\n) -> tuple[str, set[str]]:\n    \"\"\"\n    Returns (new_checkpoint, updated_notified_ids).\n    `notify` is a callable that accepts a participant dict.\n    \"\"\"\n    sync_start = datetime.now(timezone.utc).isoformat()\n    newly_notified: set[str] = set()\n    cursor = None\n\n    while True:\n        params = {\"status\": \"in_review\", \"limit\": 100}\n        if checkpoint:\n            params[\"updated_after\"] = checkpoint\n        if cursor:\n            params[\"cursor\"] = cursor\n\n        response = rate_limit_aware_get(\n            f\"{BASE}/projects/{project_id}/participants\",\n            params=params,\n        )\n        data = response.json()\n\n        for participant in data[\"participants\"]:\n            if participant[\"id\"] in notified_ids:\n                continue\n\n            notify(participant)\n            newly_notified.add(participant[\"id\"])\n\n        if not data[\"meta\"][\"has_more\"]:\n            break\n        cursor = data[\"meta\"][\"next_cursor\"]\n\n    return sync_start, notified_ids | newly_notified\n```\n\n### Putting It Together\n\nA complete polling scheduler combines the patterns above:\n\n```typescript\nasync function startPollingScheduler(projectId: string, intervalMs = 60_000) {\n  let state: NotificationState = {\n    checkpoint: null,\n    notifiedIds: new Set(),\n  }\n\n  async function poll() {\n    try {\n      const projects = await getProjectsNeedingReview()\n      if (!projects.includes(projectId)) return\n\n      state = await runNotificationLoop(\n        projectId,\n        state,\n        async (participant) => {\n          console.log(`New in_review participant: ${participant.id}`)\n          // Replace with your notification logic (Slack, email, webhook, etc.)\n        },\n      )\n    } catch (err) {\n      console.error('Poll error', err)\n      // Errors are logged but do not stop the scheduler\n    }\n  }\n\n  // Run immediately on start, then on interval\n  await poll()\n  setInterval(poll, intervalMs)\n}\n```\n\n```python\nimport threading\n\ndef start_polling_scheduler(project_id: str, interval_seconds: int = 60):\n    state = {\"checkpoint\": None, \"notified_ids\": set()}\n\n    def poll():\n        while True:\n            try:\n                projects = get_projects_needing_review()\n                if project_id in projects:\n                    def notify(participant):\n                        print(f\"New in_review participant: {participant['id']}\")\n                        # Replace with your notification logic\n\n                    new_checkpoint, new_ids = run_notification_loop(\n                        project_id,\n                        state[\"checkpoint\"],\n                        state[\"notified_ids\"],\n                        notify,\n                    )\n                    state[\"checkpoint\"] = new_checkpoint\n                    state[\"notified_ids\"] = new_ids\n            except Exception as e:\n                print(f\"Poll error: {e}\")\n\n            time.sleep(interval_seconds)\n\n    thread = threading.Thread(target=poll, daemon=True)\n    thread.start()\n```\n\n### Summary\n\n| Pattern               | Key point                                                                              |\n| --------------------- | -------------------------------------------------------------------------------------- |\n| Checkpoint            | Store the poll's start time (`syncStart`); pass it as `updated_after` on the next poll |\n| Rate limit handling   | Respect `X-RateLimit-Reset` on `429`; exponential backoff on `500`/`503`               |\n| Participant status    | Use `?status=in_review` + pre-flight `min_in_review_count=1` to reduce wasted requests |\n| Interview completion  | Poll for `status=accepted` + `interview_count > 0`                                     |\n| Idempotency           | Use participant `id` as the dedup key; persist across runs                             |\n| Notification delivery | Checkpoint + idempotency key together prevent duplicate notifications                  |"},{"name":"Understanding the Data Model","description":"Strella's API organizes its resources into a hierarchy, and each resource has defined lifecycle states and response conventions. Read this guide before building against individual endpoints, so you know all three before your first request.\n\n### Resource Graph\n\nResources nest inside each other. To fetch a participant, you specify its project in the URL: `/projects/{id}/participants/{id}`. The tree below shows how resources relate.\n\n```\nOrganization\n├── Credits (org-level balance and transaction history)\n├── Workspaces (optional grouping for projects)\n└── Projects\n    ├── Questionnaire\n    │   └── Questionnaire Items\n    ├── Screener\n    ├── Quotas\n    ├── Recruitment Orders\n    └── Participants\n        ├── Recordings (embedded in participant detail)\n        ├── Transcripts (dedicated paginated endpoint)\n        ├── Survey Responses (list endpoint + embedded in detail)\n        └── Screener Responses (list endpoint + embedded in detail)\n```\n\nNotes:\n\n- **Projects** belong to the organization directly. To assign a project to a workspace, set `workspace_id` on create or with `PATCH /projects/{id}`. Workspaces are not required.\n- **Credits** belong to the organization, not to individual projects. Buy credits in Strella's application. The API provides read-only access to the balance and transaction history. Transaction records attribute credit spend to projects.\n- **Workspaces** are an optional organizational grouping. You can create, rename, and delete workspaces via the API (`workspaces:write` scope). Deleting a workspace unassigns its projects first.\n- **Recordings** are not a standalone list endpoint. They appear as the `recordings` array on participant detail. Exclude them with `?exclude=recordings`.\n- **Bookings** are not a standalone endpoint. On a human-moderated project they appear as the `booked_interviews` array on every participant read: each booked interview slot with its `start_at` (UTC), `status` and, while `booked`, the moderator's `join_url`. Filter the list to participants with an upcoming booking with `?booking=upcoming`. Bookings are read-only.\n- **Transcripts** are available via a dedicated paginated endpoint at `GET /projects/{id}/participants/{id}/transcripts`.\n\n### Resource Operations\n\nThe table below shows which HTTP operations are available for each resource. A dash means the operation does not exist in v1.\n\n| Resource            | List | Detail | Create | Update    | Delete |\n| ------------------- | ---- | ------ | ------ | --------- | ------ |\n| Projects            | GET  | GET    | POST   | PATCH     | DELETE |\n| Participants        | GET  | GET    | -      | PATCH     | -      |\n| Workspaces          | GET  | GET    | POST   | PATCH     | DELETE |\n| Questionnaire       | -    | GET    | -      | -         | -      |\n| Questionnaire Items | -    | GET    | POST   | PUT/PATCH | DELETE |\n| Screener            | -    | GET    | -      | PUT       | DELETE |\n| Quotas              | GET  | GET    | POST   | PATCH     | DELETE |\n| Screener Responses  | GET  | -      | -      | -         | -      |\n| Survey Responses    | GET  | -      | -      | -         | -      |\n| Transcripts         | GET  | -      | -      | -         | -      |\n| Recruitment Orders  | GET  | GET    | POST   | PATCH     | DELETE |\n| Credits             | GET  | -      | -      | -         | -      |\n\nThe API cannot create participants. Participants enter Strella through recruitment. The Credits list operation covers both `/credits/balance` (current balance) and `/credits/transactions` (ledger).\n\n### Participant Lifecycle\n\nParticipants move through six `status` values, from the screener through the interview to review.\n\n| Status        | Meaning                                                              |\n| ------------- | -------------------------------------------------------------------- |\n| `unqualified` | Started or did not complete the screener                             |\n| `qualified`   | Passed the screener, awaiting an invite to the interview             |\n| `invited`     | Approved to join the interview session                               |\n| `in_review`   | Interview complete, awaiting researcher review                       |\n| `accepted`    | Researcher approved the participant; incentive payment has been made |\n| `rejected`    | Failed the screener, failed quality review, or was a no-show         |\n\nThe transition from `in_review` is the only one your integration controls directly. Send `PATCH /projects/{project_id}/participants/{participant_id}` with `{ \"status\": \"accepted\" }` or `{ \"status\": \"rejected\" }` to move the participant out of review. All earlier transitions (screener completion, invitation) happen automatically as participants interact with the study.\n\nAccepted participants carry a `payment` object on detail. Rejected participants carry `rejection_reason` and `rejected_by`, which records whether a human user or an API key performed the rejection.\n\n### Recruitment Order Lifecycle\n\nRecruitment orders move through five `status` values.\n\n| Status       | Meaning                                                                |\n| ------------ | ---------------------------------------------------------------------- |\n| `pending`    | Draft order, not yet submitted. No credit charge, no panel activity.   |\n| `recruiting` | Active. The panel is sourcing participants. Credits are being charged. |\n| `paused`     | Sourcing stopped temporarily, by you or by Strella.                    |\n| `filled`     | Target participant count reached.                                      |\n| `closed`     | Order closed. Unused credits are refunded (User Interviews only).      |\n\nTransitions work as follows:\n\n- **`pending` to `recruiting`**: Send `PATCH /projects/{project_id}/recruitment/{order_id}` with `{ \"status\": \"recruiting\" }`. This submits the order, charges credits, and calls the panel. Creating the order with `POST` always produces a `pending` draft; the panel is never contacted at create time.\n- **`recruiting` to `paused`**: Send `PATCH` with `{ \"status\": \"paused\" }`. The panel stops sourcing.\n- **`paused` to `recruiting`**: Supported for User Interviews orders only. Respondent orders treat `paused` as terminal; they cannot be resumed.\n- **Strella's own pause**: Strella moves a `recruiting` order on either panel to `paused` while its project is full or closed, and back to `recruiting` once the project has room.\n- **Any status to `closed`**: Send `PATCH` with `{ \"status\": \"closed\" }`. For User Interviews orders, unused credits are auto-refunded. For Respondent orders, `closed` is not supported in v1.\n\nThe API accepts metadata updates (participant count, incentive, targeting) only on `pending` orders.\n\n### Response Conventions\n\n**Named-array envelopes.** List responses wrap results in a named array that matches the resource: `{ \"projects\": [...], \"meta\": {...} }`, `{ \"participants\": [...], \"meta\": {...} }`. There is no generic `data` wrapper.\n\n**Cursor-based pagination.** Every list endpoint paginates via cursor. The `meta` object contains `has_more` (boolean), `next_cursor` (opaque string, absent when `has_more` is false), and `total` (total matching count). Default page size is 50 (max 100). Transcripts default to 500 utterances per page (max 2000).\n\n**Detail mode.** Add `?detail=full` to any list request to receive the complete detail object for each item in a single response, avoiding a second GET per record. Detail mode reduces the participant list page size cap to 25.\n\n**Excluding participant sections.** The participant detail endpoint combines several sections in one response. Omit large sections with `?exclude=recordings,screener_responses,survey_responses,summary,profile,booked_interviews`. The `quota` field (primary assignment) and `quota_ids` (every quota the participant belongs to, primary first, several on a project using quota groups) are always included and are not excludable, as is `variant_assignments` (which variant the participant was assigned in each variant block).\n\n**PII access.** Every participant response includes a `pii_acl` object that reports, field by field, what personal information your key was allowed to read. A field the key cannot read is absent from the response. Each personal field needs its own scope, such as `participants:read:email`, and no other scope implies one. The response also carries a deprecated `pii_level` field (`none`, `first-name-only` or `full`); read `pii_acl` instead. The Controlling Participant PII Access guide lists every scope.\n\n**Incremental sync.** All list endpoints accept `?updated_after=<ISO8601>` and `?updated_before=<ISO8601>` to filter by modification date. Combine with `?sort=updated_at:desc` and checkpoint the latest `updated_at` value for efficient delta sync. See the Building Data Sync Pipelines guide for the complete pattern.\n\n**Sort order.** All list endpoints support `?sort=created_at:asc`, `?sort=created_at:desc`, `?sort=updated_at:asc`, and `?sort=updated_at:desc`. The default is `created_at:desc`.\n\nFor full details on pagination, filtering, sorting, and error shapes, see the Conventions guide.\n\n### Endpoint Lookup\n\n| Question                              | Endpoint                                                                             | Scope                   |\n| ------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------- |\n| List or search projects               | `GET /api/public/v1/projects` or `GET /api/public/v1/projects/search?q=`             | `projects:read`         |\n| Get a project with all settings       | `GET /api/public/v1/projects/{project_id}`                                           | `projects:read`         |\n| Create a project                      | `POST /api/public/v1/projects`                                                       | `projects:write`        |\n| List participants with status filter  | `GET /api/public/v1/projects/{project_id}/participants?status=accepted,in_review`    | `participants:read`     |\n| Get participant detail with PII       | `GET /api/public/v1/projects/{project_id}/participants/{participant_id}`             | `participants:read:pii` |\n| Get paginated transcripts             | `GET /api/public/v1/projects/{project_id}/participants/{participant_id}/transcripts` | `sessions:read`         |\n| List survey responses for a project   | `GET /api/public/v1/projects/{project_id}/survey-responses`                          | `participants:read`     |\n| List screener responses for a project | `GET /api/public/v1/projects/{project_id}/screener-responses`                        | `participants:read`     |\n| Check credit balance                  | `GET /api/public/v1/credits/balance`                                                 | `credits:read`          |\n| List recruitment orders for a project | `GET /api/public/v1/projects/{project_id}/recruitment`                               | `recruitment:read`      |\n| Create a recruitment order (draft)    | `POST /api/public/v1/projects/{project_id}/recruitment/{panel}`                      | `recruitment:write`     |\n| Accept or reject a participant        | `PATCH /api/public/v1/projects/{project_id}/participants/{participant_id}`           | `participants:write`    |"},{"name":"Handling Errors","description":"### Error Format\n\nAll errors follow [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) and include `type`, `title`, `status`, `detail`, and `instance` fields. Use the `type` field for programmatic error handling, not `title` or `detail`, which are for human readers and may change.\n\n### Error Types by Status Code\n\n#### 400 Bad Request\n\n| Error Type             | Title                | When You See It                                                       | What To Do                                                                                                   |\n| ---------------------- | -------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |\n| `malformed-body`       | Malformed Body       | The request body is not valid JSON, is empty, or is missing entirely. | Check that your serialization produces valid JSON and that you are sending a body for POST and PUT requests. |\n| `missing-organization` | Missing Organization | The API key exists but is not associated with an organization.        | Contact support. This indicates an account configuration issue.                                              |\n\n#### 401 Unauthorized\n\n| Error Type          | Title             | When You See It                                                                                   | What To Do                                                                                                                      |\n| ------------------- | ----------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |\n| `invalid-api-key`   | Invalid API Key   | The API key is missing, expired, malformed, or is a user-scoped key instead of an org-scoped key. | Verify the `Authorization: Bearer <key>` header is present and correct. Org-scoped keys begin with `ak_`. Replace expired keys. |\n| `missing-mcp-token` | Missing MCP Token | The MCP authentication token was not provided. (MCP server only.)                                 | Include the MCP token in the request. This error does not occur on the REST API.                                                |\n| `invalid-mcp-token` | Invalid MCP Token | The MCP authentication token is invalid or expired. (MCP server only.)                            | Refresh your MCP token and retry. This error does not occur on the REST API.                                                    |\n\n#### 402 Payment Required\n\n| Error Type             | Title                | When You See It                                                              | What To Do                                                         |\n| ---------------------- | -------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------ |\n| `insufficient-credits` | Insufficient Credits | The organization does not have enough credits to submit a recruitment order. | Add credits to the organization account before retrying.           |\n| `payment-required`     | Payment Required     | Payment is required before this operation can proceed.                       | Complete the required payment step in the application, then retry. |\n\n#### 403 Forbidden\n\n| Error Type                   | Title                      | When You See It                                                                  | What To Do                                                                                            |\n| ---------------------------- | -------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |\n| `insufficient-scope`         | Insufficient Scope         | The API key does not have the scope required by this endpoint.                   | Use an API key that includes the required scope. Check the endpoint reference for the required scope. |\n| `organization-access-denied` | Organization Access Denied | The API key belongs to a different organization than the resource you requested. | Confirm the resource ID is correct and belongs to your organization.                                  |\n| `insufficient-permissions`   | Insufficient Permissions   | The API key's associated user does not have the role required by this operation. | Use credentials with the required role, or contact your organization admin.                           |\n\n#### 404 Not Found\n\n| Error Type  | Title     | When You See It                                                                              | What To Do                                                                                                                         |\n| ----------- | --------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |\n| `not-found` | Not Found | The requested resource does not exist, or it exists but belongs to a different organization. | Verify the resource ID. The API does not distinguish between \"does not exist\" and \"belongs to another org\" to prevent enumeration. |\n\n#### 409 Conflict\n\n| Error Type | Title    | When You See It                                                                                                                           | What To Do                                                                                                                                                          |\n| ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `conflict` | Conflict | The operation conflicts with the current state of the resource, for example a duplicate name or an order that has already been submitted. | Read the `detail` field for the specific conflict. Resolve the conflict before retrying, for example by using a unique name or checking the current resource state. |\n\n#### 415 Unsupported Media Type\n\n| Error Type               | Title                  | When You See It                                                    | What To Do                                                                            |\n| ------------------------ | ---------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |\n| `unsupported-media-type` | Unsupported Media Type | A write request was sent without `Content-Type: application/json`. | Add the `Content-Type: application/json` header to all POST, PUT, and PATCH requests. |\n\n#### 422 Unprocessable Entity\n\n| Error Type              | Title                 | When You See It                                                                                                                                                                       | What To Do                                                                                                 |\n| ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |\n| `validation-error`      | Validation Error      | The request body is valid JSON but failed field-level validation: a required field is missing, a value is out of range, a type is wrong, or a cross-field rule was violated.          | Read the `detail` field. It describes the specific field and constraint that failed.                       |\n| `unprocessable-entity`  | Unprocessable Entity  | The request body is valid JSON with correct field types, but the combination of values does not make sense for this operation (e.g., conflicting settings, invalid state transition). | Read the `detail` field for the specific reason. The fix depends on the operation.                         |\n| `idempotency-key-reuse` | Idempotency Key Reuse | The same `Idempotency-Key` value was sent with a different request body within 24 hours.                                                                                              | Use a new unique key for the new request. If you intend to retry the original request, send the same body. |\n\n#### 429 Too Many Requests\n\n| Error Type               | Title                  | When You See It                                                                                 | What To Do                                                                                                                         |\n| ------------------------ | ---------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |\n| `rate-limit-exceeded`    | Rate Limit Exceeded    | The organization has exceeded its allowed request rate.                                         | Pause requests and resume after the `X-RateLimit-Reset` timestamp.                                                                 |\n| `ip-temporarily-blocked` | IP Temporarily Blocked | The originating IP address sent repeated invalid credentials and is blocked for a short period. | Fix the credential, then wait the number of seconds in `Retry-After`. The block clears on its own and retrying does not extend it. |\n| `ip-rate-limit-exceeded` | IP Rate Limit Exceeded | The originating IP address has exceeded the per-IP rate limit.                                  | Pause requests from this IP and resume after the `X-RateLimit-Reset` timestamp.                                                    |\n\nThree separate defences return a 429 and they mean different things, so read the `type` field before deciding what to do. Two are keyed to your originating IP address and run before we check your credentials. The third is your organization's own request budget.\n\nWe check them in that order, and a block takes precedence over every other result. If your address is blocked you receive `ip-temporarily-blocked` whether or not you were also over any request budget, so rule it out first.\n\nThe block counts distinct credentials, not attempts. Retrying one wrong key counts once no matter how many times you send it, so a misconfigured job cannot lock out the address it runs from. Sending several different wrong credentials is what triggers a block. A request carrying no credential at all never counts, and neither does a valid credential we refuse on policy, such as an API key of the wrong kind. That last point matters if you rotate keys: a key that is valid but lacks the scope for an endpoint returns 403 (`insufficient-scope`) and does not count toward a block.\n\nA write's rate-limit failure can also arrive as a 503. When our own rate limiter is unavailable on a write, we return `service-unavailable` rather than telling you that you sent too many requests. Retry it with backoff, and do not treat it as a signal to slow your own traffic down.\n\n#### 500 Internal Server Error\n\n| Error Type       | Title          | When You See It                             | What To Do                                                                                                          |\n| ---------------- | -------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |\n| `internal-error` | Internal Error | An unexpected failure occurred on our side. | Retry with exponential backoff. If the error persists, contact support with the `instance` value from the response. |\n\n#### 503 Service Unavailable\n\n| Error Type                   | Title                              | When You See It                                                                                                                     | What To Do                                                                            |\n| ---------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| `auth-service-unavailable`   | Authentication Service Unavailable | The authentication service is temporarily unavailable.                                                                              | Retry with exponential backoff. This is transient.                                    |\n| `service-unavailable`        | Service Unavailable                | A service we depend on is temporarily unavailable.                                                                                  | Retry with exponential backoff. This is transient.                                    |\n| `search-service-unavailable` | Search Service Unavailable         | The semantic search service is temporarily unavailable. Search-dependent endpoints, such as project search with a query, will fail. | Retry with exponential backoff, or omit the search query if the endpoint supports it. |\n\n#### 504 Gateway Timeout\n\n| Error Type        | Title           | When You See It                                         | What To Do                                                                                           |\n| ----------------- | --------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |\n| `gateway-timeout` | Gateway Timeout | An upstream call timed out before returning a response. | Retry once. If the timeout recurs, show it to the user or contact support with the `instance` value. |\n\n### Retry Strategy\n\nMatch your retry behavior to the status code class.\n\n**Do not retry** responses with status codes 400, 401, 403, 404, 409, 415, or 422. These are client errors. A retry without a fix to the request returns the same error.\n\n**429 Too Many Requests:** Stop all requests immediately and resume when the response tells you to. Do not use exponential backoff for 429s, because the response already carries the exact wait. Read `Retry-After` for the number of seconds, or `X-RateLimit-Reset` for the timestamp. Both derive from the same moment, including on a block, so either one is safe to wait on. On an `ip-temporarily-blocked` response, fix the credential before you retry, so the block does not recur. Retrying does not extend a block.\n\n**500 Internal Server Error:** Retry with increasing delays (1s, 2s, 4s, 8s) plus a small random offset, so many clients do not retry at the same moment. Cap at 30s. After four attempts, stop and report the error.\n\n**503 Service Unavailable:** Retry with increasing delays as above. Transient failures in a service we depend on cause these errors, and they typically resolve within seconds to minutes.\n\n**504 Gateway Timeout:** Retry once after a short delay. If the timeout recurs, show the error to the user.\n\n### Rate Limit Headers\n\nEvery API response includes the following headers, not only 429 responses.\n\n| Header                  | Description                                                                                                                                                                |\n| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `X-RateLimit-Limit`     | The maximum number of requests your organization can make per window. It reads `0` on an `ip-temporarily-blocked` response, where no budget applies and none is published. |\n| `X-RateLimit-Remaining` | The number of requests remaining in the current window.                                                                                                                    |\n| `X-RateLimit-Reset`     | The Unix timestamp (seconds) when the current window resets and the full limit is restored. On an `ip-temporarily-blocked` response it is when the block clears.           |\n| `Retry-After`           | On a denied request, the number of seconds to wait. It is derived from the same moment as `X-RateLimit-Reset`, so the two never disagree.                                  |\n\nMonitor `X-RateLimit-Remaining` proactively to avoid 429 errors. When it reaches zero, pause until `X-RateLimit-Reset`.\n\nA blocked address is the one case where `X-RateLimit-Limit` and `X-RateLimit-Remaining` tell you nothing useful, since both read `0` by design. Wait on `Retry-After` or `X-RateLimit-Reset`, either of which carries the real time the block clears. Publishing the real block threshold would tell someone guessing credentials how many guesses fit underneath it, so we do not.\n\n### Idempotency\n\nWrite endpoints accept an optional `Idempotency-Key` header (max 256 characters). If you send the same key within 24 hours and the original request returned a 2xx response, we return the stored response without re-executing the operation.\n\nOnly successful responses are stored. If the original request returned an error (4xx, 5xx), we store nothing under the key, and the next request with that key executes normally. This means idempotency protects against duplicate creates on success, not against retrying failed requests.\n\nBody matching uses the raw request bytes, not parsed JSON. If you change whitespace, key ordering, or formatting between retries, the bodies do not match even if they represent the same data. Keep your serialization consistent across retries.\n\nIf you send the same `Idempotency-Key` with a different request body, we return `422 idempotency-key-reuse`.\n\nUse a randomly generated UUID per request. Do not reuse keys across logically distinct operations."},{"name":"Setting Up a Project","description":"This guide covers the full project setup sequence: create a project, configure the questionnaire, add a screener, set up quotas, and launch recruitment. Each step depends on the one before it. For deeper coverage of individual resources, see Building Questionnaires, Building Screeners, and Managing Recruitment Orders.\n\n---\n\n### Create a Project\n\n`POST /projects` requires two fields: `project_name` and `research_objectives`. All other fields are optional and use platform defaults when omitted.\n\n```bash\ncurl -X POST https://app.strella.io/api/public/v1/projects \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"project_name\": \"Q4 Onboarding Research\",\n    \"research_objectives\": \"Understand what drives users to cancel\\nExplore unmet expectations in onboarding\",\n    \"target_audience\": \"Product managers aged 25-45\",\n    \"product_description\": \"A SaaS project management tool for small teams\",\n    \"default_language\": \"en\",\n    \"default_follow_up_depth\": 2,\n    \"moderator_voice\": \"sage\"\n  }'\n```\n\n```typescript\nconst response = await fetch('https://app.strella.io/api/public/v1/projects', {\n  method: 'POST',\n  headers: {\n    Authorization: 'Bearer ak_your_key_here',\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({\n    project_name: 'Q4 Onboarding Research',\n    research_objectives:\n      'Understand what drives users to cancel\\nExplore unmet expectations in onboarding',\n    target_audience: 'Product managers aged 25-45',\n    product_description: 'A SaaS project management tool for small teams',\n    default_language: 'en',\n    default_follow_up_depth: 2,\n    moderator_voice: 'sage',\n  }),\n})\nconst project = await response.json()\n```\n\n```python\nimport requests\n\nresponse = requests.post(\n    'https://app.strella.io/api/public/v1/projects',\n    headers={\n        'Authorization': 'Bearer ak_your_key_here',\n        'Content-Type': 'application/json',\n    },\n    json={\n        'project_name': 'Q4 Onboarding Research',\n        'research_objectives': (\n            'Understand what drives users to cancel\\n'\n            'Explore unmet expectations in onboarding'\n        ),\n        'target_audience': 'Product managers aged 25-45',\n        'product_description': 'A SaaS project management tool for small teams',\n        'default_language': 'en',\n        'default_follow_up_depth': 2,\n        'moderator_voice': 'sage',\n    },\n)\nproject = response.json()\n```\n\nThe response is `201 Created` with the full project object. Save the `id` field for all subsequent calls.\n\n**Note:** The `share_url` field is absent from this response. The first `PUT /projects/{project_id}/questionnaire/items` call sets it.\n\n**Project emails:** A project created through the Strella MCP connector subscribes its creator to the project's email notifications, such as the daily study digest, the same as a project created in Strella. A project created with an org API key subscribes nobody. Researchers add themselves from the notification settings in Strella.\n\n**Optional fields at create time:**\n\n**Project context:**\n\n| Field                     | Description                                                                                                                                 |\n| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |\n| `participant_facing_name` | Name shown to participants. Defaults to `project_name`.                                                                                     |\n| `target_audience`         | Free-text description of the intended participant population.                                                                               |\n| `product_description`     | Description of the product being researched.                                                                                                |\n| `project_context`         | Secure research context (motivation, hypotheses, constraints). Never shown to participants. Informs the guide, not live moderator behavior. |\n| `workspace_id`            | ID of the workspace to assign the project to.                                                                                               |\n| `moderation_mode`         | Who runs each interview: `\"ai\"` (default) or `\"human\"`. Set at creation only. See Create a Human-Moderated Project below.                   |\n\n**AI moderator:**\n\n| Field                       | Description                                                                                                                                                                                                                                         |\n| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `default_language`          | ISO 639-1 code for the default interview language (e.g., `\"en\"`).                                                                                                                                                                                   |\n| `default_follow_up_depth`   | Follow-up depth: `0` (off), `1` (low), `2` (medium), `3` (high).                                                                                                                                                                                    |\n| `moderator_voice`           | Voice identifier. Call `GET /projects/settings` to list valid values.                                                                                                                                                                               |\n| `moderator_name`            | Display name shown to participants.                                                                                                                                                                                                                 |\n| `greet_participant_by_name` | Whether the moderator speaks the participant's name in its opening greeting. Off means it opens with \"Hi there\" instead; the real name is still stored and still keys results. To anonymize the name everywhere, set `anonymize_participant_names`. |\n| `voice_speed`               | Voice speed. Accepts one of `0.5`, `0.7`, `0.75`, `0.8`, `0.9`, `1.0`, `1.1`, `1.2`, `1.25`, `1.5`, `1.75`, `2.0`. Call `GET /projects/settings` for the current set.                                                                               |\n| `allowed_languages`         | Array of ISO 639-1 codes the moderator supports. Empty array means all languages.                                                                                                                                                                   |\n| `analysis_language`         | Language for AI-generated analysis output. Defaults to `\"en-US\"`.                                                                                                                                                                                   |\n| `general_moderator_prompt`  | Custom behavioral instructions applied to the live moderator (interview style, probing approach, dos/don'ts). This field, not `project_context`, changes how the moderator behaves during interviews.                                               |\n\n**Participant settings:**\n\n| Field                      | Description                                                                 |\n| -------------------------- | --------------------------------------------------------------------------- |\n| `participant_target_count` | Target number of participants for the study.                                |\n| `auto_invite`              | When `true`, Strella invites qualified participants without manual review.  |\n| `close_date`               | ISO 8601 datetime after which the project stops accepting new participants. |\n| `email_required`           | Whether participant email is required.                                      |\n| `name_required`            | Whether participant name is required.                                       |\n\n**Device and sharing:**\n\n| Field                               | Description                                                                                                                                                                                                                                                                                             |\n| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `device_restriction`                | `\"desktop_only\"`, `\"mobile_only\"`, or `\"no_restriction\"`. (`\"mobile_app\"` is a deprecated alias, accepted on writes and stored as `\"mobile_only\"`.) Any value combines with any `screen_sharing_requirement`; when sharing is required, mobile participants are routed to the Strella app at join time. |\n| `screen_sharing_requirement`        | `\"enabled\"` or `\"disabled\"`. Combines with any `device_restriction`.                                                                                                                                                                                                                                    |\n| `video_sharing_requirement`         | `\"required_to_join\"` (camera on) or `\"disabled\"` (camera off). See `GET /projects/settings` for valid values.                                                                                                                                                                                           |\n| `push_to_talk`                      | Require participants to hold a button to speak. Requires `device_restriction: \"desktop_only\"`.                                                                                                                                                                                                          |\n| `blur_participant_camera`           | Blur the participant's camera feed. Requires `video_sharing_requirement` not `\"disabled\"` and `device_restriction: \"desktop_only\"`.                                                                                                                                                                     |\n| `blur_participant_screen_share`     | Blur the participant's screen share. Requires `screen_sharing_requirement: \"enabled\"`.                                                                                                                                                                                                                  |\n| `allow_disabling_screen_recording`  | Let participants disable screen recording mid-session. Requires `screen_sharing_requirement: \"enabled\"`.                                                                                                                                                                                                |\n| `screen_share_display_surface`      | `\"window\"` or `\"monitor\"`. Requires `screen_sharing_requirement: \"enabled\"`.                                                                                                                                                                                                                            |\n| `require_screen_share_verification` | Verify screen share content before proceeding. Has no effect while screen sharing is off.                                                                                                                                                                                                               |\n| `voice_modulation_enabled`          | Enable voice anonymization for participants.                                                                                                                                                                                                                                                            |\n| `default_captions_enabled`          | Enable captions by default during interviews.                                                                                                                                                                                                                                                           |\n| `session_timeout`                   | Inactivity timeout in seconds. Valid values: `30`, `60`, `120`, `180`, `300`, `600`, `900`. Default: `300`.                                                                                                                                                                                             |\n| `anonymize_participant_names`       | Anonymize participant names in the researcher view.                                                                                                                                                                                                                                                     |\n\nThe API enforces cross-field constraints on these fields. Invalid combinations return `422` with a descriptive error. Use `GET /projects/settings` to discover valid enum values and defaults.\n\n### Create a Human-Moderated Project\n\nIn a human-moderated project, a Researcher runs each interview live on a video call that the Participant books, in place of the AI moderator. Pass `\"moderation_mode\": \"human\"` on `POST /projects` to create one. The mode is fixed at creation: `PATCH /projects/{project_id}` does not accept it.\n\n```bash\ncurl -X POST https://app.strella.io/api/public/v1/projects \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"project_name\": \"Onboarding Interviews\",\n    \"research_objectives\": \"Understand where new admins get stuck during setup\",\n    \"moderation_mode\": \"human\"\n  }'\n```\n\n`moderation_mode` comes back on the project detail and on every row of `GET /projects`. A human-moderated project differs from an AI-moderated one in these ways:\n\n| Area                  | Behavior                                                                                                                                                                                                 |\n| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Devices               | Any `device_restriction`. A participant on a phone joins in the browser, never in the Strella app. A project created before v1.14.0 reads `\"desktop_only\"` until you change it.                          |\n| Question guide        | None. `PUT /projects/{project_id}/questionnaire/items` and the single-item add, replace, edit, and delete endpoints return `422`. Build the screener with `PUT /projects/{project_id}/screener` instead. |\n| AI moderator settings | Have no effect, since no AI moderator joins the call.                                                                                                                                                    |\n| Recruitment           | Share links and Respondent only. User Interviews orders return `422`. See Managing Recruitment Orders.                                                                                                   |\n| Going live            | Happens in Strella: the Researcher sets their availability on the project's Scheduling tab, then publishes from the Build tab. Strella's API has no endpoint for either step.                            |\n\n---\n\n### Configure the Questionnaire\n\n`PUT /projects/{project_id}/questionnaire/items` sets the interview script the AI moderator follows. This is a full replacement of all items. The first call to this endpoint also generates the `share_url` for the project.\n\n```bash\ncurl -X PUT https://app.strella.io/api/public/v1/projects/PROJECT_ID/questionnaire/items \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"items\": [\n      {\n        \"item_type\": \"question\",\n        \"content\": \"Walk me through how you currently handle onboarding new team members.\",\n        \"dynamic_followups\": true,\n        \"follow_up_depth\": 2\n      },\n      {\n        \"item_type\": \"multiple_choice\",\n        \"content\": \"Which tools do you use most during onboarding?\",\n        \"choices\": [\n          { \"content\": \"Notion\" },\n          { \"content\": \"Confluence\" },\n          { \"content\": \"Google Docs\" },\n          { \"content\": \"Internal wiki\" }\n        ],\n        \"multi_select\": true\n      },\n      {\n        \"item_type\": \"question\",\n        \"content\": \"What is the biggest friction point in your current onboarding process?\"\n      }\n    ]\n  }'\n```\n\nTypeScript and Python follow the same pattern as the curl example above.\n\nThe response includes the updated questionnaire with stable `id` values assigned to each item. Send these IDs back in subsequent `PUT` calls, or per-question analysis data from completed interviews loses the link to its question.\n\n**Supported item types:**\n\n- `question`: Open-ended verbal question. Set `dynamic_followups: true` to enable AI-generated follow-ups.\n- `task`: A prompt for the participant to complete a task (e.g., screen share walkthrough).\n- `statement`: A read-only statement with no expected response.\n- `multiple_choice`: A multiple-choice question. Requires at least one `choices` entry. Set `multi_select: true` to allow multiple selections.\n- `ranking`: A ranking question. Requires `choices`.\n- `free_response`: A text-input question with an optional `character_limit`.\n- `slider`: A numeric slider question. Requires `slider_config` with `min_value`, `max_value`, and `step`.\n- `section_block`: A container that groups child items. Supports `audience_rules` to conditionally show sections based on screener answers.\n\nFor full item schema details, skip logic, and audience rules, see Building Questionnaires.\n\n---\n\n### Add a Screener\n\n`PUT /projects/{project_id}/screener` sets the qualifying quiz shown to participants before their interview. This is a full replacement of all screener questions. The screener is optional. Skip this step if all participants proceed directly to the interview.\n\n```bash\ncurl -X PUT https://app.strella.io/api/public/v1/projects/PROJECT_ID/screener \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"questions\": [\n      {\n        \"text\": \"Do you currently manage a team of 5 or more people?\",\n        \"question_type\": \"radio\",\n        \"is_required\": true,\n        \"answers\": [\n          { \"text\": \"Yes\", \"answer_value\": \"1\" },\n          { \"text\": \"No\", \"answer_value\": \"2\" }\n        ]\n      },\n      {\n        \"text\": \"Which of the following tools does your team use? (Select all that apply)\",\n        \"question_type\": \"checkbox\",\n        \"is_required\": true,\n        \"answers\": [\n          { \"text\": \"Jira\", \"answer_value\": \"1\" },\n          { \"text\": \"Linear\", \"answer_value\": \"1\" },\n          { \"text\": \"Trello\", \"answer_value\": \"1\" },\n          { \"text\": \"None of the above\", \"answer_value\": \"3\" }\n        ]\n      }\n    ]\n  }'\n```\n\nTypeScript and Python follow the same pattern as the curl example above.\n\n**Answer value semantics:**\n\nFor `radio` questions, `answer_value` controls qualification:\n\n- `\"1\"`: this answer qualifies the participant\n- `\"2\"`: this answer disqualifies the participant\n\nFor `checkbox` questions, `answer_value` controls selection logic:\n\n- `\"1\"`: participant may select this answer (selecting it is neutral)\n- `\"2\"`: participant must select this answer to qualify\n- `\"3\"`: selecting this answer disqualifies the participant\n\nEvery `radio` question must have at least one answer with `answer_value: \"1\"`. Every `checkbox` question must have at least one answer with `answer_value: \"1\"` or `\"2\"`.\n\n**Supported question types:** `radio`, `checkbox`, `slider_scale`, `single_line_text`, `multi_line_text`.\n\nFor skip logic, slider configuration, and the \"Other\" option, see Building Screeners.\n\n---\n\n### Set Up Quotas\n\n`POST /projects/{project_id}/quotas` creates a quota that caps how many participants from a given segment are accepted. Quotas require a published screener because criteria reference screener question text.\n\n```bash\ncurl -X POST https://app.strella.io/api/public/v1/projects/PROJECT_ID/quotas \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"name\": \"Team managers (5+ reports)\",\n    \"limit\": 10,\n    \"behavior\": \"close_when_full\",\n    \"criteria\": {\n      \"filter_groups\": [\n        {\n          \"filters\": [\n            {\n              \"screener_question\": \"Do you currently manage a team of 5 or more people?\",\n              \"match_rule\": \"is_one_of\",\n              \"answers\": [\"Yes\"]\n            }\n          ]\n        }\n      ]\n    }\n  }'\n```\n\nTypeScript and Python follow the same pattern as the curl example above.\n\nThe `criteria` object uses Disjunctive Normal Form (DNF), which means OR/AND logic: a participant matches if they satisfy any one of the filter groups (OR). Within each group, all filters must match (AND). Each filter references a screener question by its exact text (case-insensitive) and matches specific answer option texts. A quota takes at most 20 filter groups and at most 20 filters per group.\n\nStrella checks a quota's filters one at a time, from first to last, with no parentheses, so \"A and B or C and D\" is read as \"((A and B) or C) and D\". A `POST` or `PATCH` whose `filter_groups` cannot be stored with the meaning you wrote returns 422, and the error message lists the supported shapes. Here A, B, C and D each stand for one filter:\n\n- **Single-filter groups:** `[[A], [B], [C]]` means A or B or C.\n- **One group with several filters, plus single-filter groups:** `[[A, B], [C]]` means (A and B) or C.\n- **Groups that all share a filter:** `[[A, C], [B, C]]` means (A or B) and C.\n\nTwo groups that each hold several filters and share none, such as `[[A, B], [C, D]]`, return 422. When each segment can have its own target, create one quota per group.\n\n**`behavior` values:**\n\n- `\"close_when_full\"` (default): once `completed_count` reaches `limit`, no new participants are assigned to this quota.\n- `\"allow_overflow\"`: the quota continues accepting participants beyond the `limit`.\n\nA project supports up to 100 quotas. Create one quota per `POST` call.\n\nEvery quota read carries `group`: `null` for a standalone quota, or `{ id, name }` when the quota is one bucket of a quota group (for example each gender option). Quotas in a group are mutually exclusive and a participant is counted in one quota per group. `GET /quotas` also returns every group on the project as `quota_groups`; place a quota in one with `group_id` on `POST` or `PATCH` (`null` ungroups it). The criteria must not overlap any quota already in the group (409), and the project must have quota groups turned on in its settings (422 otherwise).\n\nManage groups on their own resource: `GET`/`POST /projects/{project_id}/quota-groups` list and create them, and `PATCH`/`DELETE /projects/{project_id}/quota-groups/{group_id}` rename and delete. Group names are unique per project (409 on a duplicate). Deleting a group that still holds quotas returns 409, so move or delete its quotas first.\n\n---\n\n### Launch Recruitment\n\nRecruitment is a two-step process: create a draft order, then submit it. Strella contacts the panel provider and charges credits only at submission.\n\n#### Step 1: Create a draft order\n\nChoose your panel. This example uses the User Interviews panel.\n\n```bash\ncurl -X POST https://app.strella.io/api/public/v1/projects/PROJECT_ID/recruitment/user-interviews \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"participant_count\": 10,\n    \"incentive_per_participant\": 75,\n    \"title\": \"Onboarding Research Study\",\n    \"description\": \"We are looking for team managers to discuss their onboarding processes in a 30-minute video call.\",\n    \"target_market\": \"us\"\n  }'\n```\n\nThe response includes the order `id` and `status: \"pending\"`. Save the order `id` for the next step.\n\nFor Respondent orders, use `POST /projects/{project_id}/recruitment/respondent` with a `targeting` object. Call `GET /projects/settings` to discover valid targeting options for each panel.\n\n#### Step 2: Submit the order\n\nTransitioning to `\"recruiting\"` submits the order to the panel provider and charges credits.\n\n```bash\ncurl -X PATCH https://app.strella.io/api/public/v1/projects/PROJECT_ID/recruitment/ORDER_ID \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"status\": \"recruiting\" }'\n```\n\nThe response returns the updated order with `status: \"recruiting\"` and a populated `submitted_at` timestamp.\n\n**Important:** Strella holds every participant recruited through the API for manual review before sending an invite. Set `auto_invite: true` at the project level (`PATCH /projects/{project_id}`) to invite qualified participants automatically.\n\nFor order lifecycle states (pause, close, refund), panel differences, and targeting options, see Managing Recruitment Orders.\n\n---\n\n### What's Next\n\n- **Polling for Changes**: monitor participant status changes by polling `GET /projects/{project_id}/participants?sort=updated_at:desc`.\n- **Data Taxonomy**: understand the full resource graph: projects, questionnaires, screeners, quotas, recruitment orders, participants, interviews, and sessions.\n- **Participant Lifecycle**: accept or reject participants in review using `PATCH /projects/{project_id}/participants/{participant_id}`."},{"name":"Managing Recruitment Orders","description":"Recruitment orders let you source participants from two external panels: Respondent and User Interviews. You create a draft order, configure targeting, then submit it to start recruiting. Orders move through a defined lifecycle and can be paused or closed after submission.\n\nRequired scope: `recruitment:write` for mutations, `recruitment:read` for reads.\n\n### Order Lifecycle\n\n| Status       | Meaning                                                                |\n| ------------ | ---------------------------------------------------------------------- |\n| `pending`    | Draft. The panel has not been contacted. Metadata edits are allowed.   |\n| `recruiting` | Active. The panel is sourcing participants. Credits have been charged. |\n| `paused`     | Sourcing stopped. No participants are being routed to the project.     |\n| `filled`     | The panel has delivered the requested number of participants.          |\n| `closed`     | Order ended. No further participants will be routed.                   |\n\n**Transitions:**\n\n- `pending` → `recruiting`: Submits the order to the panel and charges credits. For Respondent, this calls the Respondent API immediately. For User Interviews, this activates the order on the User Interviews platform.\n- `recruiting` → `paused`: Stops the panel from sourcing new participants. For Respondent, this pause is terminal and the order cannot be resumed. For User Interviews, the order can be resumed.\n- `paused` → `recruiting`: Resumes sourcing. Supported for User Interviews only. Respondent paused orders are terminal.\n- Strella moves a `recruiting` order on either panel to `paused` while its project is full or closed, and back to `recruiting` once the project has room. If you resume a User Interviews order Strella paused while the project is still full, Strella pauses it again.\n- `recruiting`, `paused` or `filled` → `closed`: Ends the order. Closing a `pending` order returns `422`. For User Interviews, Strella refunds unused credits automatically. For Respondent, closing is not supported in v1; send `paused` to stop sourcing.\n\n### Creating a Draft Order\n\nCreate a pending order for User Interviews:\n\n```bash\ncurl -X POST https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/user-interviews \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"target_market\": \"b2c\",\n    \"participant_count\": 10,\n    \"incentive_per_participant\": 30,\n    \"targeting\": [\n      { \"country\": { \"$in\": [\"US\", \"CA\"] } }\n    ],\n    \"title\": \"Mobile Banking UX Study\"\n  }'\n```\n\nCreate a pending order for Respondent:\n\n```bash\ncurl -X POST https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/respondent \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"participant_count\": 10,\n    \"incentive_per_participant\": 25,\n    \"targeting\": {\n      \"market_type\": \"b2b\",\n      \"countries\": [{ \"name\": \"United States\", \"value\": \"US\" }],\n      \"job_titles\": [{ \"name\": \"Product Manager\", \"value\": \"pm-001\" }],\n      \"job_functions\": [{ \"name\": \"Product Management\", \"value\": \"product\" }],\n      \"industries\": [{ \"name\": \"Software\", \"value\": \"software\" }]\n    }\n  }'\n```\n\nBoth endpoints return `201 Created` with the full order including targeting. The response `status` is `pending` and `submitted_at` is `null`. Strella does not contact the panel at this point.\n\nA project can hold multiple pending orders per panel. Each call creates a new draft.\n\nThe project must also have a published screener before you create an order. Both draft creation and submission return a `422` when the project has no published screener questions, and Strella creates no order and charges no credits. Publish a screener with `PUT /projects/{project_id}/screener` first.\n\n### Submitting an Order\n\nSend `PATCH` with `{ \"status\": \"recruiting\" }` to submit a pending order:\n\n```bash\ncurl -X PATCH https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/{order_id} \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"status\": \"recruiting\" }'\n```\n\nThis call charges credits and contacts the panel. You cannot undo a submission. To stop recruiting after this point, use `paused` or `closed`.\n\nThe response returns the full order detail with the updated `status` and a populated `submitted_at` timestamp.\n\n### Managing Active Orders\n\n**Pause recruiting:**\n\n```bash\ncurl -X PATCH https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/{order_id} \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"status\": \"paused\" }'\n```\n\n**Close an order:**\n\n```bash\ncurl -X PATCH https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/{order_id} \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"status\": \"closed\" }'\n```\n\nPanel constraints apply. For Respondent, `paused` is terminal and you cannot resume a paused Respondent order. `closed` is not supported for Respondent in v1; use `paused` instead. For User Interviews, all transitions are supported.\n\nThe server returns `422` for invalid transitions and `409` if the order status changed concurrently before your request arrived.\n\n### Panel Differences\n\n| Feature                  | Respondent                                             | User Interviews                                      |\n| ------------------------ | ------------------------------------------------------ | ---------------------------------------------------- |\n| Targeting format         | Named value objects (`{ name, value }`) keyed by field | Operator-based criteria (`{ slug: { $in: [...] } }`) |\n| Pause                    | Terminal, cannot resume                                | Resumable via `{ \"status\": \"recruiting\" }`           |\n| Close                    | Not supported in v1                                    | Supported; refunds unused credits                    |\n| Refund on close          | Not applicable                                         | Automatic                                            |\n| Auto-invite              | Settable per order; defaults to the project setting    | Settable per order; defaults to the project setting  |\n| Require approval         | Hardcoded `true`                                       | Hardcoded `true`                                     |\n| Human-moderated projects | Draft only; submit in Strella                          | Not supported; create returns `422`                  |\n\nAll API-created orders hold recruited participants in `in_review` status regardless of panel. To auto-invite qualified participants, set `auto_invite` per order (on create or `PATCH /projects/{project_id}/recruitment/{order_id}`), or project-wide via `PATCH /projects/{project_id}`. This is Strella's internal AI approval of screener passers rated 4+; it is never the vendor panel's auto-invite, which stays off. An omitted `auto_invite` on order create inherits the project setting.\n\n### Recruit for a Human-Moderated Project\n\nA project created with `\"moderation_mode\": \"human\"` recruits through share links or Respondent. We can draft a Respondent order for it through the API as usual, and the draft is priced at the human-moderated rate. Submitting it with `{ \"status\": \"recruiting\" }` returns `422`: the Researcher submits the order in Strella after setting their availability, because Respondent panelists book their interview slot on that schedule.\n\nUser Interviews does not support human-moderated projects. Creating or submitting a User Interviews order for one returns `422` before Strella charges any credits.\n\n### Targeting Options\n\nBefore building a targeting body, fetch the available criteria for the panel you are using.\n\n**Respondent (all options):**\n\n```bash\ncurl https://app.strella.io/api/public/v1/recruitment/respondent/options \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n**Respondent (job title search):**\n\n```bash\ncurl \"https://app.strella.io/api/public/v1/recruitment/respondent/job-titles?q=product+manager\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n**User Interviews (all characteristics):**\n\n```bash\ncurl https://app.strella.io/api/public/v1/recruitment/user-interviews/options \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\nThe User Interviews options response lists all targeting characteristics. Closed-set characteristics include their option values inline under `options[]`. Searchable characteristics (such as location and company) omit `options`. Use the per-slug search endpoint instead:\n\n```bash\ncurl \"https://app.strella.io/api/public/v1/recruitment/user-interviews/characteristics/{slug}/options?q=new+york\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\nPassing a slug that has inlined options to the search endpoint returns `404`. Only searchable slugs are valid there.\n\nSee the endpoint reference for the full list of targeting fields, required vs optional fields, and value constraints.\n\n### Order Counts and Freshness\n\nEach order response includes a `counts` object:\n\n```json\n{\n  \"counts\": {\n    \"qualified\": 15,\n    \"invited\": 12,\n    \"completed\": 8,\n    \"pending\": 4,\n    \"as_of\": \"2026-04-28T15:42:00.000Z\"\n  }\n}\n```\n\n`counts.as_of` is the timestamp of the last sync from the panel vendor.\n\nFor Respondent, counts update automatically in near real time as participants take action on the panel.\n\nFor User Interviews, counts refresh only when new participants sign up for your study. If no new participants arrive, `as_of` does not advance even if status changes occur on the vendor side. In steady state, User Interviews counts can be stale for an extended period. Poll `GET /projects/{project_id}/recruitment/{order_id}` to get the latest snapshot, but treat `as_of` as the authoritative freshness indicator.\n\n### Metadata Edits\n\nYou can update targeting and text fields on a `pending` order by sending a panel-shaped body without a `status` field.\n\n**Update a pending Respondent order:**\n\n```bash\ncurl -X PATCH https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/{order_id} \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"participant_count\": 15,\n    \"incentive_per_participant\": 30\n  }'\n```\n\n**Update a pending User Interviews order:**\n\n```bash\ncurl -X PATCH https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/{order_id} \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"target_market\": \"b2b\",\n    \"participant_count\": 8,\n    \"title\": \"Enterprise Onboarding Study\"\n  }'\n```\n\nMetadata edits apply only to `pending` orders. Editing a submitted order returns `422`. Send at least one field.\n\nSending `status` and metadata fields in the same body returns `422`. The two operations are intentionally separate.\n\nTo delete a pending order entirely:\n\n```bash\ncurl -X DELETE https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/{order_id} \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\nReturns `204 No Content` on success. Only `pending` orders can be deleted.\n\n### Testing with Sandbox Mode\n\nSandbox mode lets you test your recruitment integration end-to-end without contacting the panel or spending credits. When you create an order in sandbox mode, the server skips the panel integration and generates a synthetic participant and session on the project. You can then call every read endpoint (list participants, fetch detail, read transcripts) against realistic sample data.\n\nBoth Respondent and User Interviews panel orders support sandbox mode.\n\nCreate a dedicated workspace (e.g., \"Sandbox\" or \"Integration Tests\") and assign your test projects to it. This keeps sandbox participants and orders separate from live data when browsing projects in Strella.\n\n#### Creating a Sandbox Order\n\nPass `\"sandbox\": true` in the POST body:\n\n```bash\ncurl -X POST https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/respondent \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"participant_count\": 10,\n    \"incentive_per_participant\": 25,\n    \"targeting\": {\n      \"market_type\": \"b2b\",\n      \"countries\": [{ \"name\": \"United States\", \"value\": \"US\" }],\n      \"job_titles\": [{ \"name\": \"Product Manager\", \"value\": \"pm-001\" }]\n    },\n    \"sandbox\": true\n  }'\n```\n\n```typescript\nconst response = await fetch(\n  'https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/respondent',\n  {\n    method: 'POST',\n    headers: {\n      Authorization: 'Bearer ak_your_key_here',\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      participant_count: 10,\n      incentive_per_participant: 25,\n      targeting: {\n        market_type: 'b2b',\n        countries: [{ name: 'United States', value: 'US' }],\n        job_titles: [{ name: 'Product Manager', value: 'pm-001' }],\n      },\n      sandbox: true,\n    }),\n  },\n)\n```\n\n```python\nimport requests\n\nresponse = requests.post(\n    \"https://app.strella.io/api/public/v1/projects/{project_id}/recruitment/respondent\",\n    headers={\n        \"Authorization\": \"Bearer ak_your_key_here\",\n        \"Content-Type\": \"application/json\",\n    },\n    json={\n        \"participant_count\": 10,\n        \"incentive_per_participant\": 25,\n        \"targeting\": {\n            \"market_type\": \"b2b\",\n            \"countries\": [{\"name\": \"United States\", \"value\": \"US\"}],\n            \"job_titles\": [{\"name\": \"Product Manager\", \"value\": \"pm-001\"}],\n        },\n        \"sandbox\": True,\n    },\n)\n```\n\nAlternatively, send the `X-Sandbox: true` request header instead of the body field. This is useful when your HTTP client or SDK generates the body from a schema and you prefer not to modify it. The header accepts `true` or `1` (case-insensitive). If both the body field and header are present, the body field takes precedence.\n\n#### What Sandbox Creates\n\nEach sandbox order generates one synthetic participant and one session on the project:\n\n- The participant appears in `GET /projects/{project_id}/participants` with sample data (name, occupation, age). Consecutive sandbox orders on the same project cycle through four distinct profiles.\n- The session has a 10-minute duration. No recordings or transcripts are generated.\n- The participant is placed directly into `attended` status with no manual transition needed.\n\nAccepting a sandbox participant does not deduct credits or trigger a reward.\n\n#### Identifying Sandbox Orders\n\nSandbox orders include `\"sandbox\": true` in every response (create, list, detail). Real orders omit the field entirely, so you can use its presence to distinguish test data from production data:\n\n```json\n{\n  \"id\": \"clx...\",\n  \"panel\": \"respondent\",\n  \"status\": \"pending\",\n  \"sandbox\": true,\n  \"participant_count\": 10,\n  \"...\"\n}\n```\n\n#### Constraints\n\nSandbox orders enforce the same JSON shape validation as real orders. The project must have a questionnaire; missing it returns `422`.\n\nFor Respondent orders, sandbox skips the live-catalog check on targeting codes. A sandbox `201` proves the JSON shape is valid, not that your codes exist in the Respondent.io catalog. Fetch `/recruitment/respondent/options` or drop the sandbox flag to validate codes against the catalog. User Interviews targeting validates against a built-in characteristic list whether sandbox or not, so unknown slugs return `422` in both modes.\n\n**Do not submit sandbox orders.** Sandbox is a create-time flag only. If you transition a sandbox order to `recruiting` via `PATCH`, it charges credits and contacts the panel like a real order. Use sandbox orders for testing the create and read flow, not the submission lifecycle."},{"name":"Accepting and Rejecting Participants","description":"### Overview\n\nA Participant moves through a fixed sequence of states: screener, then interview, then quality review. Strella's API makes the final decision from your code: accept a Participant with payment, or reject them with a recorded reason. Your code then makes every decision without a Researcher reviewing each one, and pays every accepted Participant by the same rule.\n\n### Participant States\n\n| State         | Description                                                                                           |\n| ------------- | ----------------------------------------------------------------------------------------------------- |\n| `unqualified` | Participant started the screener but has not submitted it                                             |\n| `qualified`   | Participant passed the screener and is waiting to be invited                                          |\n| `invited`     | Participant was invited to the interview and has not completed it yet                                 |\n| `in_review`   | Interview is complete; participant is awaiting acceptance or rejection                                |\n| `accepted`    | Participant passed quality review; incentive payment has been processed or recorded                   |\n| `rejected`    | Participant was rejected for quality or an invalid response, had their invite cancelled, or no-showed |\n\nSee the Data Taxonomy guide for the full lifecycle diagram and transition rules.\n\n### Accepting a Participant\n\nSend a `PATCH` request to update the participant's status to `accepted`. For self-recruited participants, include `payment_method` and `amount_cents` to record the incentive payment.\n\n```\nPATCH https://app.strella.io/api/public/v1/projects/{project_id}/participants/{participant_id}\n```\n\n**Request body:**\n\n```json\n{\n  \"status\": \"accepted\",\n  \"payment_method\": \"credits\",\n  \"amount_cents\": 5000\n}\n```\n\n**curl**\n\n```bash\ncurl -X PATCH \\\n  \"https://app.strella.io/api/public/v1/projects/cm7c4hp5w0003jm04kx8v2n9r/participants/cm7f2kp9w0008jm04xt5v3n7s\" \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"status\": \"accepted\",\n    \"payment_method\": \"credits\",\n    \"amount_cents\": 5000\n  }'\n```\n\n**Python**\n\n```python\nimport requests\n\nresponse = requests.patch(\n    \"https://app.strella.io/api/public/v1/projects/cm7c4hp5w0003jm04kx8v2n9r/participants/cm7f2kp9w0008jm04xt5v3n7s\",\n    headers={\n        \"Authorization\": \"Bearer ak_your_key_here\",\n        \"Content-Type\": \"application/json\",\n    },\n    json={\n        \"status\": \"accepted\",\n        \"payment_method\": \"credits\",\n        \"amount_cents\": 5000,\n    },\n)\nparticipant = response.json()\n```\n\n**Node.js**\n\n```javascript\nconst response = await fetch(\n  'https://app.strella.io/api/public/v1/projects/cm7c4hp5w0003jm04kx8v2n9r/participants/cm7f2kp9w0008jm04xt5v3n7s',\n  {\n    method: 'PATCH',\n    headers: {\n      Authorization: 'Bearer ak_your_key_here',\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      status: 'accepted',\n      payment_method: 'credits',\n      amount_cents: 5000,\n    }),\n  },\n)\nconst participant = await response.json()\n```\n\n**Payment field rules:**\n\n- `payment_method`: `\"credits\"` charges your organization's credit balance. `\"manual\"` records the acceptance without an API-initiated charge, for cases where payment is handled outside the platform.\n- `amount_cents`: required whenever `payment_method` is present. A positive integer in US cents, maximum `100000` ($1,000). Check your credit balance with `GET /credits/balance` before paying with `\"credits\"`.\n- **Panel-recruited participants**: the recruitment panel handles payment directly. Do not include `payment_method` or `amount_cents` for panel participants; the API returns `422` if you do.\n- **Self-recruited participants**: both `\"credits\"` and `\"manual\"` are supported.\n- **API key actors**: API keys (identifiers starting with `ak_`) cannot use `payment_method: \"credits\"` for self-recruited participants. Use `\"manual\"`, or perform the acceptance through a user session instead.\n\nThe response is the updated `ParticipantDetail` object.\n\n### Rejecting a Participant\n\nSend a `PATCH` request with `status: \"rejected\"`. Include the optional `reason` field to keep a record of why. We store it on the participant record as `rejection_reason`.\n\n```\nPATCH https://app.strella.io/api/public/v1/projects/{project_id}/participants/{participant_id}\n```\n\n**Request body:**\n\n```json\n{\n  \"status\": \"rejected\",\n  \"reason\": \"Did not meet screening criteria\"\n}\n```\n\n**curl**\n\n```bash\ncurl -X PATCH \\\n  \"https://app.strella.io/api/public/v1/projects/cm7c4hp5w0003jm04kx8v2n9r/participants/cm7f2kp9w0008jm04xt5v3n7s\" \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"status\": \"rejected\",\n    \"reason\": \"Did not meet screening criteria\"\n  }'\n```\n\n**Python**\n\n```python\nimport requests\n\nresponse = requests.patch(\n    \"https://app.strella.io/api/public/v1/projects/cm7c4hp5w0003jm04kx8v2n9r/participants/cm7f2kp9w0008jm04xt5v3n7s\",\n    headers={\n        \"Authorization\": \"Bearer ak_your_key_here\",\n        \"Content-Type\": \"application/json\",\n    },\n    json={\n        \"status\": \"rejected\",\n        \"reason\": \"Did not meet screening criteria\",\n    },\n)\nparticipant = response.json()\n```\n\n**Node.js**\n\n```javascript\nconst response = await fetch(\n  'https://app.strella.io/api/public/v1/projects/cm7c4hp5w0003jm04kx8v2n9r/participants/cm7f2kp9w0008jm04xt5v3n7s',\n  {\n    method: 'PATCH',\n    headers: {\n      Authorization: 'Bearer ak_your_key_here',\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      status: 'rejected',\n      reason: 'Did not meet screening criteria',\n    }),\n  },\n)\nconst participant = await response.json()\n```\n\nRejection removes the participant's session from the project. We store the `reason` on the participant record for audit purposes. Payment fields (`payment_method`, `amount_cents`) are invalid on a rejection request and return `422`.\n\n### Editing a Participant\n\nThe same `PATCH` request renames a participant or sets whether they are excluded from analysis. Send only the fields to change; omitted fields keep their values, and `status` is not required.\n\n| Field                    | Type    | Rules                                                                                                                   |\n| ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------- |\n| `name`                   | string  | 1 to 100 characters, trimmed. Requires full name access: `pii_acl.name` is `full`.                                      |\n| `excluded_from_analysis` | boolean | `true` leaves the participant out of the project's analysis (Summary, themes, and charts). `false` includes them again. |\n\n**Request body:**\n\n```json\n{\n  \"name\": \"Jordan Rivera\",\n  \"excluded_from_analysis\": true\n}\n```\n\n**curl**\n\n```bash\ncurl -X PATCH \\\n  \"https://app.strella.io/api/public/v1/projects/cm7c4hp5w0003jm04kx8v2n9r/participants/cm7f2kp9w0008jm04xt5v3n7s\" \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"name\": \"Jordan Rivera\",\n    \"excluded_from_analysis\": true\n  }'\n```\n\n**Python**\n\n```python\nimport requests\n\nresponse = requests.patch(\n    \"https://app.strella.io/api/public/v1/projects/cm7c4hp5w0003jm04kx8v2n9r/participants/cm7f2kp9w0008jm04xt5v3n7s\",\n    headers={\n        \"Authorization\": \"Bearer ak_your_key_here\",\n        \"Content-Type\": \"application/json\",\n    },\n    json={\n        \"name\": \"Jordan Rivera\",\n        \"excluded_from_analysis\": True,\n    },\n)\nparticipant = response.json()\n```\n\n**Node.js**\n\n```javascript\nconst response = await fetch(\n  'https://app.strella.io/api/public/v1/projects/cm7c4hp5w0003jm04kx8v2n9r/participants/cm7f2kp9w0008jm04xt5v3n7s',\n  {\n    method: 'PATCH',\n    headers: {\n      Authorization: 'Bearer ak_your_key_here',\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      name: 'Jordan Rivera',\n      excluded_from_analysis: true,\n    }),\n  },\n)\nconst participant = await response.json()\n```\n\nThe response is the full participant detail, with `name` and `excluded_from_analysis` showing the new values. Every participant read carries `excluded_from_analysis`.\n\nA rename overwrites the whole name, so we refuse it when the caller cannot read the whole name:\n\n- `403` with `code: full_name_access_required` when the key's name access is `first` or `none`.\n- `422` with `code: project_anonymizes_names` when the project anonymizes participant names (`anonymize_participant_names` is `true`), because the stored name is an alias.\n\nA request that also carries `status` writes nothing when the rename is refused. An empty body returns `422`, and so do `payment_method`, `amount_cents` or `reason` sent without `status`.\n\n### Moving a Participant Between Quotas\n\n`quota_ids` on the same `PATCH` replaces the quotas a participant belongs to. Send the whole list, not a change: read `quota_ids` from `GET /projects/{project_id}/participants/{participant_id}`, edit it, and send it back.\n\n```bash\ncurl -X PATCH \\\n  \"https://app.strella.io/api/public/v1/projects/cm7c4hp5w0003jm04kx8v2n9r/participants/cm7f2kp9w0008jm04xt5v3n7s\" \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"quota_ids\": [\"cm7a9qk2x0001jv04quota02\"] }'\n```\n\n- A quota left out of the list loses the participant. `[]` removes them from every quota.\n- A participant whose interview counts carries that completion into each quota you add them to, and removing them from a quota releases it. Someone who has not finished fills no slot.\n- Screener criteria are not checked. This is the same override a researcher makes from the participant table in Strella.\n- With quota groups turned on, the list holds at most one quota per group, plus at most one ungrouped quota. With them turned off, it holds at most one quota. Otherwise the request returns `422`.\n- An id that is not a live quota on the project returns `404` with `code: quota_not_found`.\n\nA refused `quota_ids` writes nothing, including the other fields in the same body. Quota counts from `GET /projects/{project_id}/quotas` reflect the move on the next read.\n\n### Checking Results\n\nAfter accepting a participant, the `ParticipantDetail` response includes a `payment` object:\n\n```json\n{\n  \"payment\": {\n    \"method\": \"credits\",\n    \"amount_cents\": 5000\n  }\n}\n```\n\n`payment` is `null` for participants who have not been accepted or whose acceptance did not involve an incentive payment.\n\nAfter rejecting a participant, the response includes:\n\n```json\n{\n  \"rejection_reason\": \"Did not meet screening criteria\",\n  \"rejected_by\": {\n    \"type\": \"api_key\",\n    \"id\": \"ak_your_key_here\",\n    \"label\": \"Quality Review Bot\"\n  }\n}\n```\n\n`rejected_by.type` is `\"user\"` when a human performed the rejection through the app, or `\"api_key\"` when an API key performed it. This lets you distinguish automated rejections from manual ones in your audit trail.\n\n### Reading Custom Fields\n\nThe participant detail endpoint and each `?detail=full` list item return `custom_fields`: the project's participants table columns, including Tags and AI-coded categories, that hold a value for the participant. The API returns them in the table's column order.\n\n```json\n{\n  \"custom_fields\": [\n    {\n      \"id\": \"custom_8f2c1a7e-4b1d-4c9a-9f0e-2d6b3a1c5e7f\",\n      \"name\": \"Tags\",\n      \"type\": \"multi_select\",\n      \"value\": [\"power_user\"]\n    },\n    {\n      \"id\": \"custom_a91d3e20-6c4f-4f8b-b2a1-7e5d9c0f1b3a\",\n      \"name\": \"Account tier\",\n      \"type\": \"single_select\",\n      \"value\": \"enterprise\"\n    }\n  ]\n}\n```\n\n| Field   | Type                 | Description                                                                                                  |\n| ------- | -------------------- | ------------------------------------------------------------------------------------------------------------ |\n| `id`    | string               | The field's id. It stays the same when a Researcher renames the column, so match on `id` rather than `name`. |\n| `name`  | string               | The column name shown in the participants table.                                                             |\n| `type`  | string               | `text`, `single_select` or `multi_select`.                                                                   |\n| `value` | string or string\\[\\] | The text entered, or the chosen option's stored value. `multi_select` returns an array that is never empty.  |\n\nA select value is the option's stored value, not its display label, so renaming an option's label does not change what the API returns. The API leaves out a field the participant holds no value in, so `[]` means the participant holds no values.\n\nThe API returns custom fields whatever your personally identifiable information (PII) permissions, described in PII Considerations below. To leave them out of a detail response, pass `?exclude=custom_fields`.\n\nTo read every field on the project, including fields no participant has filled in and each select field's options, read the project. `GET /projects/{project_id}` returns them as `custom_fields`, in column order:\n\n```json\n{\n  \"custom_fields\": [\n    {\n      \"id\": \"custom_a91d3e20-6c4f-4f8b-b2a1-7e5d9c0f1b3a\",\n      \"name\": \"Account tier\",\n      \"type\": \"single_select\",\n      \"options\": [\n        { \"value\": \"enterprise\", \"label\": \"Enterprise\" },\n        { \"value\": \"self_serve\", \"label\": \"Self serve\" }\n      ],\n      \"is_match_key\": false\n    }\n  ]\n}\n```\n\nThe `id` matches `custom_fields[].id` on a participant, and a participant's select value matches an option's `value`, so map values to labels with this list. `options` is `null` for `text` fields. Reading the project needs the `projects:read` scope.\n\n### Managing Custom Fields\n\nWith the `projects:write` scope you can add, change, reorder and delete the project's fields. Read the project, edit its `custom_fields` list, and send the whole list back as `custom_fields` on `PATCH /projects/{project_id}`.\n\n```bash\ncurl -X PATCH \"https://app.strella.io/api/public/v1/projects/{project_id}\" \\\n  -H \"Authorization: Bearer $PUBLIC_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"custom_fields\": [\n        {\"id\": \"custom_a91d3e20-6c4f-4f8b-b2a1-7e5d9c0f1b3a\", \"name\": \"Plan\", \"type\": \"single_select\",\n         \"options\": [{\"value\": \"enterprise\", \"label\": \"Enterprise plan\"}, {\"label\": \"Self serve\"}]},\n        {\"name\": \"Region\", \"type\": \"text\"}\n      ]}'\n```\n\nThe response is the project, whose `custom_fields` holds the new fields' ids and each option's stored `value` (`self_serve`).\n\nThe API compares the list you send with the project's stored fields and applies each difference:\n\n| In the list you send     | Result                                                                                                                                                                                                                                                                                                                                          |\n| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| An entry with an `id`    | Changes that field to the `name`, `type` and `options` you send. Participants keep their values.                                                                                                                                                                                                                                                |\n| An entry without an `id` | Adds a field. No participant holds a value in it yet.                                                                                                                                                                                                                                                                                           |\n| A field you leave out    | Deletes the field and every participant's value in it, and Strella cannot restore them. A field change made in Strella clears the cached project read, but a Researcher can still add a field between your read and your write. So read the project right before you send the list, and check that the list keeps every field you mean to keep. |\n| The order of the entries | Becomes the column order.                                                                                                                                                                                                                                                                                                                       |\n| `options`                | The whole option list, in display order. To relabel an option without losing the participants who hold it, send its existing `value` with the new `label`. Left out or `null`, the field keeps its options.                                                                                                                                     |\n| `is_match_key: true`     | Makes this text field the participant identifier, which matches a returning participant to their row, and takes the role from any other field. The API returns `409` while two active participants share a value.                                                                                                                               |\n\nNames must differ from each other, ignoring case, or the request returns `409`. An `id` the project does not have returns `422`. When the API refuses the list, it writes nothing else in the request.\n\nThe API does not convert participants' values when a field's `type` changes. A participant read returns such a value in the field's new shape. A list becomes one comma-joined string under `text` or `single_select`, and a string becomes a one-item list under `multi_select`. When you remove an option, the API leaves participants' values as they are. A `multi_select` field named Tags is the field Strella's Tags picker edits. You can edit AI-coded categories, but Strella rebuilds them the next time it re-codes the question.\n\n### Setting Custom Field Values\n\nSend `custom_fields` on `PATCH /projects/{project_id}/participants/{participant_id}`, alone or in the same request that changes the participant's status or name. Each key is a field id. Each value is what the participant holds afterwards.\n\n```json\n{\n  \"custom_fields\": {\n    \"custom_a91d3e20-6c4f-4f8b-b2a1-7e5d9c0f1b3a\": \"enterprise\",\n    \"custom_8f2c1a7e-4b1d-4c9a-9f0e-2d6b3a1c5e7f\": [\"power_user\", \"beta\"],\n    \"custom_c07e5d31-2b9a-4f6e-8d1c-3a7b9e0f2d4c\": null\n  }\n}\n```\n\n| Field type      | Value                                                        |\n| --------------- | ------------------------------------------------------------ |\n| `text`          | A string                                                     |\n| `single_select` | One option `value`                                           |\n| `multi_select`  | The full list of option `value`s the participant should hold |\n| any             | `null` clears the field                                      |\n\nFields you leave out keep their values. A value must be one of the field's option `value`s, so add a new option to the project's `custom_fields` list before you set it. An unknown field id, a value that is not an option, or the wrong shape returns `422` with a `code` naming the problem. A participant identifier value another participant already holds returns `409`. The API checks the values before it changes anything else, so a refused value leaves the participant as it was.\n\nTo tag a participant, set the Tags field's values the same way.\n\n### Polling for In-Review Participants\n\nTo find participants who need a decision, filter the participant list by status:\n\n```\nGET https://app.strella.io/api/public/v1/projects/{project_id}/participants?status=in_review&sort=updated_at:desc\n```\n\nTo find the projects with participants waiting for review, filter the project list first:\n\n```\nGET https://app.strella.io/api/public/v1/projects?min_in_review_count=1\n```\n\nThis returns only projects with at least one `in_review` participant, so you can skip projects that need no action. See the Polling for Changes guide for recommended intervals and backoff strategies.\n\n### PII Considerations\n\nHow much of a participant's personal information you receive depends on your PII permissions. Three fields are controlled this way: the participant's name, their email, and their profile picture. Each is granted on its own, so you might be allowed a first name and an email but no picture, or an email but no name at all.\n\nEvery participant detail response reports the permissions that were applied, so you never have to guess what you are allowed to see.\n\n#### Per-field permissions: `pii_acl`\n\n`pii_acl` is the authoritative record of what you were granted, one entry per personal field:\n\n| Field                 | Value                           | When granted, you receive |\n| --------------------- | ------------------------------- | ------------------------- |\n| `name`                | `\"full\"` / `\"first\"` / `\"none\"` | Full or first name        |\n| `email`               | `true` / `false`                | Email address (or `null`) |\n| `profile_picture_url` | `true` / `false`                | Picture URL (or `null`)   |\n\nA personal field appears in the response only when its `pii_acl` entry grants it. A field you are not permitted to see is left out of the response, not returned as `null`.\n\nFor example, a caller granted a first name and an email but no picture receives:\n\n```json\n{\n  \"name\": \"Jane\",\n  \"email\": \"jane@example.com\",\n  \"pii_acl\": {\n    \"name\": \"first\",\n    \"email\": true,\n    \"profile_picture_url\": false\n  },\n  \"pii_level\": \"first-name-only\"\n}\n```\n\n`name` is the first name only, `email` is present, and `profile_picture_url` is absent.\n\n#### Single-label summary: `pii_level` (deprecated)\n\n`pii_level` is deprecated. Read `pii_acl` instead.\n\n`pii_level` reduces the three permissions to one label, and returns the same value for a given grant as it always has, so integrations written before per-field permissions existed keep working. It rounds down: it reports `\"full\"` only when all three fields are visible, so any partial grant reads `\"first-name-only\"`. Because it is a single label, it cannot tell you which fields you actually received. Read `pii_acl` in a new integration.\n\nThe matrix below reads the fields as columns. Each row is an example grant; the last column is the `pii_level` it produces. The two middle rows are different grants that both report `\"first-name-only\"`, so the label cannot tell you which fields to expect:\n\n| Example grant        | `pii_level`         | `name`     | `email` | `profile_picture_url` |\n| -------------------- | ------------------- | ---------- | ------- | --------------------- |\n| Full identity        | `\"full\"`            | Full name  | Yes     | Yes                   |\n| First name and email | `\"first-name-only\"` | First name | Yes     | No                    |\n| First name only      | `\"first-name-only\"` | First name | No      | No                    |\n| No identity          | `\"none\"`            | No         | No      | No                    |\n\n#### Finding the permission your key has\n\nThere are two ways to know what you are allowed to see:\n\n1. Read `pii_acl` on any participant detail response. It always reflects the permissions applied to that call.\n2. For an API key, the permission comes from the key's scopes. A key with `participants:read` alone receives no personal information: every `pii_acl` entry is off and `pii_level` is `\"none\"`. Add the `participants:read:pii` scope to receive identity. See the Setting Up Authentication guide for scope configuration.\n\nNote: an API key's identity access is per field. The `participants:read:pii` scope grants full identity; per-field scopes such as `participants:read:email` grant one field each. See the Controlling Participant PII Access guide for the full list. Read `pii_acl` instead of `pii_level`, so your integration handles any per-field combination. Request the PII scope when your workflow needs to match participants against your own records."},{"name":"Building Questionnaires","description":"### Overview\n\nThe questionnaire is the interview script: the questions, tasks, and media the AI moderator follows during a session. We can read the full questionnaire, replace all items at once, or manage items individually via single-item CRUD endpoints.\n\n### Human-Moderated Projects\n\nA project created with `\"moderation_mode\": \"human\"` has no question guide, because a Researcher runs each interview live. Every questionnaire write on it (`PUT /items`, `POST /items`, `PUT /items/{id}`, `PATCH /items/{id}`, `DELETE /items/{id}`) returns `422` and writes nothing.\n\nBuild its screener instead, with `PUT /projects/{project_id}/screener`.\n\n### Reading the Questionnaire\n\n`GET /projects/{project_id}/questionnaire` returns the questionnaire with its full `items` tree. By default it returns the published state. Pass `?include_draft=true` to also receive `draft_items` when unpublished changes exist in the dashboard editor.\n\n```bash\ncurl https://app.strella.io/api/public/v1/projects/proj_abc123/questionnaire \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\nWith draft items:\n\n```bash\ncurl \"https://app.strella.io/api/public/v1/projects/proj_abc123/questionnaire?include_draft=true\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\nThe `draft_items` field appears only when a draft exists and it differs from the published items. If there are no unpublished changes, `draft_items` is absent even when `include_draft=true` is passed.\n\n### Item Types\n\nEach item has an `item_type` that determines its behavior. Leaf types are standalone questions or content. Container types group other items inside them.\n\n| `item_type`       | Kind      | Description                                                                                                                                                                                                                                                                                                                                                                                                                                          |\n| ----------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `question`        | Leaf      | Open-ended question. The AI moderator probes for depth.                                                                                                                                                                                                                                                                                                                                                                                              |\n| `task`            | Leaf      | Participant-action prompt (e.g., \"Try to complete a checkout\").                                                                                                                                                                                                                                                                                                                                                                                      |\n| `statement`       | Leaf      | Read-aloud statement with no expected response.                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `multiple_choice` | Leaf      | Choice question. Requires at least 2 `choices`. Supports `multi_select`, `randomize`, and selection bounds.                                                                                                                                                                                                                                                                                                                                          |\n| `ranking`         | Leaf      | Drag-to-rank question. Requires at least 2 `choices`.                                                                                                                                                                                                                                                                                                                                                                                                |\n| `free_response`   | Leaf      | Long-form text response. Supports `character_limit`.                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `slider`          | Leaf      | Numeric scale. Requires `slider_config` with `min_value`, `max_value`, and `step`.                                                                                                                                                                                                                                                                                                                                                                   |\n| `rating_scale`    | Leaf      | Rating question with a visual style. Supports `likert`, `faces`, and `stars` via `rating_style`.                                                                                                                                                                                                                                                                                                                                                     |\n| `matrix`          | Leaf      | Grid question: rows of items, columns of mutually exclusive options. Requires `matrix_rows` (2 to 10) and `matrix_columns` (2 to 7).                                                                                                                                                                                                                                                                                                                 |\n| `nps`             | Leaf      | Fixed 0 to 10 \"how likely are you to recommend\" scale. Optional `nps_style` (`buttons` or `slider`, default `buttons`).                                                                                                                                                                                                                                                                                                                              |\n| `card_sort`       | Leaf      | Card sort: participants sort the labels in `card_sort_cards` into groups. `card_sort_type` is `open` (participants name the groups, the default), `closed` (participants use your `card_sort_groups`) or `hybrid` (your groups plus their own).                                                                                                                                                                                                      |\n| `tree_test`       | Leaf      | Findability task: the participant navigates `tree_test_nodes`, a label hierarchy written as a depth-first flat list, and picks where they would find what `content` asks for. `tree_test_tasks` holds one task with its `correct_node_ids`. Write it with the bulk `PUT /items`: the single-item `PUT` returns `422`.                                                                                                                                |\n| `section_block`   | Container | Groups leaf items under a heading. The only type that accepts `audience_rules`.                                                                                                                                                                                                                                                                                                                                                                      |\n| `media_block`     | Container | Displays media (image, video, embed, website, Figma prototype, or written text) alongside child items. Requires `media_type`. `content` is the media URL, except on a `concept` block, where it is the statement shown on screen, as HTML restricted to `p`, `br`, `b`, `strong`, `i`, `em`, `u`, `s`, `h1`, `h2`, `h3`, `ul`, `ol`, `li`, `blockquote`, `hr`, `a` and sanitized on write. The optional `label` names the block in the guide editor. |\n| `variant_block`   | Container | A/B variant wrapper. Contains only `variant_child` items.                                                                                                                                                                                                                                                                                                                                                                                            |\n| `variant_child`   | Container | One variant branch inside a `variant_block`. Contains leaf items or `media_block` items.                                                                                                                                                                                                                                                                                                                                                             |\n\n### Rating Scales\n\nA `rating_scale` item presents a visual rating widget. The `rating_style` field controls how the scale renders:\n\n| `rating_style` | Rendering                                                                                       | Choices required?                          |\n| -------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------ |\n| `likert`       | Text labels per position (e.g., \"Strongly disagree\" to \"Strongly agree\"). This is the default.  | No; if present, exactly 4, 5, or 7 choices |\n| `faces`        | Five colored emoji buttons ranging from angry (red) to laughing (green). Locked to 5 positions. | No, the system stores its labels           |\n| `stars`        | Five filled stars. Tapping a star fills all preceding stars. Locked to 5 positions.             | No, the system stores its labels           |\n\nChoices are optional on write for every rating style, and the stored item always carries the full label set. For `likert`, omit `choices` to store the standard agreement labels (\"Strongly disagree\" through \"Strongly agree\") on a 5-point scale, or supply 4, 5, or 7 choices to set the scale size and your own labels (an entry with empty `content` is stored as the default label for that position). For `faces` and `stars`, omit the `choices` array and the system stores \"Very bad\" through \"Very good\" or \"1 star\" through \"5 stars\". Reading the item back returns the labels as stored.\n\nRegardless of style, the participant's answer is stored as the selected choice's stored text (the participant sees it translated into the interview language). The style affects only how the scale is presented during the interview.\n\n```json\n{\n  \"item_type\": \"rating_scale\",\n  \"content\": \"How would you rate your onboarding experience?\",\n  \"rating_style\": \"likert\",\n  \"choices\": [\n    { \"content\": \"Strongly disagree\" },\n    { \"content\": \"Disagree\" },\n    { \"content\": \"Neutral\" },\n    { \"content\": \"Agree\" },\n    { \"content\": \"Strongly agree\" }\n  ]\n}\n```\n\n`rating_style` is only accepted on the bulk replacement endpoint (`PUT /questionnaire/items`). Per-item PUT and PATCH do not support this field.\n\n### Matrix and NPS Questions\n\nA `matrix` item presents a grid: rows of items to evaluate, columns of mutually exclusive options, and the participant picks exactly one column per row. `matrix_rows` (2 to 10 entries of `{id?, content}`) and `matrix_columns` (2 to 7 entries) are required; the API rejects both with 422 on any other item type. The API keeps row and column ids across a read and a write. Survey responses refer to rows and columns by those ids, so resend the ids when editing an item that already has answers. The response stores one JSON string per row (`{\"rowId\",\"row\",\"columnId\",\"column\"}`), and `randomize` shuffles row order.\n\n```json\n{\n  \"item_type\": \"matrix\",\n  \"content\": \"How satisfied are you with each area?\",\n  \"matrix_rows\": [{ \"content\": \"Pricing\" }, { \"content\": \"Support\" }],\n  \"matrix_columns\": [\n    { \"content\": \"Unsatisfied\" },\n    { \"content\": \"Neutral\" },\n    { \"content\": \"Satisfied\" }\n  ]\n}\n```\n\nAn `nps` item asks the standard 0 to 10 \"how likely are you to recommend\" question and stores the picked score as a single string (`\"0\"` to `\"10\"`). The optional `nps_style` (`buttons` or `slider`, default `buttons`) controls only how the scale renders; the API rejects it with 422 on any other item type. Both types are ordinary leaves: they place like any question, support the dynamic follow-up fields, and cannot carry `children`.\n\n### Selection Bounds on Multiple Choice\n\n`multiple_choice` items with `multi_select: true` support `min_selections` and `max_selections` to limit how many choices a participant can select. The interview screen enforces both bounds: participants cannot continue below the minimum and cannot select beyond the maximum.\n\nBoth fields are optional positive integers. When omitted, there is no bound.\n\n```json\n{\n  \"item_type\": \"multiple_choice\",\n  \"content\": \"Which features do you use regularly?\",\n  \"multi_select\": true,\n  \"min_selections\": 2,\n  \"max_selections\": 5,\n  \"choices\": [\n    { \"content\": \"Dashboard\" },\n    { \"content\": \"Reports\" },\n    { \"content\": \"API\" },\n    { \"content\": \"Notifications\" },\n    { \"content\": \"Integrations\" },\n    { \"content\": \"Settings\" }\n  ]\n}\n```\n\nValidation rules:\n\n- `min_selections` must be less than or equal to `max_selections`\n- Neither value can exceed the number of choices that are not exclusive\n\nThese fields are only accepted on the bulk replacement endpoint (`PUT /questionnaire/items`). Per-item PUT and PATCH do not support them.\n\n### Exclusive Options on Multiple Choice\n\nSet `exclusive: true` on a choice of a `multiple_choice` item for an answer that cannot be combined with the others, such as \"None of the above\" or \"Prefer not to say\". When a participant picks it, every other pick is cleared; picking any other option clears it. An exclusive pick alone completes the question, whatever `min_selections` says.\n\n```json\n{\n  \"item_type\": \"multiple_choice\",\n  \"content\": \"Which of these do you use to pay online?\",\n  \"multi_select\": true,\n  \"choices\": [\n    { \"content\": \"Credit card\" },\n    { \"content\": \"Pix\" },\n    { \"content\": \"None of the above\", \"exclusive\": true }\n  ]\n}\n```\n\nExclusive options are always listed last, after the \"Other\" option, and are never shuffled by `randomize`.\n\nAn exclusive option only works on a multi-select question, so Strella turns `multi_select` on when a request marks an option exclusive on a single-select `multiple_choice` item. The exception is a request that turns `multi_select` off: `false` on a new item, `false` in a `PATCH`, or `false` on an item stored with `multi_select: true`. A bulk `PUT` that sends back the `false` it read from a single-select item does not turn it off. When a request turns `multi_select` off, or the item is not `multiple_choice`, Strella drops the flag and keeps the option as an ordinary choice.\n\n### Image Blocks\n\nA `media_block` with `media_type: \"image\"` shows an image your organization uploaded in Strella. Strella's API has no upload endpoint, so upload the image in the guide builder in Strella, then reuse the address you read back.\n\nThe optional `image_fit` sets how the image fits the participant's screen, as **Scale image** does in the guide builder:\n\n| `image_fit` | What the participant sees                                            |\n| ----------- | -------------------------------------------------------------------- |\n| `contain`   | The whole image fits the screen. The default                         |\n| `fit-width` | The image spans the screen width and the participant scrolls down it |\n\nUse `fit-width` for a tall full-page mockup, which `contain` shrinks to a thin strip. A read returns `image_fit` only on a `fit-width` block: `contain` is the default, so a read leaves it off.\n\nOn a write, an image block's `content` must be one of these:\n\n- an address the questionnaire already holds, which the API saves unchanged\n- a Strella upload from one of your organization's projects that is not deleted, or an organization-level upload of your organization\n- empty, so the guide can carry the image step and its questions before the image exists, until someone uploads the image in the guide builder\n\nThe API returns `422` for any other address, with a `code`:\n\n| `code`                  | Meaning                                                              |\n| ----------------------- | -------------------------------------------------------------------- |\n| `hosted_elsewhere`      | A link to an image on another site                                   |\n| `not_this_organization` | A Strella address another organization owns, or one no longer served |\n\nBranch on `code`, not on the `detail` text. To show a web page that allows framing, use `media_type: \"embed\"`.\n\n### Embed and Figma Blocks\n\nA `figma` block takes any Figma prototype, design, board, slides or deck link. Strella stores and returns it in its `embed.figma.com` form. The `scaling` query value of the link sets how the prototype fits the participant's screen:\n\n| `scaling`    | Guide builder label            |\n| ------------ | ------------------------------ |\n| `contain`    | Fit width and height (default) |\n| `fit-width`  | Fit width                      |\n| `scale-down` | Fill screen                    |\n| `min-zoom`   | Actual size (100%)             |\n| `free`       | Responsive                     |\n\nStrella stores any other value as `contain`, with no error.\n\nTwo item settings apply to the items inside an `embed` or `figma` block:\n\n- `block_media_interaction: true` covers the embed while the item is asked, so the participant answers before clicking around.\n- `media_redirect_url` reloads the embed at another page when the interview reaches the item, for example to start a question on a checkout screen. Later items keep showing that page until another item sets one. Inside a `figma` block it must be a Figma link, and the API refuses it once a Researcher imports a prototype for the block in Strella, because the imported prototype controls navigation.\n\nSet both on the child item, not on the block. Anywhere else, the API returns `422` for `block_media_interaction: true` or a non-empty `media_redirect_url`.\n\n#### Figma Task Settings\n\nA Researcher can import a `figma` block's prototype in the Strella guide builder, which needs their Figma sign-in. The API cannot import one. Every read returns `figma_prototype` on a `figma` block: the prototype's `name` and its `screens`, or `null` when none is imported.\n\n```json\n{\n  \"id\": \"mb_1\",\n  \"item_type\": \"media_block\",\n  \"media_type\": \"figma\",\n  \"content\": \"https://embed.figma.com/proto/abc123/Checkout?node-id=11-1&embed-host=Strella&footer=false&viewport-controls=true&scaling=contain\",\n  \"figma_prototype\": {\n    \"name\": \"Checkout flow\",\n    \"screens\": [\n      { \"id\": \"11:1\", \"name\": \"Home\" },\n      { \"id\": \"11:7\", \"name\": \"Checkout\" }\n    ]\n  },\n  \"children\": [\n    {\n      \"id\": \"t_1\",\n      \"item_type\": \"task\",\n      \"content\": \"Find a way to pay for your basket.\",\n      \"figma_task_type\": \"goal_based\",\n      \"figma_start_screen\": \"11:1\",\n      \"figma_goal_screen\": \"11:7\"\n    }\n  ]\n}\n```\n\nThree fields apply to a `task` inside a block with an imported prototype:\n\n- `figma_task_type`: `free_explore` lets the participant click around, and `goal_based` ends the task when the participant reaches the goal screen. Strella stores `free_explore` on a task there with no type, as the guide editor does.\n- `figma_start_screen`: the screen the prototype opens on for the task.\n- `figma_goal_screen`: the screen that ends a `goal_based` task. Set one on every `goal_based` task, since the task does not end on its own without it.\n\nSend a screen `id` from `figma_prototype.screens`, never its name. Send `\"\"` to clear a screen. The API returns `422` for these fields on any other item, for a screen the prototype does not have, and for a goal screen on a `free_explore` task. Reads leave out a screen the prototype no longer has and a goal screen on a `free_explore` task, so a read you send back does not return `422`.\n\n### Advanced Question Settings\n\nThree item fields are the guide editor's **Advanced** toggles on a question:\n\n- `blur_screen_recording` (**Blur recording for privacy**): the recording blurs the participant's screen share while the item is asked. Use it for a sensitive question. To blur the whole interview, use the project's `blur_participant_screen_share` setting instead.\n- `verbatim` (**Ask question verbatim**): the moderator reads the item exactly as written instead of adapting it to what the participant already said. Follow-up questions stay adaptive.\n- `participant_controlled` (**Participant-controlled**): the moderator waits after asking the item until the participant clicks to move on.\n\nSend `blur_screen_recording` and `verbatim` on a question, task, statement or survey item. Send `participant_controlled` on a `question`, `free_response`, `multiple_choice`, `ranking`, `rating_scale` or `slider` item. The API returns `422` for `true` on any other item, and names the field. `false` turns a setting off. Reads return a setting only on an item whose type offers it.\n\n### Bulk Replacement\n\n`PUT /projects/{project_id}/questionnaire/items` replaces the entire items array in a single operation. The questionnaire is published immediately. The first `PUT` also generates the project's `share_url`.\n\nUse a GET-modify-PUT round-trip: read the current items, modify them in your code, and write them back. Keep the `id` values on items that already exist. Removing an ID detaches that item's per-question analysis data from the questionnaire.\n\n```bash\ncurl -X PUT https://app.strella.io/api/public/v1/projects/proj_abc123/questionnaire/items \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"items\": [\n      {\n        \"id\": \"sec_1\",\n        \"item_type\": \"section_block\",\n        \"content\": \"Onboarding Experience\",\n        \"children\": [\n          {\n            \"id\": \"q_1\",\n            \"item_type\": \"question\",\n            \"content\": \"Walk me through your first day.\",\n            \"dynamic_followups\": true,\n            \"follow_up_depth\": 2\n          },\n          {\n            \"id\": \"mc_1\",\n            \"item_type\": \"multiple_choice\",\n            \"content\": \"How satisfied were you overall?\",\n            \"choices\": [\n              { \"content\": \"Very satisfied\" },\n              { \"content\": \"Somewhat satisfied\" },\n              { \"content\": \"Not satisfied\" }\n            ],\n            \"randomize\": true\n          }\n        ]\n      }\n    ]\n  }'\n```\n\nThe `PUT` body replaces the entire tree. Any field you omit is not preserved from the previous state. If a `section_block` item had `audience_rules` and you omit that field in the `PUT` body, the rules are cleared. Some fields are the exception, because Researchers set them in the guide editor before the API exposed them. An omitted `label` keeps a media block's stored label. An omitted `image_fit` keeps an image block's stored fit. Send `contain` to reset it. An omitted `block_media_interaction` keeps an item's stored value while the item stays inside an `embed` or `figma` block. An omitted `media_redirect_url` keeps an item's stored address while the item stays inside an `embed` block, or a `figma` block with no imported prototype. An omitted `blur_screen_recording`, `verbatim` or `participant_controlled` keeps the stored value while the item's type still offers it. An omitted `figma_task_type`, `figma_start_screen` or `figma_goal_screen` keeps a task's stored value while the task stays in the same `figma` block with an imported prototype. Send `\"\"` or `false` to clear them.\n\n### Individual Item CRUD\n\nAll single-item operations use `/projects/{project_id}/questionnaire/items/{item_id}`.\n\n| Method   | Path suffix  | Effect                                                                                                                                       |\n| -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |\n| `POST`   | (none)       | Add an item. Specify `parent_id` (container ID or `null` for root) and optional `position`.                                                  |\n| `GET`    | `/{item_id}` | Retrieve one item with all its properties. Container items include their children.                                                           |\n| `PUT`    | `/{item_id}` | Replace an item's fields. `item_type` is required. Children are preserved; use child CRUD to change them.                                    |\n| `PATCH`  | `/{item_id}` | Partial update. Omitted fields are preserved. Pass `null` to clear a nullable field. `item_type` and `children` cannot be changed via PATCH. |\n| `DELETE` | `/{item_id}` | Remove an item. Containers are deleted with all their children. Returns 204 No Content.                                                      |\n\nEvery write publishes the questionnaire immediately. If a Researcher has unsaved edits in the browser at the same time, the last write wins.\n\nA questionnaire may contain at most 500 items (counted across the entire tree, including nested children). Exceeding this limit returns 422.\n\n### Nesting Rules\n\nItems form a tree up to 5 levels deep. The rules below are enforced on every write:\n\n- **Leaf items** (`question`, `task`, `statement`, `multiple_choice`, `ranking`, `free_response`, `slider`, `rating_scale`, `matrix`, `nps`) cannot have children.\n- **`section_block`** can contain leaf items and `media_block` items. It cannot contain another `section_block` or a `variant_child`.\n- **`media_block`** can only be a child of `section_block` or `variant_child`. It can only contain leaf items.\n- **`variant_block`** can only contain `variant_child` items.\n- **`variant_child`** can only contain leaf items and `media_block` items. It cannot contain `section_block`, `variant_block`, or another `variant_child`.\n\nViolations return 422 with a description of the specific rule that was broken.\n\n### Which Variant a Participant Saw\n\nEach participant read (`GET /projects/{project_id}/participants`, with or without `?detail=full`, and `GET /participants/{participant_id}`) returns `variant_assignments`, one single-key object per `variant_block`: the key is the `variant_block` item id and the value lists the assigned `variant_child` item ids.\n\n```json\n\"variant_assignments\": [\n  { \"vb_pricing\": [\"vc_monthly\"] },\n  { \"vb_concepts\": [\"vc_x\", \"vc_z\"] }\n]\n```\n\nA block lists more than one id when its `variant_subset_size` is above 1. The array is `[]` until the participant starts the interview. An assignment does not prove the participant reached the block, so check the transcript before treating an arm as seen.\n\n### Audience Rules\n\n`audience_rules` on a `section_block` item controls whether that section is shown to a participant during an interview. Rules use Disjunctive Normal Form (DNF), which means OR/AND logic: a participant sees the section if they match any one filter group (OR), and within each group all filters must match (AND).\n\nWe support two question sources for filters:\n\n- `screener`: a pre-interview qualifying question. Reference it by question text (matching the `screener_question` field on a quota).\n- `in_call_survey`: a `multiple_choice` item answered earlier in the same questionnaire. Reference it by the item's `id`.\n\n`audience_rules` is writable only on `section_block` items. To show a `media_block` or `variant_block` only to participants whose answers match a rule, wrap it in a `section_block` and apply the rule there.\n\nOn read, `audience_rules` may be `null` (no rules configured) or absent (leaf item). On read, the API leaves out of `audience_rules` any legacy editor rule that uses an unsupported operator or source, without an error; the deprecated `conditions` field preserves them. Compare `audience_rules` against `conditions` before a GET-modify-PUT round-trip if you need to detect dropped data.\n\nSee the endpoint reference for the full `audience_rules` schema.\n\n### Unpublished Changes\n\nEvery API write (`PUT /items`, `POST /items`, `PUT /items/{id}`, `PATCH /items/{id}`, `DELETE /items/{id}`) publishes immediately. There is no draft workflow in the API.\n\nA questionnaire write also publishes any screener draft a Researcher saved in Strella, so the project's screener matches what Strella shows.\n\nResearchers editing in the web dashboard may have unpublished changes. Pass `?include_draft=true` on `GET /questionnaire` to see those changes in a separate `draft_items` field. The `has_unpublished_changes` flag on the project list reports whether the dashboard draft differs from the published questionnaire. Check it before an API write overwrites the questionnaire. Strella caches both for up to 5 minutes. Saving a draft or publishing in Strella clears both, so a read made after the save returns the Researcher's latest edits."},{"name":"Building Screeners","description":"### Overview\n\nA screener is a set of qualifying questions a Participant answers before the interview begins. Their answers decide whether they continue to the interview or stop at the screener. Reading and writing the screener requires the `projects:read` scope for reads and `projects:write` for writes.\n\n### Reading the Screener\n\n`GET /projects/{project_id}/screener` returns all screener questions with their answer options and qualification rules, sorted by position.\n\nPass `?include_draft=true` to include an `is_draft` boolean on each question, indicating whether the question is unpublished.\n\n```bash\ncurl https://app.strella.io/api/public/v1/projects/proj_abc123/screener \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\nTo include draft status:\n\n```bash\ncurl \"https://app.strella.io/api/public/v1/projects/proj_abc123/screener?include_draft=true\" \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n### Question Types\n\n| Type       | API value          | Description                                          |\n| ---------- | ------------------ | ---------------------------------------------------- |\n| Radio      | `radio`            | Single-select. Participant picks exactly one answer. |\n| Checkbox   | `checkbox`         | Multi-select. Participant picks one or more answers. |\n| Slider     | `slider_scale`     | Numeric slider with configurable min, max, and step. |\n| Short text | `single_line_text` | Free-text, single line.                              |\n| Long text  | `multi_line_text`  | Free-text, multiline.                                |\n| Matrix     | `matrix`           | Rows answered on one shared set of columns.          |\n\nRadio and checkbox questions require at least two answer options. Slider questions require a `slider_config` object with `min_value`, `max_value`, and `step`. Matrix questions require a `matrix` object (see Matrix Questions below). Text questions take no answers or slider config.\n\nThe API stores `slider_config` only on a slider question, and reads return `slider_config: null` on every other type. A `slider_config` sent on a radio, checkbox or text question is accepted and dropped, so a body built from an earlier read still saves.\n\n### Answer Values and Qualification\n\nThe `answer_value` field on each answer controls qualification logic. Its meaning differs by question type.\n\n**Radio (`radio`)**\n\n| `answer_value` | Meaning                                   |\n| -------------- | ----------------------------------------- |\n| `\"1\"`          | Qualify: participant passes this question |\n| `\"2\"`          | Disqualify: participant is filtered out   |\n\nEvery radio question must have at least one answer with `answer_value: \"1\"`. `\"3\"` is not valid for radio questions.\n\n**Checkbox (`checkbox`)**\n\n| `answer_value` | Meaning                                                       |\n| -------------- | ------------------------------------------------------------- |\n| `\"1\"`          | May select: selecting this answer is allowed                  |\n| `\"2\"`          | Must select: participant must select this answer to qualify   |\n| `\"3\"`          | Disqualify: selecting this answer filters the participant out |\n\nEvery checkbox question must have at least one answer with `answer_value: \"1\"` or `\"2\"`.\n\n**Slider and text questions**\n\nSlider and text questions do not carry `answer_value`. These types do not qualify or disqualify: every response passes.\n\n**Other option**\n\nRadio and checkbox questions can include a free-text \"Other\" option via `include_other_option: true`. Set `other_option_answer_value` to the same values as regular answers (`\"1\"` or `\"2\"` for radio, `\"1\"`, `\"2\"`, or `\"3\"` for checkbox).\n\nThe API stores the Other option only on radio and checkbox questions. On slider and text questions it drops `include_other_option` and `other_option_answer_value`, and reads return `include_other_option: false` with `other_option_answer_value: \"1\"`. A matrix question sent with an Other option returns `422`.\n\n### Matrix Questions\n\nA matrix asks one question across several rows that share an answer scale, such as \"How often do you use each of these tools?\" with rows Slack and Zoom and columns Daily, Weekly and Never. Each row qualifies on its own columns, and the question qualifies when every row passes, or when any one row does.\n\nA matrix question has no `answers` and no \"Other\" option. Its grid goes in `matrix`:\n\n| Field            | Value                                                                                        |\n| ---------------- | -------------------------------------------------------------------------------------------- |\n| `selection_mode` | `\"single\"` (one column per row, the default) or `\"multiple\"` (any number of columns per row) |\n| `qualify_when`   | `\"all_rows\"` (every row must pass, the default) or `\"any_row\"` (one passing row is enough)   |\n| `columns`        | 2 to 7 `{ id, text }` answer columns                                                         |\n| `rows`           | 2 to 10 `{ id, text, cells }` rows                                                           |\n\nRow labels must be unique, and so must column labels (both compared case-insensitively). Every label is required and at most 300 characters.\n\n`cells` maps a column's `text` to how picking that cell counts, using the same codes as radio and checkbox answers:\n\n| Selection mode | `\"1\"`      | `\"2\"`       | `\"3\"`      |\n| -------------- | ---------- | ----------- | ---------- |\n| `single`       | Qualify    | Disqualify  | Not valid  |\n| `multiple`     | May select | Must select | Disqualify |\n\nA column you leave out of `cells` counts as `\"1\"`. On single select, a row passes when the participant picks a qualifying column, so every row needs at least one `\"1\"`, or the write returns `422`. On multiple selection, a row passes when every must-select column is picked and no disqualifying one is.\n\n`GET` always returns every column in `cells`, and returns `matrix: null` on every other question type.\n\n**Row and column IDs.** `GET` returns an `id` on every row and column. Echo them on `PUT` to keep a row or column through a rename, so routing rules and analysis stay attached to it. A row or column sent without an `id`, or with an `id` this question does not have, gets a new one.\n\n```bash\ncurl -X PUT https://app.strella.io/api/public/v1/projects/proj_abc123/screener \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"questions\": [\n      {\n        \"text\": \"How often do you use each of these tools?\",\n        \"question_type\": \"matrix\",\n        \"is_required\": true,\n        \"matrix\": {\n          \"selection_mode\": \"single\",\n          \"qualify_when\": \"all_rows\",\n          \"columns\": [{ \"text\": \"Daily\" }, { \"text\": \"Weekly\" }, { \"text\": \"Never\" }],\n          \"rows\": [\n            { \"text\": \"Slack\", \"cells\": { \"Never\": \"2\" } },\n            { \"text\": \"Zoom\", \"cells\": { \"Never\": \"2\" } }\n          ]\n        }\n      }\n    ]\n  }'\n```\n\nQuotas and questionnaire `audience_rules` name a matrix cell as `\"Row: Column\"`, for example `\"Slack: Daily\"`.\n\n### Replacing the Screener\n\n`PUT /projects/{project_id}/screener` replaces all screener questions with the body you provide. The change takes effect immediately.\n\nA screener draft that a Researcher saved in Strella does not survive the write: Strella shows the questions you sent.\n\nOn a human-moderated project the write saves the screener but does not take the project live. The project goes live when the Researcher sets availability and publishes it in Strella.\n\n`is_required` defaults to `true`, the same as a question added in Strella, so a participant must answer the question to continue. Because the write replaces the whole screener, the default also applies to an existing question you send without the field. Send `is_required: false` on any question participants may skip.\n\nConstraints:\n\n- At least one question is required. To remove all questions, use `DELETE` instead.\n- Maximum 40 questions.\n- Question text: maximum 500 characters.\n- Answer text: maximum 300 characters.\n- The API rejects duplicate question text (case-sensitive after trim) with 422.\n- The API rejects duplicate answer text within a question (case-insensitive) with 422.\n\nThe following example sets two questions: a radio question followed by a checkbox question.\n\n```bash\ncurl -X PUT https://app.strella.io/api/public/v1/projects/proj_abc123/screener \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"questions\": [\n      {\n        \"text\": \"Have you purchased a software product in the last 3 months?\",\n        \"question_type\": \"radio\",\n        \"is_required\": true,\n        \"answers\": [\n          { \"text\": \"Yes\", \"answer_value\": \"1\" },\n          { \"text\": \"No\", \"answer_value\": \"2\" }\n        ]\n      },\n      {\n        \"text\": \"Which categories of software do you use?\",\n        \"question_type\": \"checkbox\",\n        \"is_required\": true,\n        \"answers\": [\n          { \"text\": \"Productivity\", \"answer_value\": \"1\" },\n          { \"text\": \"Security\", \"answer_value\": \"1\" },\n          { \"text\": \"Gaming\", \"answer_value\": \"2\" },\n          { \"text\": \"None of the above\", \"answer_value\": \"3\" }\n        ]\n      }\n    ]\n  }'\n```\n\nA successful response returns `200` with the full updated screener state.\n\n### Stable Question IDs\n\nEach question in the GET response includes an `id` field. When you send a PUT request, include the `id` on any question you want to update in place.\n\n- If the `id` matches an existing question, the API updates that question and keeps its ID.\n- If the `id` is omitted or does not match any existing question, the API creates a new question with a new ID.\n- The API archives every question from the previous screener whose ID does not appear in the PUT body.\n\nScreener responses, skip logic destinations, and quota criteria refer to questions by ID. Omitting the ID on an existing question replaces it with a new one, and those references no longer point at it.\n\nIf your integration manages screener content, store the question IDs returned by GET and echo them back on every PUT. The API does not auto-match by question text.\n\n```bash\ncurl -X PUT https://app.strella.io/api/public/v1/projects/proj_abc123/screener \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"questions\": [\n      {\n        \"id\": \"cm7g3nq8r0007jm04wv5t2k6s\",\n        \"text\": \"Have you purchased a software product in the last 6 months?\",\n        \"question_type\": \"radio\",\n        \"is_required\": true,\n        \"answers\": [\n          { \"text\": \"Yes\", \"answer_value\": \"1\" },\n          { \"text\": \"No\", \"answer_value\": \"2\" }\n        ]\n      }\n    ]\n  }'\n```\n\n### Deleting the Screener\n\n`DELETE /projects/{project_id}/screener` removes all screener questions. It also removes all quotas for the project, since quota criteria depend on screener questions and would be invalid without them. Calling it when no screener exists is not an error: it returns `204` either way. It also discards any screener draft a Researcher saved in Strella.\n\n```bash\ncurl -X DELETE https://app.strella.io/api/public/v1/projects/proj_abc123/screener \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n### Skip Logic\n\nEach question carries two skip logic fields, both settable on `PUT` and returned on `GET`:\n\n- `skip_logic_id`: a stable identifier for this question's position in skip logic destination references. Other questions refer to this ID, not the question's `id`, in their routing rules. Echo it on update to preserve those references; omit it on a new question and the server assigns one.\n- `skip_logic`: the routing configuration. Pass `null` to clear it, or omit the field to preserve a compatible existing rule. A change to the question type, answers, or order that makes the retained rule invalid returns `422`; clear or replace the rule in that same write.\n\nSkip logic comes in four forms, reflected in the `type` discriminant, one per question type:\n\n- `answer_routing` (radio): each answer text maps to a `destination_question_id` (`skip_logic_id` of the destination, or `null` for end of screener).\n- `condition` (checkbox): one or more conditions (`selected` / `not_selected`) combine via a `join` (`and` / `or`) to route to a single `destination_question_id`.\n- `slider_threshold` (slider_scale): one or more numeric thresholds combine via a `join` to route to a single `destination_question_id`. Use `null` to end the screener or an empty string to continue to the next question. This destination `null` differs from `skip_logic: null`, which removes the rule. If the destination question has `skip_logic_id: \"next\"` or `\"end\"`, reference its question `id` instead; those aliases are reserved in slider routing and return `422`. Radio routes can use those aliases.\n- `matrix_condition` (matrix): one or more cell conditions, each `{ row_text, column_text, match }` with `match` of `selected` / `not_selected`, combine via a `join` to route to a single `destination_question_id`. As with slider routing, use `null` to end the screener or an empty string to continue to the next question, and reference a question whose `skip_logic_id` is `\"next\"` or `\"end\"` by its `id`. Every `row_text` and `column_text` must name a row and column of the grid. Strella keeps the rule attached to those cells when they are renamed, and drops a condition whose row or column a write removes.\n\nEvery routing key must name a real answer on the question. `answer_routing` destination keys and `condition` `answer_text` values must match one of the question's `answers[].text` exactly, or the write returns `422` rather than saving a dangling reference. The free-text \"Other\" option is not routable and cannot be a routing key.\n\nEvery question destination must reference a later question in the same screener, by its `skip_logic_id` or question `id`. Self and backward routes return `422`. A destination pointing at a removed question, or at a `skip_logic_id` regenerated because a question's `id` was not echoed on a full replacement, returns `422` instead of silently breaking the route. Echo each question's `id` (or its `skip_logic_id`) on update to keep cross-question routes intact.\n\n### Preventing Broken References\n\nIf you try to remove or rename a screener question that other parts of the project depend on (quota criteria or questionnaire audience rules), the API returns `409 Conflict` rather than break those references. The error `detail` names the affected quotas or questionnaire sections.\n\nUpdate or remove the dependent quotas or audience rules first, then retry the screener write.\n\nFor audience rules on questionnaire sections: clear the affected audience rule on the section before retrying.\n\n```bash\n# Clear audience rules on the blocking section\ncurl -X PATCH https://app.strella.io/api/public/v1/projects/proj_abc123/questionnaire/items/section_id \\\n  -H \"Authorization: Bearer ak_your_key_here\" \\\n  -H \"Content-Type: application/merge-patch+json\" \\\n  -d '{ \"audience_rules\": null }'\n```\n\nNote: the API accepts a change to the answer options on a question that quotas reference. A quota criterion that matches a removed or renamed answer stops matching participants, and the API reports no error. Review affected quotas after changing answers."},{"name":"Managing API Upgrades","description":"Detect and handle deprecated endpoints in Strella's API. No v1 endpoint is deprecated today. When we deprecate one, it carries the signals below for 90 days before we remove it, so build the check into your client now.\n\n---\n\n### Detecting Deprecation\n\nA deprecated endpoint returns these headers in every response. `Sunset` follows RFC 8594.\n\n| Header        | Example                         | Meaning                          |\n| ------------- | ------------------------------- | -------------------------------- |\n| `Deprecation` | `Thu, 14 May 2026 00:00:00 GMT` | Date when deprecation began      |\n| `Sunset`      | `Wed, 12 Aug 2026 00:00:00 GMT` | Date when we remove the endpoint |\n| `Link`        | `<https://...>; rel=\"sunset\"`   | Migration guide URL              |\n\n```bash\ncurl -I https://app.strella.io/api/public/v1/projects/123/questionnaire \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```\nHTTP/2 200\nDeprecation: Thu, 14 May 2026 00:00:00 GMT\nSunset: Wed, 12 Aug 2026 00:00:00 GMT\nLink: <https://app.strella.io/docs/api#tag/Managing-API-Upgrades>; rel=\"sunset\"\n```\n\n### Programmatic Detection\n\nCheck for the `Sunset` header in your HTTP client:\n\n```typescript\nconst response = await fetch(\n  'https://app.strella.io/api/public/v1/projects/123/questionnaire',\n  { headers: { Authorization: 'Bearer ak_your_key_here' } },\n)\n\nconst sunsetDate = response.headers.get('Sunset')\nif (sunsetDate) {\n  console.warn(`Endpoint deprecated, sunset: ${sunsetDate}`)\n  // Log, alert, or trigger migration workflow\n}\n```\n\n```python\nimport requests\n\nresponse = requests.get(\n    \"https://app.strella.io/api/public/v1/projects/123/questionnaire\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n)\n\nsunset = response.headers.get(\"Sunset\")\nif sunset:\n    print(f\"Endpoint deprecated, sunset: {sunset}\")\n```\n\n### Discovery Endpoint\n\nThe discovery endpoint (`GET /api/public/v1/`) lists all available endpoints. Deprecated endpoints appear as objects with sunset metadata instead of plain URL strings:\n\n```json\n{\n  \"api_version\": \"v1\",\n  \"endpoints\": {\n    \"projects\": \"/api/public/v1/projects\",\n    \"questionnaire\": {\n      \"url\": \"/api/public/v1/projects/{project_id}/questionnaire\",\n      \"deprecated\": true,\n      \"sunsetAt\": \"2026-08-12T00:00:00Z\",\n      \"migration\": \"https://app.strella.io/docs/api#tag/Managing-API-Upgrades\"\n    }\n  }\n}\n```\n\n### Timeline\n\n| Phase     | What happens                                   |\n| --------- | ---------------------------------------------- |\n| Day 0     | Deprecation headers start appearing            |\n| Days 1-90 | Both old and new behavior work (sunset window) |\n| Day 91+   | We remove the endpoint                         |\n\n### Best Practices\n\n1. **Monitor headers in production**: log `Sunset` headers to detect deprecations early.\n2. **Check the discovery endpoint**: poll `/api/public/v1/` periodically to find deprecated endpoints.\n3. **Follow migration guides**: open the URL in the `Link` header for the endpoint's migration instructions.\n4. **Test before sunset**: migrate and test well before the sunset date."},{"name":"Controlling Participant PII Access","description":"Participant records carry personal information: name, email, profile picture, demographics, recordings, and survey and screener responses. An API key reads only the fields its scopes grant. Each field requires its own scope, so a key can be limited to exactly what an integration needs.\n\n### How a Key's PII Access Is Decided\n\nA key's PII access comes entirely from its scopes, set when the key is created. There are three cases:\n\n- **No PII field scope**: the key reads non-identity data (recordings, demographics, survey and screener responses) but not name, email, or profile picture. This is the default for keys created before per-field scopes existed, and it is preserved unchanged.\n- **One or more per-field scopes**: the key reads only the fields it names. Adding `participants:read:email` and nothing else yields email and no other personal field.\n- **`participants:read:pii`**: a legacy alias that grants every PII field at once. It is deprecated. Prefer the per-field scopes below; `participants:read:pii` continues to work for existing keys.\n\nAll PII field scopes are additive on top of `participants:read`, and no `:write` scope implies any of them. PII access is always granted explicitly.\n\n### Per-Field Scopes\n\n| Scope                                  | Grants                                 |\n| -------------------------------------- | -------------------------------------- |\n| `participants:read:name:first`         | First name only                        |\n| `participants:read:name:full`          | Full name                              |\n| `participants:read:email`              | Email address                          |\n| `participants:read:recordings`         | Interview recordings                   |\n| `participants:read:gender`             | Gender                                 |\n| `participants:read:age`                | Age                                    |\n| `participants:read:race-ethnicity`     | Race and ethnicity                     |\n| `participants:read:education`          | Education level                        |\n| `participants:read:household-income`   | Household income                       |\n| `participants:read:job-title`          | Job title                              |\n| `participants:read:seniority`          | Seniority                              |\n| `participants:read:company-size`       | Company size                           |\n| `participants:read:city`               | Location                               |\n| `participants:read:survey-responses`   | Survey responses                       |\n| `participants:read:screener-responses` | Screener responses                     |\n| `participants:read:transcript-bodies`  | Transcript text and recording metadata |\n\nThe profile picture has no dedicated scope; it is granted only by the `participants:read:pii` alias.\n\n### How Denied Fields Appear\n\nA denied field is **absent** from the response, not null. The participant record is still returned with the fields the key is allowed to read. A read raises no error for a field a key cannot see: the endpoint succeeds and returns what it is authorized to show. A filter or link that would reveal a hidden field is different, and returns `403` (see below).\n\nFor example, a key with `participants:read` and `participants:read:email` returns:\n\n```json\n{\n  \"id\": \"interviewee_abc123\",\n  \"status\": \"accepted\",\n  \"email\": \"participant@example.com\",\n  \"pii_acl\": { \"name\": \"none\", \"email\": true, \"profile_picture_url\": false }\n}\n```\n\nThe `name` and `profile_picture_url` fields are absent, demographics and recordings are absent, and the `pii_acl` object tells you exactly what the key was allowed to read.\n\n```bash\ncurl https://app.strella.io/api/public/v1/projects/PROJECT_ID/participants/PARTICIPANT_ID \\\n  -H \"Authorization: Bearer ak_your_key_here\"\n```\n\n```typescript\nconst res = await fetch(\n  'https://app.strella.io/api/public/v1/projects/PROJECT_ID/participants/PARTICIPANT_ID',\n  { headers: { Authorization: 'Bearer ak_your_key_here' } },\n)\nconst participant = await res.json()\n// participant.pii_acl describes which identity fields were granted\n// 'email' in participant === true only if the key has participants:read:email\n```\n\n```python\nimport requests\n\nres = requests.get(\n    \"https://app.strella.io/api/public/v1/projects/PROJECT_ID/participants/PARTICIPANT_ID\",\n    headers={\"Authorization\": \"Bearer ak_your_key_here\"},\n)\nparticipant = res.json()\n# 'email' present only if the key has participants:read:email\n```\n\n### The `pii_acl` Response Field\n\nEvery participant detail response includes a `pii_acl` object naming the identity fields the caller was granted (`name`, `email`, `profile_picture_url`). Read it to learn the most a key can read without guessing from which fields happen to be present.\n\nThe older `pii_level` field (`'full'`, `'first-name-only'`, `'none'`) is **deprecated**. It reduces the per-field grants to one value and rounds down, so a key granted only `email` reads `pii_level: 'none'`. Branch on `pii_acl` or on the presence of the specific field, never on `pii_level`.\n\n### Filters and Links That Need Field Access\n\nSome requests would reveal a hidden field through what they return, not through the field itself. The API refuses those with `403` and a `code` naming the access the key lacks, or leaves the revealing value out:\n\n- **Age filter.** `age_min` and `age_max` on `GET /projects/{project_id}/participants` need `participants:read:age`. Without it, the API returns `403` with `code: age_access_required`, on both list shapes.\n- **Name filter.** `name` on the same endpoint matches first names only for a key with `participants:read:name:first`. A key with no name scope gets `403` with `code: name_access_required`.\n- **Quota membership.** A quota names a demographic group, so participant reads need every demographic scope to show it. Without them, participant detail, `?detail=full` and the `PATCH` response return `quota: null` and `quota_ids: []`, and a `PATCH` that sends `quota_ids` returns `403` with `code: demographics_access_required` and writes nothing.\n- **Highlight reel share links.** A reel's `share_url` plays its recordings to anyone who holds it, so it needs `participants:read:recordings`. Without it, highlight reel reads and write responses return `share_url: null`, and `create_share_link: true` returns `403` with `code: recordings_access_required` and writes nothing.\n\n### Project Anonymization Overrides the Email Grant\n\nA project with participant anonymization enabled never returns participant emails from the API, MCP, or exports, regardless of the key's scopes. The `email` field is absent from every participants response for that project, even for a key holding `participants:read:email`; participant names are already stored as anonymized aliases, so `name` still appears.\n\nThe responses say why. Every participants list response carries a top-level `anonymize_participant_names` boolean, and the participant detail carries the same field next to `pii_acl`. `pii_acl` keeps reporting the access the key was _granted_, so `\"pii_acl\": { \"email\": true }` alongside `\"anonymize_participant_names\": true` means the key holds the email grant and the project setting withholds the email. The field also appears on every `GET /projects` list item and project detail, so the state is visible as project metadata before fetching participants.\n\n### Human Access Through AI Assistants\n\nAn integration reads participant data in one of two ways: with an API key, or as a person using an AI assistant through the MCP connector. A per-field grant controls both, but for a person an admin sets the grant per teammate instead of through scopes on a key. A teammate cannot raise their own access, and there is no API or MCP endpoint that changes access. See [Participant data access](/docs/product/account/participant-data-access) for the admin side.\n\n### Recommended Practice\n\nGrant each key only the fields its integration needs. An integration that matches participants to a CRM by email needs `participants:read` and `participants:read:email`, nothing more. Add scopes incrementally; an absent field is a missing scope, not an error, so confirm the `pii_acl` object shows what you expect."},{"name":"Logging and Auditing API Access","description":"Strella's API reads and manages your research data. It does not accept logs from you, and it does not return a record of who viewed your data. Strella keeps its own log of API usage, for its own security and operations. Your admins can read a separate audit log of actions in your organization inside the app. This guide lists where each record is kept, so you can answer a security review from one page.\n\n### Sending Logs to Strella\n\nYou cannot send your security logs to Strella through the API. No endpoint accepts log or monitoring data from you.\n\nThe API reads and manages research data: projects and workspaces, participants and their transcripts, survey and screener responses, questionnaires and screeners, recruitment orders, quotas, and credits. The full list is in the [API reference](/docs/api). None of those endpoints take in security or audit data.\n\n### Auditing Data Access\n\nThere is no API call that returns a log of who accessed what. Strella keeps two different records that are both called an audit log.\n\nThe first is Strella's own record of API usage. Every authenticated request is logged for Strella's security and operations, capturing the method and path, the organization, the key or token that made the call, the response status, and how long it took. This record is internal. It is not available as an endpoint, a feed, or an export, and your integration cannot read it back.\n\nThe second is your organization's audit log. It records what people in your organization do, including who creates or revokes an API key, changes a setting, makes a share link or imports participants. Your admins review it inside the app under Settings → Advanced, not through the API. See [Audit logs](/docs/product/account/audit-logs) for the full list of what it records.\n\nThe access record a security review usually asks for is this organization audit log, and it records when your API keys are created and revoked. An admin reads it in the app, not through an API call.\n\n### Webhooks\n\nStrella's API does not call out to a URL when something changes, and it does not accept events you push to it. Instead, poll the relevant endpoints on a schedule to find new data. The Polling for Changes guide covers how often to check and how to page through what is new.\n\n### Summary\n\nA security review can quote this directly: none of the capabilities below are available through the API, and the table lists where each one is kept instead.\n\n| Capability                             | Available through the API? | Where it lives                                               |\n| -------------------------------------- | -------------------------- | ------------------------------------------------------------ |\n| Send security logs to Strella          | No                         | Not supported                                                |\n| Retrieve a record of who accessed data | No                         | Organization audit log, in the app under Settings → Advanced |\n| Strella's internal record of API usage | No                         | Internal to Strella; not exposed                             |\n| Event webhooks                         | No                         | Use polling instead                                          |"},{"name":"Managing Share Links","description":"Share links are the participant-facing URLs you hand out to recruit people directly (outside a panel). A project can have several links, each with its own settings, so you can run parallel outreach and track where participants came from. Each link inherits its settings from the project unless you override them on that link.\n\nRequired scope: `projects:read` for reads, `projects:write` for mutations.\n\n### Reads Always Return Resolved Settings\n\nEach per-link setting is stored as an override that may be empty, meaning \"inherit the project value.\" Reads never expose that empty state: `GET` responses always return the **effective** setting after applying the fallback, so you always see what a participant on that link actually experiences. To make a link inherit again, send `null` for that field on `PATCH` (see below).\n\n### Listing Share Links\n\n`GET /projects/{project_id}/share-links` returns the project's links, cursor-paginated:\n\n```json\n{\n  \"share_links\": [\n    {\n      \"id\": \"b7c1e2f3-4a5b-6c7d-8e9f-0a1b2c3d4e5f\",\n      \"name\": \"LinkedIn outreach\",\n      \"url_slug\": \"linkedin-outreach\",\n      \"share_url\": \"https://app.strella.io/onboard/linkedin-outreach-1a2b3c4d\",\n      \"status\": \"active\",\n      \"participant_limit_enabled\": true,\n      \"participant_limit\": 50,\n      \"require_screener\": true,\n      \"auto_invite\": false,\n      \"collect_email\": true,\n      \"collect_names\": true,\n      \"first_name_only\": false,\n      \"send_reminder_emails\": false,\n      \"call_finished_redirect_url\": \"https://example.com/thank-you\",\n      \"screenout_redirect_url\": null,\n      \"quota_full_redirect_url\": null,\n      \"call_dropout_redirect_url\": null,\n      \"participant_count\": 12,\n      \"created_at\": \"2026-03-20T12:00:00.000Z\",\n      \"updated_at\": \"2026-03-21T10:15:00.000Z\"\n    }\n  ],\n  \"meta\": { \"has_more\": false, \"next_cursor\": null, \"total\": 1 }\n}\n```\n\nGive the `share_url` to participants or include it in outreach messages. `participant_count` is how many people have onboarded through that specific link.\n\n`participant_limit_enabled` is `true` only when the link has its own cap. When it is `false`, `participant_limit` holds the project participant target the link inherits, or `null` when the link's limit is turned off. The project target still closes the whole project when it is reached.\n\nPagination follows the standard cursor scheme: pass `?limit=` (1-100, default 50) and, when `meta.has_more` is `true`, `?cursor=` with `meta.next_cursor` to fetch the next page. Sort with `?sort=created_at:desc` (default) or `updated_at`, either direction.\n\n### Creating a Link\n\n`POST /projects/{project_id}/share-links` with a unique `name` (and optionally a `url_slug` that seeds the participant URL):\n\n```json\n{ \"name\": \"Newsletter\", \"url_slug\": \"newsletter\" }\n```\n\nReturns `201` with the created link (settings resolved to the project defaults, since a new link has no overrides yet). A duplicate name returns `409`.\n\n### Overriding Per-Link Settings\n\n`PATCH /projects/{project_id}/share-links/{share_link_id}` uses JSON Merge Patch (RFC 7396):\n\n- Omit a field to leave it unchanged.\n- Send a concrete value to set a per-link override.\n- Send `null` to clear the override so the link inherits the project value again.\n\n```json\n{\n  \"require_screener\": false,\n  \"participant_limit_enabled\": true,\n  \"participant_limit\": 25,\n  \"auto_invite\": null\n}\n```\n\nHere the link stops requiring the screener, caps itself at 25 participants, and reverts `auto_invite` to the project setting. The `auto_invite` here is Strella's internal AI auto-approval (screener passers rated 4+ are auto-invited); it is never the vendor panel's auto-invite.\n\n### Asking for a First Name Only\n\nSend `\"first_name_only\": true` to ask participants on the link for a first name and no last name. The link must collect names, so send `\"collect_names\": true` in the same request when it does not, or the API returns `422`. `first_name_only` takes `true` or `false`, never `null`: it has no project value to inherit. Turning `collect_names` off turns it off, and a read returns `false` for it while the link does not collect names.\n\nThe four redirect fields (`call_finished_redirect_url`, `screenout_redirect_url`, `quota_full_redirect_url`, `call_dropout_redirect_url`) are set here, not on create. Each takes an `http` or `https` URL with a full hostname. A hostname with no dot, such as `https://localhost` or `https://intranet`, returns `422`, the same rule Strella applies when a Researcher saves a redirect URL.\n\nChanging `url_slug` regenerates the participant token, so the previous `share_url` stops resolving.\n\n### Status\n\n`status` is `active` (accepting participants), `disabled` (closed to new participants), or `complete` (reached its participant limit). A read sets a link to `complete` once its resolved limit is reached. Set it explicitly via `PATCH { \"status\": \"disabled\" }`.\n\n### Deleting a Link\n\n`DELETE /projects/{project_id}/share-links/{share_link_id}` soft-deletes the link (`204`). A project must keep at least one live link, so deleting the last one returns `409`."},{"name":"Conventions","description":"### Pagination\n\nAll list endpoints use cursor-based pagination. Every response includes a `meta` object:\n\n```json\n{\n  \"projects\": [...],\n  \"meta\": {\n    \"has_more\": true,\n    \"next_cursor\": \"dGVzdA\",\n    \"total\": 142\n  }\n}\n```\n\n- `has_more`: whether more items exist beyond this page.\n- `next_cursor`: pass as the `cursor` query parameter to fetch the next page. Null on the last page.\n- `total`: total count of items matching the query across all pages.\n\n**Page size**: default 50, max 100 (enforced at the schema level). Transcripts default to 500 utterances per page, max 2000.\n\n**List envelope**: the array key is the resource name (`projects`, `participants`, `transactions`, etc.), not a generic `data` key.\n\n### Errors\n\nAll errors follow [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) (Problem Details for HTTP APIs):\n\n```json\n{\n  \"type\": \"insufficient-scope\",\n  \"title\": \"Insufficient Scope\",\n  \"status\": 403,\n  \"detail\": \"The API key does not have the 'projects:write' scope required for this request.\",\n  \"instance\": \"/api/public/v1/projects\"\n}\n```\n\n- `type`: machine-readable error identifier. Use this for programmatic handling.\n- `title`: human-readable label for the error class.\n- `status`: the HTTP status code.\n- `detail`: specific information about this error occurrence.\n- `instance`: the request path where the error occurred.\n\n### Rate Limiting\n\nEvery response includes rate limit headers:\n\n- `X-RateLimit-Limit`: maximum requests allowed in the current window.\n- `X-RateLimit-Remaining`: requests remaining in the current window.\n- `X-RateLimit-Reset`: Unix timestamp when the window resets.\n\nA `429` status means you must stop sending requests until after `X-RateLimit-Reset`.\n\n### Idempotency\n\nWrite endpoints accept an optional `Idempotency-Key` header. If the original request returned a 2xx response, sending the same key within 24 hours returns the stored response without re-executing the operation. Error responses are not stored. See the Error Reference for full details.\n\n### PII\n\nParticipant endpoints return PII fields conditionally based on the API key's scope grants.\n\n**Per-field scopes** (recommended for new keys): use the minimum grant needed. A key minted with **any** per-field scope receives **only** the fields it names — every other axis is withheld.\n- `participants:read:name:first` / `participants:read:name:full` — name (tri-state: none / first / full; `name:full` beats `name:first` when both present)\n- `participants:read:email` — email address\n- `participants:read:recordings` — recording URLs\n- `participants:read:gender` — gender\n- `participants:read:age` — age\n- `participants:read:race-ethnicity` — race / ethnicity\n- `participants:read:education` — education level\n- `participants:read:household-income` — household income\n- `participants:read:job-title` — job title\n- `participants:read:seniority` — seniority\n- `participants:read:company-size` — company size\n- `participants:read:city` — city\n- `participants:read:survey-responses` — survey answer bodies\n- `participants:read:screener-responses` — screener answer bodies\n- `participants:read:transcript-bodies` — transcript body text\n\n**Legacy scope** *(deprecated, still works)*: `participants:read:pii` grants all axes above.\n\n**Legacy keys** with no per-field PII scope keep their original access: identity fields (name, email, picture) are absent, but data fields (demographics, recordings, responses, transcripts) are still returned — exactly as before these scopes existed. The moment a key requests any per-field scope it switches to \"only what you name.\"\n\nPII fields a key may not see are physically absent from the response — no error is raised. The response echoes a `pii_acl` object showing which axes are active."},{"name":"Projects","description":"Research projects and settings"},{"name":"Participants","description":"Participant data and lifecycle"},{"name":"Transcripts","description":"Interview transcript data"},{"name":"Questionnaires","description":"Interview scripts and items"},{"name":"Screeners","description":"Pre-interview qualification"},{"name":"Responses","description":"Survey and screener responses"},{"name":"Recruitment","description":"Recruitment orders and panels"},{"name":"Quotas","description":"Participant segment targeting"},{"name":"Share Links","description":"Participant share links and per-link settings"},{"name":"Credits","description":"Balance and transactions"},{"name":"Workspaces","description":"Project organization"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"API key (`ak_*`) created in your organization's Developer settings, or OAuth 2.0 bearer token (`oat_*`). Pass as `Authorization: Bearer <token>`."}},"schemas":{"ProblemDetails":{"type":"object","properties":{"type":{"type":"string","description":"URI reference identifying the problem type","example":"/problems/rate-limit-exceeded"},"title":{"type":"string","description":"Short human-readable summary","example":"Rate Limit Exceeded"},"status":{"type":"integer","description":"HTTP status code","example":429},"detail":{"type":"string","description":"Human-readable explanation","example":"Rate limit exceeded. Try again later."},"instance":{"type":"string","description":"URI reference for this occurrence"}},"required":["type","title","status","detail"],"x-tags":["Conventions"]},"PaginationQuery":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"cursor":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"}},"x-tags":["Conventions"]},"AudienceSource":{"type":"string","enum":["screener","in_call_survey","quota"],"x-tags":["Questionnaires"]},"ChoiceMatchRule":{"type":"string","enum":["is_one_of","is_not_one_of"],"description":"Match rule for choice answers and quota membership. 'is_one_of' = participant's answer (or assigned quota) is among the listed values. 'is_not_one_of' = none of them.","example":"is_one_of","x-tags":["Questionnaires"]},"NumericMatchRule":{"type":"string","enum":["lt","lte","eq","gte","gt"],"description":"Numeric comparison for slider thresholds. 'lt' = less than, 'lte' = less than or equal, 'eq' = equal, 'gte' = greater than or equal, 'gt' = greater than.","example":"gte","x-tags":["Questionnaires"]},"ChoiceAudienceFilter":{"type":"object","properties":{"source":{"type":"string","enum":["screener","in_call_survey"],"description":"Where the referenced question lives. 'screener' = pre-interview qualifying quiz. 'in_call_survey' = a multiple_choice answered earlier in the same questionnaire.","example":"screener"},"question_id":{"type":"string","minLength":1,"maxLength":512,"description":"Identifier of the referenced question. For 'screener' this is the question's text (matching the `/quotas` `screener_question` field). For 'in_call_survey' this is the element id from a previous read of the questionnaire.","example":"What is your role?"},"kind":{"type":"string","enum":["choice"]},"match_rule":{"type":"string","enum":["is_one_of","is_not_one_of"],"description":"Match rule for choice answers and quota membership. 'is_one_of' = participant's answer (or assigned quota) is among the listed values. 'is_not_one_of' = none of them.","example":"is_one_of"},"values":{"type":"array","items":{"type":"string","minLength":1,"maxLength":512},"minItems":1,"maxItems":50,"description":"Answer texts (screener) or choice texts (in-call) to match. Use the same display values returned in a previous read of the questionnaire / screener. For an NPS question the values are its scores (`0`-`10`) or a segment name (`Promoter`, `Passive`, `Detractor`).","example":["Product Manager","Designer"]}},"required":["source","question_id","kind","match_rule","values"],"description":"Filter on a multiple_choice answer. Matches when the participant's selected option is in (or out, for is_not_one_of) of values[].","x-tags":["Questionnaires"]},"QuotaAudienceFilter":{"type":"object","properties":{"kind":{"type":"string","enum":["quota"]},"match_rule":{"type":"string","enum":["is_one_of","is_not_one_of"],"description":"Match rule for choice answers and quota membership. 'is_one_of' = participant's answer (or assigned quota) is among the listed values. 'is_not_one_of' = none of them.","example":"is_one_of"},"quota_ids":{"type":"array","items":{"type":"string","minLength":1,"maxLength":128},"minItems":1,"maxItems":50,"description":"Quota identifiers (from GET /projects/{projectId}/quotas). Matches when the participant is assigned to (or not, for is_not_one_of) any of these quotas.","example":["cm7a9qk2x0001jv04quota01"]}},"required":["kind","match_rule","quota_ids"],"description":"Filter on quota membership. Use the quota IDs returned by GET /projects/{projectId}/quotas.","x-tags":["Quotas"]},"NumericAudienceFilter":{"type":"object","properties":{"source":{"type":"string","enum":["screener","in_call_survey"],"description":"Where the referenced question lives. 'screener' = pre-interview qualifying quiz. 'in_call_survey' = a multiple_choice answered earlier in the same questionnaire.","example":"screener"},"question_id":{"type":"string","minLength":1,"maxLength":512,"description":"Identifier of the referenced question. For 'screener' this is the question's text (matching the `/quotas` `screener_question` field). For 'in_call_survey' this is the element id from a previous read of the questionnaire.","example":"What is your role?"},"kind":{"type":"string","enum":["numeric"]},"match_rule":{"type":"string","enum":["lt","lte","eq","gte","gt"],"description":"Numeric comparison for slider thresholds. 'lt' = less than, 'lte' = less than or equal, 'eq' = equal, 'gte' = greater than or equal, 'gt' = greater than.","example":"gte"},"value":{"type":"number","description":"Numeric threshold to compare against. Must fall within the referenced slider question's [min_value, max_value] range.","example":7}},"required":["source","question_id","kind","match_rule","value"],"description":"Filter on a numeric answer (a slider, or an NPS question's 0-10 score). Matches when the participant's value compares to `value` per `match_rule`.","x-tags":["Questionnaires"]},"AudienceFilter":{"oneOf":[{"$ref":"#/components/schemas/ChoiceAudienceFilter"},{"$ref":"#/components/schemas/QuotaAudienceFilter"},{"$ref":"#/components/schemas/NumericAudienceFilter"}],"discriminator":{"propertyName":"kind","mapping":{"choice":"#/components/schemas/ChoiceAudienceFilter","quota":"#/components/schemas/QuotaAudienceFilter","numeric":"#/components/schemas/NumericAudienceFilter"}},"description":"A single audience filter. The `kind` discriminator dictates which fields are present.","x-tags":["Questionnaires"]},"AudienceFilterGroup":{"type":"object","properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/AudienceFilter"},"minItems":1,"maxItems":20,"description":"Filters AND-ed within this group."}},"required":["filters"],"description":"A group of filters. Filters within a group are AND-ed together.","x-tags":["Questionnaires"]},"AudienceRules":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/AudienceFilter"},"minItems":1,"maxItems":20,"description":"Filters AND-ed within this group."}},"required":["filters"],"description":"A group of filters. Filters within a group are AND-ed together."},"minItems":1,"maxItems":20,"description":"Groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form — same shape as quota criteria.) At most one group may hold several filters, unless every multi-filter group shares a filter, e.g. [[A, C], [B, C]] for \"(A or B) and C\"; other shapes are rejected."}},"required":["filter_groups"],"description":"Audience rules — gate section visibility on prior screener / in-call survey answers or quota membership. Writable only on section_block items (matches the Question Guide editor). DNF shape: filter_groups OR-ed, filters within a group AND-ed.","x-tags":["Questionnaires"]},"QuestionnaireInputItem":{"oneOf":[{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Stable item identifier. Optional on write. If provided, preserved (round-trip safe). If omitted, a new UUID is generated. For projects with existing interviews, preserve IDs via GET-modify-PUT to avoid orphaning per-question analysis data."},"content":{"type":"string","maxLength":10000,"description":"Question, task, or statement text. Required on every item except an image media_block, which may be empty until its image is uploaded in the guide builder.","example":"What challenges do you face with onboarding?"},"choices":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Choice identifier. Optional on write. If provided, preserved. If omitted, generated."},"content":{"type":"string","description":"Choice text. Required (min 1 char) for multiple_choice and ranking items. Allowed empty on rating_scale, where an empty entry is stored as the style-appropriate default label for that position.","example":"Very satisfied"},"exclusive":{"type":"boolean","description":"Marks an exclusive option, such as \"None of the above\": picking it clears every other pick. On a multiple_choice item it turns multi_select on, unless the same request turns multi_select off. On any other item, or when the request turns multi_select off, the flag is dropped. Exclusive options are listed last."}},"required":["content"]},"description":"Answer choices — required (min 2) for multiple_choice and ranking items. Optional for rating_scale: omitted or empty stores the default labels (5-point likert); send 4, 5, or 7 entries (content may be empty) to pick a scale size, and the stored item always carries the full label set."},"media_type":{"type":"string","enum":["image","embed","website","figma","video","text"],"description":"Media type for media_block containers. Determines how `content` is rendered: image (displayed inline), video (video player), embed (iframe), website (opens in new tab), figma (Figma prototype viewer), text (rich text shown on screen for the participant to read — for this one type `content` is the statement itself, not a URL). For a text block the `content` is HTML restricted to `p`, `br`, `b`, `strong`, `i`, `em`, `u`, `s`, `h1`, `h2`, `h3`, `ul`, `ol`, `li`, `blockquote`, `hr`, `a`; anything else is stripped on write, so read it back to see what was stored. For a figma block, Strella stores `content` in its embed.figma.com form and keeps the `scaling` query value of the link, which sets how the prototype fits the screen: `contain` (Fit width and height, the default), `fit-width` (Fit width), `scale-down` (Fill screen), `min-zoom` (Actual size (100%)) or `free` (Responsive). Strella stores any other value as `contain`, with no error.","example":"image"},"label":{"type":"string","maxLength":10000,"description":"Display label for a media_block, shown in the guide editor tree. `content` holds the URL; this names it. Omit to keep the stored label; send an empty string to clear it. Only valid on media_block items.","example":"Prototype walkthrough"},"watermark":{"type":"boolean","description":"Media_block only (not website). When true, the in-call media is marked with a tiled per-participant watermark, and served images/video are baked with it. Ignored on non-media_block items.","example":true},"image_fit":{"type":"string","enum":["contain","fit-width"],"description":"Image media_block only. How the image fits the participant's screen: `contain` (Fit width and height, the default: the whole image fits the screen) or `fit-width` (Fit width: the image spans the screen width and scrolls vertically). Omit to keep the stored value; send `contain` to reset it. Ignored on other items.","example":"fit-width"},"block_media_interaction":{"type":"boolean","description":"Only valid on an item inside a media_block with media_type embed or figma. When true, the embed or prototype is covered while this item is being asked, so the participant answers before interacting with it. Setting it to true anywhere else is rejected with 422.","example":true},"media_redirect_url":{"anyOf":[{"type":"string","maxLength":2048,"format":"uri"},{"type":"string","enum":[""]}],"description":"Only valid on an item inside a media_block with media_type embed, or figma with no prototype imported in Strella. When the interview reaches this item, the embed or prototype reloads at this http(s) address, and later items keep showing it until another item sets one. Inside a figma block it must be a Figma prototype, design, board, slides or deck link, and Strella stores it in the embed form the block content uses. An empty string clears it. A non-empty value anywhere else is rejected with 422.","example":"https://www.figma.com/proto/abc123/Checkout?node-id=11-7"},"figma_task_type":{"type":"string","enum":["free_explore","goal_based"],"description":"The guide editor's \"Task type\" for a task inside a figma media_block with a prototype imported in Strella: free_explore lets the participant click around, and goal_based ends the task when the participant reaches figma_goal_screen. Strella stores free_explore when a task there has no type. Valid only on a task in such a block: the API rejects it with 422 anywhere else.","example":"goal_based"},"figma_start_screen":{"type":"string","maxLength":128,"description":"The guide editor's \"Start screen\": the screen id the prototype opens on for this task, from the `figma_prototype.screens` of its media_block. Valid only on a task inside a figma media_block with an imported prototype. An empty string clears it.","example":"11:1"},"figma_goal_screen":{"type":"string","maxLength":128,"description":"The guide editor's \"Goal screen\": the screen id that ends a goal_based task when the participant reaches it, from the `figma_prototype.screens` of its media_block. Valid only with figma_task_type goal_based. An empty string clears it.","example":"11:7"},"blur_screen_recording":{"type":"boolean","description":"The guide editor's \"Blur recording for privacy\". When true, Strella blurs the participant's screen share in the recording while this item is asked. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422.","example":true},"verbatim":{"type":"boolean","description":"The guide editor's \"Ask question verbatim\". When true, the moderator reads this item exactly as written instead of adapting it to what the participant already said. Follow-up questions stay adaptive. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422.","example":true},"participant_controlled":{"type":"boolean","description":"The guide editor's \"Participant-controlled\". When true, the moderator waits after asking this item until the participant clicks to move on. Valid on question, free_response, multiple_choice, ranking, rating_scale and slider items. Setting it to true on any other item type is rejected with 422. It has no effect while the project moves to the next question only when the participant clicks, for every question.","example":true},"dynamic_followups":{"type":"boolean","default":false,"description":"Whether the AI moderator generates follow-up questions","example":false},"follow_up_guidance":{"type":"string","maxLength":2000,"description":"Guidance for the AI moderator when generating follow-ups","example":"Probe deeper into workflow pain points"},"follow_up_depth":{"anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]}],"description":"Follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High","example":2},"randomize":{"type":"boolean","description":"Whether to randomize display order (choices for multiple_choice/ranking, cards for card_sort, rows for matrix)","example":false},"multi_select":{"type":"boolean","description":"Allow multiple selections (multiple_choice items)","example":false},"min_selections":{"type":"integer","minimum":1,"description":"Minimum number of selections required (multiple_choice items with multi_select=true). Participants cannot continue until they meet this minimum.","example":2},"max_selections":{"type":"integer","minimum":1,"description":"Maximum number of selections allowed (multiple_choice items with multi_select=true). UI prevents selecting beyond this cap; server-side rejects payloads that exceed it.","example":3},"rating_style":{"type":"string","enum":["likert","faces","stars"],"description":"RatingScale-only display mode. `likert` (default) shows text per position. `faces` shows 5 colored emoji (locks scale size to 5). `stars` shows 5 filled stars. Data shape unchanged regardless of style.","example":"likert"},"nps_style":{"type":"string","enum":["buttons","slider"],"description":"NPS-only display mode for the fixed 0-10 scale. `buttons` (default) shows an 11-button strip the participant taps once. `slider` shows a 0-10 handle the participant drags. Data shape unchanged regardless of style: the answer is the chosen integer either way.","example":"buttons"},"tree_style":{"type":"string","enum":["focused","full"],"description":"TreeTest-only display mode. `focused` (default) drills down: opening a section hides its siblings, so only the path the participant chose stays on screen. `full` keeps every top-level section visible and expands sections in place. Data shape unchanged regardless of style: the recorded navigation events are the same either way.","example":"focused"},"tree_branches_selectable":{"type":"boolean","description":"TreeTest-only. Whether participants may answer with a branch (a node that has children). Default false: branches only open and close, the answer is a leaf, and the correct destination must be a leaf.","example":false},"media_upload_kinds":{"type":"array","items":{"type":"string","enum":["image","video","document"]},"description":"File kinds the participant may upload as their answer (media_upload items). Must contain at least one kind when present; omitting it accepts all kinds.","example":["video","image"]},"card_sort_type":{"type":"string","enum":["open","closed","hybrid"],"description":"Who defines the groups for a card_sort item: participants (`open`), the researcher (`closed`), or both (`hybrid`). Defaults to `open` when omitted.","example":"closed"},"card_sort_cards":{"type":"array","items":{"type":"string"},"description":"Card labels participants sort (card_sort items). Must contain at least one card.","example":["Track an order","Update email address"]},"card_sort_groups":{"type":"array","items":{"type":"string"},"description":"Predefined group titles (card_sort items). Required (non-empty) for closed and hybrid sorts; not allowed for open sorts.","example":["Orders","Account"]},"tree_test_nodes":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Node identifier. Optional on write. If provided, preserved (recorded navigation paths key on it). If omitted, generated. The node a task answer references must carry one."},"label":{"type":"string","minLength":1,"description":"Node label — what the participant sees.","example":"Returns & Exchanges"},"depth":{"type":"integer","minimum":0,"description":"Nesting depth in the depth-first flat list; 0 is a root, each child is parent depth + 1."}},"required":["label","depth"]},"description":"The label hierarchy participants navigate (tree_test items), as a depth-first flat list. Required (min 1 node)."},"tree_test_tasks":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Task identifier. Optional on write; preserved when provided (recorded answers key on it), generated when omitted."},"correct_node_ids":{"type":"array","items":{"type":"string"},"description":"Ids of nodes that count as a successful find. Exactly one entry, matching an `id` in tree_test_nodes."}},"required":["correct_node_ids"]},"description":"Findability tasks (tree_test items). Exactly one task; its prompt is the item content."},"matrix_rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Row identifier. Optional on write. If provided, preserved (survey responses key on it). If omitted, generated."},"content":{"type":"string","minLength":1,"description":"Row label — the item being asked about.","example":"Nike"}},"required":["content"]},"maxItems":30,"description":"Grid rows (matrix items). The participant answers the question once per row. Required (min 1, max 30)."},"matrix_columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Column identifier. Optional on write. If provided, preserved (survey responses key on it). If omitted, generated."},"content":{"type":"string","minLength":1,"description":"Column label — one of the mutually exclusive options.","example":"In the past month"}},"required":["content"]},"maxItems":10,"description":"Grid columns (matrix items). The participant picks exactly one per row. Required (min 2, max 10)."},"include_other_option":{"type":"boolean","description":"Include an \"Other\" free-text option (multiple_choice items)","example":false},"character_limit":{"type":"integer","description":"Character limit for free_response items","example":500},"slider_config":{"type":"object","properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1},"min_label":{"type":"string","maxLength":20,"description":"Label for the minimum end of the slider","example":"Not at all likely"},"max_label":{"type":"string","maxLength":20,"description":"Label for the maximum end of the slider","example":"Extremely likely"}},"required":["min_value","max_value","step"],"description":"Slider configuration — required for slider items. Defines the numeric scale range, step increment, and optional endpoint labels."},"variant_subset_size":{"type":"integer","minimum":1,"description":"For variant_block: how many variants each participant sees out of the total children (load-balanced random assignment). Defaults to 1 when omitted. Must be ≤ number of variant_child children. Ignored on non-variant_block items.","example":2},"item_type":{"type":"string","enum":["section_block"],"description":"Type of questionnaire item","example":"section_block"},"children":{"type":"array","items":{"$ref":"#/components/schemas/QuestionnaireInputItem"},"description":"Nested items — required for section_block (must be non-empty)"},"audience_rules":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/AudienceFilter"},"minItems":1,"maxItems":20,"description":"Filters AND-ed within this group."}},"required":["filters"],"description":"A group of filters. Filters within a group are AND-ed together."},"minItems":1,"maxItems":20,"description":"Groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form — same shape as quota criteria.) At most one group may hold several filters, unless every multi-filter group shares a filter, e.g. [[A, C], [B, C]] for \"(A or B) and C\"; other shapes are rejected."}},"required":["filter_groups"],"description":"Audience rules — gate section visibility on prior screener / in-call survey answers or quota membership. Writable only on section_block items (matches the Question Guide editor). DNF shape: filter_groups OR-ed, filters within a group AND-ed."},"only_ask_if":{}},"required":["content","item_type"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Stable item identifier. Optional on write. If provided, preserved (round-trip safe). If omitted, a new UUID is generated. For projects with existing interviews, preserve IDs via GET-modify-PUT to avoid orphaning per-question analysis data."},"content":{"type":"string","maxLength":10000,"description":"Question, task, or statement text. Required on every item except an image media_block, which may be empty until its image is uploaded in the guide builder.","example":"What challenges do you face with onboarding?"},"choices":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Choice identifier. Optional on write. If provided, preserved. If omitted, generated."},"content":{"type":"string","description":"Choice text. Required (min 1 char) for multiple_choice and ranking items. Allowed empty on rating_scale, where an empty entry is stored as the style-appropriate default label for that position.","example":"Very satisfied"},"exclusive":{"type":"boolean","description":"Marks an exclusive option, such as \"None of the above\": picking it clears every other pick. On a multiple_choice item it turns multi_select on, unless the same request turns multi_select off. On any other item, or when the request turns multi_select off, the flag is dropped. Exclusive options are listed last."}},"required":["content"]},"description":"Answer choices — required (min 2) for multiple_choice and ranking items. Optional for rating_scale: omitted or empty stores the default labels (5-point likert); send 4, 5, or 7 entries (content may be empty) to pick a scale size, and the stored item always carries the full label set."},"media_type":{"type":"string","enum":["image","embed","website","figma","video","text"],"description":"Media type for media_block containers. Determines how `content` is rendered: image (displayed inline), video (video player), embed (iframe), website (opens in new tab), figma (Figma prototype viewer), text (rich text shown on screen for the participant to read — for this one type `content` is the statement itself, not a URL). For a text block the `content` is HTML restricted to `p`, `br`, `b`, `strong`, `i`, `em`, `u`, `s`, `h1`, `h2`, `h3`, `ul`, `ol`, `li`, `blockquote`, `hr`, `a`; anything else is stripped on write, so read it back to see what was stored. For a figma block, Strella stores `content` in its embed.figma.com form and keeps the `scaling` query value of the link, which sets how the prototype fits the screen: `contain` (Fit width and height, the default), `fit-width` (Fit width), `scale-down` (Fill screen), `min-zoom` (Actual size (100%)) or `free` (Responsive). Strella stores any other value as `contain`, with no error.","example":"image"},"label":{"type":"string","maxLength":10000,"description":"Display label for a media_block, shown in the guide editor tree. `content` holds the URL; this names it. Omit to keep the stored label; send an empty string to clear it. Only valid on media_block items.","example":"Prototype walkthrough"},"watermark":{"type":"boolean","description":"Media_block only (not website). When true, the in-call media is marked with a tiled per-participant watermark, and served images/video are baked with it. Ignored on non-media_block items.","example":true},"image_fit":{"type":"string","enum":["contain","fit-width"],"description":"Image media_block only. How the image fits the participant's screen: `contain` (Fit width and height, the default: the whole image fits the screen) or `fit-width` (Fit width: the image spans the screen width and scrolls vertically). Omit to keep the stored value; send `contain` to reset it. Ignored on other items.","example":"fit-width"},"block_media_interaction":{"type":"boolean","description":"Only valid on an item inside a media_block with media_type embed or figma. When true, the embed or prototype is covered while this item is being asked, so the participant answers before interacting with it. Setting it to true anywhere else is rejected with 422.","example":true},"media_redirect_url":{"anyOf":[{"type":"string","maxLength":2048,"format":"uri"},{"type":"string","enum":[""]}],"description":"Only valid on an item inside a media_block with media_type embed, or figma with no prototype imported in Strella. When the interview reaches this item, the embed or prototype reloads at this http(s) address, and later items keep showing it until another item sets one. Inside a figma block it must be a Figma prototype, design, board, slides or deck link, and Strella stores it in the embed form the block content uses. An empty string clears it. A non-empty value anywhere else is rejected with 422.","example":"https://www.figma.com/proto/abc123/Checkout?node-id=11-7"},"figma_task_type":{"type":"string","enum":["free_explore","goal_based"],"description":"The guide editor's \"Task type\" for a task inside a figma media_block with a prototype imported in Strella: free_explore lets the participant click around, and goal_based ends the task when the participant reaches figma_goal_screen. Strella stores free_explore when a task there has no type. Valid only on a task in such a block: the API rejects it with 422 anywhere else.","example":"goal_based"},"figma_start_screen":{"type":"string","maxLength":128,"description":"The guide editor's \"Start screen\": the screen id the prototype opens on for this task, from the `figma_prototype.screens` of its media_block. Valid only on a task inside a figma media_block with an imported prototype. An empty string clears it.","example":"11:1"},"figma_goal_screen":{"type":"string","maxLength":128,"description":"The guide editor's \"Goal screen\": the screen id that ends a goal_based task when the participant reaches it, from the `figma_prototype.screens` of its media_block. Valid only with figma_task_type goal_based. An empty string clears it.","example":"11:7"},"blur_screen_recording":{"type":"boolean","description":"The guide editor's \"Blur recording for privacy\". When true, Strella blurs the participant's screen share in the recording while this item is asked. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422.","example":true},"verbatim":{"type":"boolean","description":"The guide editor's \"Ask question verbatim\". When true, the moderator reads this item exactly as written instead of adapting it to what the participant already said. Follow-up questions stay adaptive. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422.","example":true},"participant_controlled":{"type":"boolean","description":"The guide editor's \"Participant-controlled\". When true, the moderator waits after asking this item until the participant clicks to move on. Valid on question, free_response, multiple_choice, ranking, rating_scale and slider items. Setting it to true on any other item type is rejected with 422. It has no effect while the project moves to the next question only when the participant clicks, for every question.","example":true},"dynamic_followups":{"type":"boolean","default":false,"description":"Whether the AI moderator generates follow-up questions","example":false},"follow_up_guidance":{"type":"string","maxLength":2000,"description":"Guidance for the AI moderator when generating follow-ups","example":"Probe deeper into workflow pain points"},"follow_up_depth":{"anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]}],"description":"Follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High","example":2},"randomize":{"type":"boolean","description":"Whether to randomize display order (choices for multiple_choice/ranking, cards for card_sort, rows for matrix)","example":false},"multi_select":{"type":"boolean","description":"Allow multiple selections (multiple_choice items)","example":false},"min_selections":{"type":"integer","minimum":1,"description":"Minimum number of selections required (multiple_choice items with multi_select=true). Participants cannot continue until they meet this minimum.","example":2},"max_selections":{"type":"integer","minimum":1,"description":"Maximum number of selections allowed (multiple_choice items with multi_select=true). UI prevents selecting beyond this cap; server-side rejects payloads that exceed it.","example":3},"rating_style":{"type":"string","enum":["likert","faces","stars"],"description":"RatingScale-only display mode. `likert` (default) shows text per position. `faces` shows 5 colored emoji (locks scale size to 5). `stars` shows 5 filled stars. Data shape unchanged regardless of style.","example":"likert"},"nps_style":{"type":"string","enum":["buttons","slider"],"description":"NPS-only display mode for the fixed 0-10 scale. `buttons` (default) shows an 11-button strip the participant taps once. `slider` shows a 0-10 handle the participant drags. Data shape unchanged regardless of style: the answer is the chosen integer either way.","example":"buttons"},"tree_style":{"type":"string","enum":["focused","full"],"description":"TreeTest-only display mode. `focused` (default) drills down: opening a section hides its siblings, so only the path the participant chose stays on screen. `full` keeps every top-level section visible and expands sections in place. Data shape unchanged regardless of style: the recorded navigation events are the same either way.","example":"focused"},"tree_branches_selectable":{"type":"boolean","description":"TreeTest-only. Whether participants may answer with a branch (a node that has children). Default false: branches only open and close, the answer is a leaf, and the correct destination must be a leaf.","example":false},"media_upload_kinds":{"type":"array","items":{"type":"string","enum":["image","video","document"]},"description":"File kinds the participant may upload as their answer (media_upload items). Must contain at least one kind when present; omitting it accepts all kinds.","example":["video","image"]},"card_sort_type":{"type":"string","enum":["open","closed","hybrid"],"description":"Who defines the groups for a card_sort item: participants (`open`), the researcher (`closed`), or both (`hybrid`). Defaults to `open` when omitted.","example":"closed"},"card_sort_cards":{"type":"array","items":{"type":"string"},"description":"Card labels participants sort (card_sort items). Must contain at least one card.","example":["Track an order","Update email address"]},"card_sort_groups":{"type":"array","items":{"type":"string"},"description":"Predefined group titles (card_sort items). Required (non-empty) for closed and hybrid sorts; not allowed for open sorts.","example":["Orders","Account"]},"tree_test_nodes":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Node identifier. Optional on write. If provided, preserved (recorded navigation paths key on it). If omitted, generated. The node a task answer references must carry one."},"label":{"type":"string","minLength":1,"description":"Node label — what the participant sees.","example":"Returns & Exchanges"},"depth":{"type":"integer","minimum":0,"description":"Nesting depth in the depth-first flat list; 0 is a root, each child is parent depth + 1."}},"required":["label","depth"]},"description":"The label hierarchy participants navigate (tree_test items), as a depth-first flat list. Required (min 1 node)."},"tree_test_tasks":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Task identifier. Optional on write; preserved when provided (recorded answers key on it), generated when omitted."},"correct_node_ids":{"type":"array","items":{"type":"string"},"description":"Ids of nodes that count as a successful find. Exactly one entry, matching an `id` in tree_test_nodes."}},"required":["correct_node_ids"]},"description":"Findability tasks (tree_test items). Exactly one task; its prompt is the item content."},"matrix_rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Row identifier. Optional on write. If provided, preserved (survey responses key on it). If omitted, generated."},"content":{"type":"string","minLength":1,"description":"Row label — the item being asked about.","example":"Nike"}},"required":["content"]},"maxItems":30,"description":"Grid rows (matrix items). The participant answers the question once per row. Required (min 1, max 30)."},"matrix_columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Column identifier. Optional on write. If provided, preserved (survey responses key on it). If omitted, generated."},"content":{"type":"string","minLength":1,"description":"Column label — one of the mutually exclusive options.","example":"In the past month"}},"required":["content"]},"maxItems":10,"description":"Grid columns (matrix items). The participant picks exactly one per row. Required (min 2, max 10)."},"include_other_option":{"type":"boolean","description":"Include an \"Other\" free-text option (multiple_choice items)","example":false},"character_limit":{"type":"integer","description":"Character limit for free_response items","example":500},"slider_config":{"type":"object","properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1},"min_label":{"type":"string","maxLength":20,"description":"Label for the minimum end of the slider","example":"Not at all likely"},"max_label":{"type":"string","maxLength":20,"description":"Label for the maximum end of the slider","example":"Extremely likely"}},"required":["min_value","max_value","step"],"description":"Slider configuration — required for slider items. Defines the numeric scale range, step increment, and optional endpoint labels."},"variant_subset_size":{"type":"integer","minimum":1,"description":"For variant_block: how many variants each participant sees out of the total children (load-balanced random assignment). Defaults to 1 when omitted. Must be ≤ number of variant_child children. Ignored on non-variant_block items.","example":2},"item_type":{"type":"string","enum":["question","task","statement","multiple_choice","ranking","free_response","slider","rating_scale","nps","media_upload","matrix","card_sort","tree_test","media_block","variant_block","variant_child"],"description":"Type of questionnaire item","example":"question"},"children":{"type":"array","items":{"$ref":"#/components/schemas/QuestionnaireInputItem"},"description":"Nested items — required for container types (media_block, variant_block, variant_child; a media_block with media_type video may be empty), optional for card_sort and tree_test (non-media follow-up questions asked after the test), forbidden for other leaf types"},"audience_rules":{},"only_ask_if":{}},"required":["content","item_type"]}],"description":"Questionnaire item input — discriminated by `item_type`. `section_block` accepts `audience_rules`; other types reject it.","x-tags":["Questionnaires"]},"PutQuestionnaireItemsBody":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/QuestionnaireInputItem"},"description":"Questionnaire items tree. Replaces all existing items."}},"required":["items"],"description":"Bulk-replace all questionnaire items. Provide `id` on items to preserve them (round-trip safe); omit for server-generated UUIDs.","x-tags":["Questionnaires"]},"ReelClipRead":{"type":"object","properties":{"id":{"type":"string","description":"Clip identifier. Reference it in PATCH `clips` to keep the clip.","example":"clnote123abc456"},"participant_id":{"type":"string","description":"Participant the clip belongs to.","example":"clabc123def456"},"project_id":{"type":"string","description":"Project the clip's interview was recorded in. May differ from the reel's filing project — a reel is filed under one project but its clips may come from any project in the organization.","example":"clproj789xyz012"},"start_time":{"type":["number","null"],"description":"Clip start in continuous seconds across the participant's full interview (the same frame transcript search and reel creation use). Null when the clip's recording is no longer on the processed timeline.","example":120.5},"end_time":{"type":["number","null"],"description":"Clip end in continuous seconds. Null when the recording is no longer on the processed timeline.","example":154},"note":{"type":"string","description":"Clip caption — what insight it shows.","example":"Streak loss triggers 3am anxiety"},"sentiment":{"type":"string","enum":["positive","neutral","negative"],"description":"Sentiment of the quote in the context of the interview. Shown as an icon on the reel and aggregated into the reel's overall sentiment, so judge each clip individually rather than defaulting.","example":"negative"},"playable":{"type":"boolean","description":"Whether the clip currently plays in the reel. False clips are hidden by the app but stay stored (see `hidden_reason`); keeping or dropping them in a PATCH works normally.","example":true},"hidden_reason":{"type":"string","enum":["participant_not_eligible","participant_incomplete","session_not_playable"],"description":"Why the clip is hidden from the reel today. `participant_not_eligible`: the participant is a preview, deleted, or excluded from analysis. `participant_incomplete`: the participant has not completed their interview. `session_not_playable`: the recording is no longer processed or has no playable video.","example":"participant_incomplete"},"quote":{"type":"string","description":"Stored verbatim quote. Detail endpoint only (never on list rows), requires transcript PII access, and may be empty when the clip was saved without one.","example":"I literally set an alarm so I would not lose my streak."}},"required":["id","participant_id","start_time","end_time","note","sentiment","playable"],"x-tags":["Highlight Reels"]},"HighlightReelListItem":{"type":"object","properties":{"id":{"type":"string","description":"Highlight reel identifier.","example":"clreel123abc456"},"title":{"type":"string","description":"Reel title.","example":"Streak anxiety: the 3am problem"},"description":{"type":["string","null"],"description":"Reel description. Null when none is set.","example":"Six moments showing how streak pressure drives night-time usage."},"clip_count":{"type":"integer","description":"Number of stored clips, including currently-hidden ones. A clip spanning a session boundary counts once per session.","example":6},"sentiment":{"type":["string","null"],"enum":["positive","neutral","negative",null],"description":"Net sentiment across the reel's visible clips (the same derivation the reel card uses). Null when no clip is currently visible.","example":"negative"},"created_at":{"type":"string","format":"date-time","description":"Creation timestamp (ISO 8601).","example":"2026-07-20T17:45:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"Last edit timestamp (ISO 8601).","example":"2026-07-21T09:10:00.000Z"},"share_url":{"type":["string","null"],"description":"Public share link for the reel (`/share/player/{id}`), or null when the reel has none. Reflects the clips as of the last save through this API. Reads never create one; request it with `create_share_link: true` on a save. Always null for a caller without access to participant recordings, because the link plays the recordings to anyone who holds it.","example":"https://app.strella.io/share/player/clshare123abc456"},"clips":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Clip identifier. Reference it in PATCH `clips` to keep the clip.","example":"clnote123abc456"},"participant_id":{"type":"string","description":"Participant the clip belongs to.","example":"clabc123def456"},"project_id":{"type":"string","description":"Project the clip's interview was recorded in. May differ from the reel's filing project — a reel is filed under one project but its clips may come from any project in the organization.","example":"clproj789xyz012"},"start_time":{"type":["number","null"],"description":"Clip start in continuous seconds across the participant's full interview (the same frame transcript search and reel creation use). Null when the clip's recording is no longer on the processed timeline.","example":120.5},"end_time":{"type":["number","null"],"description":"Clip end in continuous seconds. Null when the recording is no longer on the processed timeline.","example":154},"note":{"type":"string","description":"Clip caption — what insight it shows.","example":"Streak loss triggers 3am anxiety"},"sentiment":{"type":"string","enum":["positive","neutral","negative"],"description":"Sentiment of the quote in the context of the interview. Shown as an icon on the reel and aggregated into the reel's overall sentiment, so judge each clip individually rather than defaulting.","example":"negative"},"playable":{"type":"boolean","description":"Whether the clip currently plays in the reel. False clips are hidden by the app but stay stored (see `hidden_reason`); keeping or dropping them in a PATCH works normally.","example":true},"hidden_reason":{"type":"string","enum":["participant_not_eligible","participant_incomplete","session_not_playable"],"description":"Why the clip is hidden from the reel today. `participant_not_eligible`: the participant is a preview, deleted, or excluded from analysis. `participant_incomplete`: the participant has not completed their interview. `session_not_playable`: the recording is no longer processed or has no playable video.","example":"participant_incomplete"}},"required":["id","participant_id","start_time","end_time","note","sentiment","playable"]},"description":"The stored clips in playback order. Present only with `?include=clips`; list clips never include `quote`."}},"required":["id","title","description","clip_count","sentiment","created_at","updated_at","share_url"],"x-tags":["Highlight Reels"]},"HighlightReelDetail":{"type":"object","properties":{"id":{"type":"string","description":"Highlight reel identifier.","example":"clreel123abc456"},"title":{"type":"string","description":"Reel title.","example":"Streak anxiety: the 3am problem"},"description":{"type":["string","null"],"description":"Reel description. Null when none is set.","example":"Six moments showing how streak pressure drives night-time usage."},"clip_count":{"type":"integer","description":"Number of stored clips, including currently-hidden ones. A clip spanning a session boundary counts once per session.","example":6},"sentiment":{"type":["string","null"],"enum":["positive","neutral","negative",null],"description":"Net sentiment across the reel's visible clips (the same derivation the reel card uses). Null when no clip is currently visible.","example":"negative"},"created_at":{"type":"string","format":"date-time","description":"Creation timestamp (ISO 8601).","example":"2026-07-20T17:45:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"Last edit timestamp (ISO 8601).","example":"2026-07-21T09:10:00.000Z"},"share_url":{"type":["string","null"],"description":"Public share link for the reel (`/share/player/{id}`), or null when the reel has none. Reflects the clips as of the last save through this API. Reads never create one; request it with `create_share_link: true` on a save. Always null for a caller without access to participant recordings, because the link plays the recordings to anyone who holds it.","example":"https://app.strella.io/share/player/clshare123abc456"},"clips":{"type":"array","items":{"$ref":"#/components/schemas/ReelClipRead"},"description":"The stored clips in playback order (`orderedIncludedNoteIds` order), including currently-hidden ones."}},"required":["id","title","description","clip_count","sentiment","created_at","updated_at","share_url","clips"],"x-tags":["Highlight Reels"]},"HighlightReelListQuery":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"cursor":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"sort":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"},"include":{"type":"string","enum":["clips"],"description":"Opt-in embed. `clips` embeds each row's stored clips (without quotes) — useful for scanning reel contents without a per-reel detail call.","example":"clips"}},"x-tags":["Highlight Reels"]},"HighlightReelListResponse":{"type":"object","properties":{"highlight_reels":{"type":"array","items":{"$ref":"#/components/schemas/HighlightReelListItem"},"description":"Saved reels in the project, sorted per the ?sort query param (default \"created_at:desc\")."},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["highlight_reels","meta"],"x-tags":["Highlight Reels"]},"UpdateHighlightReelBody":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"New reel title, used verbatim. Omit to preserve."},"description":{"type":["string","null"],"maxLength":2000,"description":"New reel description. `null` clears it (the reel's Overview then falls back to the title). Omit to preserve."},"clips":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Id of a clip already in the reel (from GET detail). Keeps the stored clip: provided fields overwrite, omitted fields are preserved. Clip times cannot be changed — to trim a clip, omit its id and add a new clip with the adjusted times.","example":"clnote123abc456"},"note":{"type":"string","minLength":1,"maxLength":200,"description":"New caption for the kept clip. Omit to preserve."},"sentiment":{"type":"string","enum":["positive","neutral","negative"],"description":"New sentiment for the kept clip. Omit to preserve.","example":"negative"},"quote":{"type":"string","maxLength":2000,"description":"New verbatim quote for the kept clip. Omit to preserve the stored quote — editors never need to re-supply it."}},"required":["id"],"additionalProperties":false},{"type":"object","properties":{"participant_id":{"type":"string","minLength":1,"description":"Participant the clip belongs to.","example":"clabc123def456"},"start_time":{"type":"number","minimum":0,"description":"Clip start in seconds, continuous across the participant's full interview (the same frame transcript search results and recording clips use).","example":120.5},"end_time":{"type":"number","exclusiveMinimum":0,"description":"Clip end in seconds, continuous across the full interview.","example":154},"note":{"type":"string","minLength":1,"maxLength":200,"description":"Caption for this clip — what insight it shows. Concise and specific to this quote, standing alone without surrounding context.","example":"Streak loss triggers 3am anxiety"},"sentiment":{"type":"string","enum":["positive","neutral","negative"],"description":"Sentiment of the quote in the context of the interview. Shown as an icon on the reel and aggregated into the reel's overall sentiment, so judge each clip individually rather than defaulting.","example":"negative"},"quote":{"type":"string","maxLength":2000,"description":"Verbatim quote text for the clip. Optional — display surfaces derive the transcript text from the clip timestamps; the stored quote is used by exports. Write-only: the response echoes clips without it.","example":"I literally set an alarm so I would not lose my streak."}},"required":["participant_id","start_time","end_time","note","sentiment"],"additionalProperties":false}],"description":"Either a kept clip (`{ id, ... }` — a clip already in the reel) or a new clip (the create shape). New clips are validated with the same preflight as reel creation."},"minItems":1,"maxItems":25,"description":"Full replacement clip list in playback order (1-25). Stored clips whose id is not listed are removed. Echoing every clip id unchanged is a no-op on the clips. Omit to leave the clips untouched."},"create_share_link":{"type":"boolean","description":"Create a public share link (`share_url`) for this reel if it does not have one yet. A reel that already has a link keeps it, and every save refreshes it to the clips as saved, whether or not this flag is sent. On an update, sending only this flag creates or returns the link without changing the reel. Requires access to participant recordings: without it, a save that sends `true` returns `403` with `code: recordings_access_required` and writes nothing.","example":true}},"additionalProperties":false,"x-tags":["Highlight Reels"]},"CreditTransactionQuery":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"cursor":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"type":{"type":"string","enum":["credit_purchase","credit_refund","audience_purchase","incentive_payment","manual_adjustment","expired"],"description":"Filter by transaction type","example":"credit_purchase"},"status":{"type":"string","enum":["pending","completed","failed","reversed"],"description":"Filter by transaction status","example":"completed"},"created_after":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"created_before":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"}},"x-tags":["Credits"]},"CreditTransactionItem":{"type":"object","properties":{"id":{"type":"string","description":"Unique transaction identifier","example":"clx_test_123"},"transaction_type":{"type":"string","enum":["credit_purchase","credit_refund","audience_purchase","incentive_payment","manual_adjustment","expired"],"description":"Type of credit transaction. audience_purchase — debit for launching a recruitment order. incentive_payment — debit for paying an accepted participant. credit_refund — returned when a recruitment order closes early. credit_purchase — top-up credit added to your balance.","example":"credit_purchase"},"status":{"type":"string","enum":["pending","completed","failed","reversed"],"description":"Transaction status","example":"completed"},"amount_cents":{"type":"integer","description":"Transaction amount in cents (negative for debits)","example":-5000},"balance_after_cents":{"type":"integer","minimum":0,"description":"Credit balance after this transaction, in cents","example":245000},"description":{"type":["string","null"],"description":"Human-readable description of the transaction","example":"Purchased 50 credits"},"project_id":{"type":["string","null"],"description":"Associated project identifier, if applicable","example":"cm7_test_456"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-05-04T10:00:00.000Z"}},"required":["id","transaction_type","status","amount_cents","balance_after_cents","description","project_id","created_at"],"x-tags":["Credits"]},"CreditTransactionListResponse":{"type":"object","properties":{"transactions":{"type":"array","items":{"$ref":"#/components/schemas/CreditTransactionItem"},"description":"Credit transactions sorted by creation date (newest first)"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["transactions","meta"],"x-tags":["Credits"]},"CreditBalance":{"type":"object","properties":{"balance_cents":{"type":"integer","minimum":0,"description":"Current credit balance in cents (USD)","example":250000},"currency":{"type":"string","enum":["usd"],"description":"Currency denomination (always \"usd\")","example":"usd"}},"required":["balance_cents","currency"],"x-tags":["Credits"]},"CustomFieldOption":{"type":"object","properties":{"value":{"type":"string","description":"The stored value. Participant `custom_fields` return this, and it stays the same when the label is renamed.","example":"enterprise"},"label":{"type":"string","description":"The option text shown in the participants table","example":"Enterprise"}},"required":["value","label"],"x-tags":["Projects"]},"CustomFieldDefinition":{"type":"object","properties":{"id":{"type":"string","description":"The field's id, matching `custom_fields[].id` on a participant. It stays the same when a Researcher renames the field.","example":"custom_8f2c1a7e-4b1d-4c9a-9f0e-2d6b3a1c5e7f"},"name":{"type":"string","description":"The column name shown in the participants table","example":"Account tier"},"type":{"type":"string","enum":["text","single_select","multi_select"],"description":"`text` holds free text, `single_select` one option, `multi_select` any number of options"},"options":{"type":["array","null"],"items":{"$ref":"#/components/schemas/CustomFieldOption"},"description":"The options a participant can hold, in display order. `null` for `text` fields."},"is_match_key":{"type":"boolean","description":"Whether this field is the project's participant identifier, which matches a returning participant to their existing row. At most one field holds the role."}},"required":["id","name","type","options","is_match_key"],"x-tags":["Projects"]},"ParticipantCustomField":{"oneOf":[{"type":"object","properties":{"id":{"type":"string","description":"The field's id. It stays the same when a Researcher renames the field, so match on it rather than on `name`.","example":"custom_8f2c1a7e-4b1d-4c9a-9f0e-2d6b3a1c5e7f"},"name":{"type":"string","description":"The column name shown in the participants table","example":"Account tier"},"type":{"type":"string","enum":["text","single_select"]},"value":{"type":"string","description":"The text entered, or for `single_select` the chosen option's stored value (not its display label). The project's `custom_fields` (`GET /projects/{project_id}`) maps values to labels.","example":"enterprise"}},"required":["id","name","type","value"]},{"type":"object","properties":{"id":{"type":"string","description":"The field's id. It stays the same when a Researcher renames the field, so match on it rather than on `name`.","example":"custom_8f2c1a7e-4b1d-4c9a-9f0e-2d6b3a1c5e7f"},"name":{"type":"string","description":"The column name shown in the participants table","example":"Account tier"},"type":{"type":"string","enum":["multi_select"]},"value":{"type":"array","items":{"type":"string"},"description":"The chosen options' stored values (not their display labels). Never empty. The project's `custom_fields` (`GET /projects/{project_id}`) maps values to labels.","example":["power_user"]}},"required":["id","name","type","value"]}],"description":"One of the project's custom fields that holds a value for this participant","x-tags":["Participants"]},"ScreenerAnswerOption":{"type":"object","properties":{"text":{"type":"string","description":"Answer text. For a matrix question, the picked cell as \"Row: Column\"."},"value":{"anyOf":[{"type":"string"},{"type":"number"}]},"is_selected":{"type":"boolean"},"row":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the row of this cell"},"column":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the column of this cell"}},"required":["value"],"x-tags":["Screeners"]},"ScreenerResponseListItem":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener response identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"question_text":{"type":"string","description":"The screener question text","example":"Which of the following products have you purchased in the last 3 months?"},"question_type":{"type":"string","description":"Question type (radio, checkbox, sliderScale, singleLineTextbox, multiLineTextbox, matrix)","example":"checkbox"},"response":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text. For a matrix question, the picked cell as \"Row: Column\"."},"value":{"anyOf":[{"type":"string"},{"type":"number"}]},"is_selected":{"type":"boolean"},"row":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the row of this cell"},"column":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the column of this cell"}},"required":["value"]},"description":"Answer options for this question"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-06-01T12:00:00.000Z"}},"required":["id","question_text","question_type","response","created_at"],"x-tags":["Responses"]},"ScreenerResponseListResponse":{"type":"object","properties":{"screener_responses":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener response identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"question_text":{"type":"string","description":"The screener question text","example":"Which of the following products have you purchased in the last 3 months?"},"question_type":{"type":"string","description":"Question type (radio, checkbox, sliderScale, singleLineTextbox, multiLineTextbox, matrix)","example":"checkbox"},"response":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text. For a matrix question, the picked cell as \"Row: Column\"."},"value":{"anyOf":[{"type":"string"},{"type":"number"}]},"is_selected":{"type":"boolean"},"row":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the row of this cell"},"column":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the column of this cell"}},"required":["value"]},"description":"Answer options for this question"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-06-01T12:00:00.000Z"}},"required":["id","question_text","question_type","response","created_at"]},"description":"Screener responses sorted by question position (ascending), then by ID (ascending)"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["screener_responses","meta"],"x-tags":["Responses"]},"ProjectScreenerResponseListQuery":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"cursor":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"participant_ids":{"type":"string","description":"Filter by participant IDs (comma-separated). Returns screener responses for these participants.","example":"abc123,def456"}},"x-tags":["Responses"]},"ProjectScreenerResponseListItem":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener response identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"question_text":{"type":"string","description":"The screener question text","example":"Which of the following products have you purchased in the last 3 months?"},"question_type":{"type":"string","description":"Question type (radio, checkbox, sliderScale, singleLineTextbox, multiLineTextbox, matrix)","example":"checkbox"},"response":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text. For a matrix question, the picked cell as \"Row: Column\"."},"value":{"anyOf":[{"type":"string"},{"type":"number"}]},"is_selected":{"type":"boolean"},"row":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the row of this cell"},"column":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the column of this cell"}},"required":["value"]},"description":"Answer options for this question"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-06-01T12:00:00.000Z"},"participant_id":{"type":"string","description":"Participant who submitted this screener response","example":"cm7c4hp5w0003jm04kx8v2n9r"}},"required":["id","question_text","question_type","response","created_at","participant_id"],"x-tags":["Responses"]},"ProjectScreenerResponseListResponse":{"type":"object","properties":{"screener_responses":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener response identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"question_text":{"type":"string","description":"The screener question text","example":"Which of the following products have you purchased in the last 3 months?"},"question_type":{"type":"string","description":"Question type (radio, checkbox, sliderScale, singleLineTextbox, multiLineTextbox, matrix)","example":"checkbox"},"response":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text. For a matrix question, the picked cell as \"Row: Column\"."},"value":{"anyOf":[{"type":"string"},{"type":"number"}]},"is_selected":{"type":"boolean"},"row":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the row of this cell"},"column":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the column of this cell"}},"required":["value"]},"description":"Answer options for this question"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-06-01T12:00:00.000Z"},"participant_id":{"type":"string","description":"Participant who submitted this screener response","example":"cm7c4hp5w0003jm04kx8v2n9r"}},"required":["id","question_text","question_type","response","created_at","participant_id"]},"description":"Screener responses sorted by question position (ascending), then by ID (ascending)"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["screener_responses","meta"],"x-tags":["Responses"]},"SurveyResponseListQuery":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"cursor":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"participant_ids":{"type":"string","description":"Filter by participant IDs (comma-separated). Returns survey responses for these participants.","example":"abc123,def456"},"questionnaire_item_id":{"type":"string","description":"Filter by questionnaire item ID"},"created_after":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"created_before":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"updated_after":{"type":"string","format":"date-time","description":"Filter results updated after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"updated_before":{"type":"string","format":"date-time","description":"Filter results updated before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"sort":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"}},"x-tags":["Responses"]},"SurveyResponseListItem":{"type":"object","properties":{"id":{"type":"string","description":"Unique survey response identifier","example":"cm7f1mp7v0006im04bt3y9h2k"},"questionnaire_item_id":{"type":"string","description":"Questionnaire item ID this response answers","example":"ia7bx3kq9"},"participant_id":{"type":"string","description":"Participant who submitted this response","example":"cm7c4hp5w0003jm04kx8v2n9r"},"question_text":{"type":"string","description":"The question text as shown to the participant"},"response":{"type":"array","items":{"type":"string"},"description":"Array of response values (single or multi-select)","example":["2 to 3 times a week"]},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-01-15T10:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last modification timestamp","example":"2026-01-20T14:30:00.000Z"}},"required":["id","questionnaire_item_id","participant_id","question_text","response","created_at","updated_at"],"x-tags":["Responses"]},"SurveyResponseListResponse":{"type":"object","properties":{"survey_responses":{"type":"array","items":{"$ref":"#/components/schemas/SurveyResponseListItem"},"description":"Survey responses sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["survey_responses","meta"],"x-tags":["Responses"]},"ParticipantBooking":{"type":"object","properties":{"id":{"type":"string","description":"Booking ID","example":"cmbk3x9q20001l7081a2b3c4d"},"start_at":{"type":"string","description":"When the interview starts, RFC 3339 in UTC","example":"2026-10-14T17:00:00.000Z"},"status":{"type":"string","enum":["booked","cancelled","no_show","completed"],"description":"`booked` holds the slot. `cancelled` frees it. `no_show` and `completed` record how the interview went. A participant has at most one `booked` booking at a time.","example":"booked"},"join_url":{"type":["string","null"],"description":"The moderator's link to join the interview call. It opens only for a signed-in member of the organization. Null unless `status` is `booked`.","example":"https://app.strella.io/moderated-call/booking/cmbk3x9q20001l7081a2b3c4d"}},"required":["id","start_at","status","join_url"],"description":"A participant's booked slot on a human-moderated project. Read-only.","x-tags":["Participants"]},"ParticipantStatus":{"type":"string","enum":["unqualified","qualified","invited","in_review","rejected","accepted"],"description":"Participant status in the study lifecycle","example":"accepted","x-tags":["Participants"]},"ParticipantListQuery":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"cursor":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"status":{"type":"string","description":"Filter by participant status (comma-separated). Valid values: unqualified (started the screener but has not submitted it), qualified (passed the screener, awaiting invite), invited, in_review, rejected, accepted","example":"accepted,in_review"},"participant_ids":{"type":"string","description":"Filter by participant IDs (comma-separated).","example":"abc123,def456"},"name":{"type":"string","description":"Case-insensitive contains match on participant name, as far as the caller may see names: anywhere in the name with full name access, the first name only with first-name-only access. Without name access the request returns `403` with `code: name_access_required`.","example":"jane"},"age_min":{"type":["integer","null"],"minimum":0,"description":"Minimum age (inclusive). Participants with unknown age (0) are excluded. Requires access to participant age: without it, the request returns `403` with `code: age_access_required`.","example":25},"age_max":{"type":["integer","null"],"minimum":0,"description":"Maximum age (inclusive). Participants with unknown age (0) are excluded. Requires access to participant age: without it, the request returns `403` with `code: age_access_required`.","example":34},"booking":{"type":"string","enum":["upcoming"],"description":"Filter by booking on a human-moderated project. `upcoming`: participants holding a booked slot that has not started yet. Matches no one on an AI-moderated project.","example":"upcoming"},"detail":{"type":"string","enum":["full"],"description":"When set to \"full\", each list item includes all fields from the single-resource endpoint (e.g., description, screener_required for projects; embedded sessions for interviews; profile for participants). Omit for lighter responses when only summary fields are needed. Pagination behavior (limit, cursor, has_more) is unchanged.","example":"full"},"created_after":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"created_before":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"updated_after":{"type":"string","format":"date-time","description":"Filter results updated after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"updated_before":{"type":"string","format":"date-time","description":"Filter results updated before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"sort":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"}},"x-tags":["Participants"]},"ParticipantListItem":{"type":"object","properties":{"id":{"type":"string","description":"Unique participant identifier","example":"cm7c4hp5w0003jm04kx8v2n9r"},"status":{"type":"string","enum":["unqualified","qualified","invited","in_review","rejected","accepted"],"description":"Participant status in the study lifecycle","example":"accepted"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-06-01T12:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last modification timestamp","example":"2025-06-15T09:30:00.000Z"},"interview_count":{"type":"integer","minimum":0,"description":"Number of completed interviews","example":1},"total_duration_seconds":{"type":"integer","minimum":0,"description":"Total interview duration across all interviews (seconds)","example":568},"survey_response_count":{"type":"integer","minimum":0,"description":"Number of survey responses","example":5},"screener_response_count":{"type":"integer","minimum":0,"description":"Number of screener question responses","example":3},"variant_assignments":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantVariantAssignment"},"description":"Which variant the participant was assigned in each variant block, one object per block. A block holds several ids when its `variant_subset_size` is above 1. Assignment happens when the interview starts, so it does not prove the participant reached the block; `[]` until then, or when the project has no variant blocks. An id can be missing from the current questionnaire if that variant was deleted after assignment. Always present, not excludable via `?exclude=`.","example":[{"vb_pricing":["vc_monthly"]},{"vb_concepts":["vc_x","vc_z"]}]},"excluded_from_analysis":{"type":"boolean","description":"Whether the participant is left out of the project's analysis (Summary, themes, and charts). Set it with `PATCH /participants/{participant_id}`.","example":false},"booked_interviews":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantBooking"},"description":"The interview slots this participant booked on a human-moderated project, newest start first. A cancel and rebook adds a row, so earlier rows are the history. `[]` on AI-moderated projects and for participants who never booked."},"name":{"type":"string","description":"Participant display name (requires participants:read:pii scope)","example":"Jane Doe"},"email":{"type":["string","null"],"description":"Participant email address (requires participants:read:pii scope). Always absent when the project has participant anonymization enabled (`anonymize_participant_names`).","example":"jane@example.com"},"profile_picture_url":{"type":["string","null"],"description":"URL to profile picture (requires participants:read:pii scope)","example":"https://example.com/photo.jpg"}},"required":["id","status","created_at","updated_at","interview_count","total_duration_seconds","survey_response_count","screener_response_count","variant_assignments","excluded_from_analysis","booked_interviews"],"x-tags":["Participants"]},"ParticipantVariantAssignment":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string","minLength":1},"minItems":1},"description":"One variant block and what the participant was assigned in it: a single key, the `variant_block` item id, whose value lists the assigned `variant_child` item ids. Ids match the ones `GET /projects/{projectId}/questionnaire` returns.","minProperties":1,"maxProperties":1,"example":{"vb_concepts":["vc_x","vc_z"]},"x-tags":["Participants"]},"ParticipantListResponse":{"type":"object","properties":{"participants":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantListItem"},"description":"Participants sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]},"anonymize_participant_names":{"type":"boolean","description":"Whether participant anonymization is enabled for this project. When true, Strella never returns participant `email` anywhere — API, MCP, or exports — regardless of the caller's `participants:read:email` grant, and `name` values are anonymized aliases. Toggled via the project's Privacy settings or `PATCH /projects` (`anonymize_participant_names`).","example":false}},"required":["participants","meta","anonymize_participant_names"],"x-tags":["Participants"]},"ParticipantProfile":{"type":"object","properties":{"job_title":{"type":"string","description":"Current job title","example":"Product Manager"},"gender":{"type":"string","description":"Self-reported gender","example":"Female"},"age":{"type":"string","description":"Age or age range","example":"25-34"},"level_of_education":{"type":"string","description":"Highest education level","example":"Bachelor's degree"},"race_ethnicity":{"type":"string","description":"Self-reported race or ethnicity"},"household_income_in_usd":{"type":"string","description":"Household income bracket in USD","example":"$75,000 - $99,999"},"seniority":{"type":"string","description":"Professional seniority level","example":"Senior"},"location":{"type":"string","description":"Geographic location","example":"San Francisco, CA"},"company_size":{"type":"string","description":"Size of employer organization","example":"51-200 employees"}},"x-tags":["Participants"]},"Recording":{"type":"object","properties":{"recording_url":{"type":["string","null"],"deprecated":true,"description":"DEPRECATED, read `split_screen_url` for the split-screen layout and `video_url` for the primary/processing recording. The split-screen recording, falling back to `video_url`. Retained for back-compat."},"split_screen_url":{"type":["string","null"],"description":"Split-screen recording URL (participant camera + shared screen, composited), always a video. Null when the participant did not share their screen, or when only an audio-only track survives (anonymized legacy sessions) — read `video_url` for that audio."},"screen_share_url":{"type":["string","null"],"description":"Screen share recording URL (shared screen, camera picture-in-picture). Null when the participant did not share their screen."},"participant_camera_url":{"type":["string","null"],"description":"Participant camera recording URL. Null when video was disabled."},"video_url":{"type":["string","null"],"description":"Primary recording used for transcription and analysis (whichever layout is best for processing; may be audio-only for anonymized sessions). Not a viewing layout, prefer the layout URLs above for playback."},"duration_seconds":{"type":"integer","minimum":0,"description":"Recording duration in seconds","example":568},"device_type":{"type":["string","null"],"enum":["desktop_web","mobile_web","mobile_app",null],"description":"The device class the participant took this interview on, best-effort from the self-reported platform and recorded user agent. `desktop_web` or `mobile_web` for a browser session, `mobile_app` for the native app. Null when the session carries no device signal (uploads, human-moderated, or pre-capture sessions).","example":"mobile_web"}},"required":["recording_url","split_screen_url","screen_share_url","participant_camera_url","video_url","duration_seconds","device_type"],"description":"A session recording. On projects using the private recordings bucket, every non-null URL is an absolute `https://<app-host>/api/media/<bucket>/...` link that requires an authenticated app session to play (an API key alone cannot fetch it); the route re-signs on each read, so the link never expires. On public-bucket projects the URLs are direct and stable.","x-tags":["Participants"]},"ParticipantQuota":{"type":"object","properties":{"id":{"type":"string","description":"Quota identifier","example":"cm7f2kp9w0008jm04xt5v3n7s"},"name":{"type":"string","description":"Quota name","example":"Women 25-34"}},"required":["id","name"],"x-tags":["Participants"]},"PaymentSummary":{"type":"object","properties":{"method":{"type":"string","enum":["credits","manual"],"description":"Payment method used when the participant was accepted"},"amount_cents":{"type":"integer","exclusiveMinimum":0,"description":"Incentive amount paid, in cents (USD)","example":5000},"payment_intent_id":{"type":"string","description":"Stripe PaymentIntent ID associated with the payment","example":"pi_3PxYzA2eZvKYlo2C1234abcd"}},"required":["method","amount_cents","payment_intent_id"],"x-tags":["Participants"]},"Attribution":{"type":"object","properties":{"type":{"type":"string","enum":["user","api_key"],"description":"Whether the action was performed by a human user or an API key"},"id":{"type":"string","description":"Identifier of the user or API key","example":"user_2xYzAbCdEfGhIjKlMnOpQrSt"},"label":{"type":"string","description":"Display name of the user or API key label","example":"Arjan Singh"}},"required":["type","id","label"],"x-tags":["Participants"]},"ParticipantDetail":{"type":"object","properties":{"id":{"type":"string","description":"Unique participant identifier","example":"cm7c4hp5w0003jm04kx8v2n9r"},"status":{"type":"string","enum":["unqualified","qualified","invited","in_review","rejected","accepted"],"description":"Participant status in the study lifecycle","example":"accepted"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-01-15T10:00:00.000Z"},"quota":{"type":["object","null"],"properties":{"id":{"type":"string","description":"Quota identifier","example":"cm7f2kp9w0008jm04xt5v3n7s"},"name":{"type":"string","description":"Quota name","example":"Women 25-34"}},"required":["id","name"],"description":"Quota assignment. Always present: `null` when the participant is not assigned to a quota, otherwise the assigned quota. Also `null` for a caller whose PII access hides any demographic field, because a quota names a demographic group. Not excludable via `?exclude=`."},"quota_ids":{"type":"array","items":{"type":"string"},"description":"Every quota the participant belongs to, primary first, including quotas already completed. A project using quota groups counts a participant in one quota per group, so this can hold several ids; otherwise it is `[quota.id]` or `[]`. `[]` for a caller whose PII access hides any demographic field. Always present, not excludable via `?exclude=`.","example":["cm7f2kp9w0008jm04xt5v3n7s"]},"variant_assignments":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantVariantAssignment"},"description":"Which variant the participant was assigned in each variant block, one object per block. A block holds several ids when its `variant_subset_size` is above 1. Assignment happens when the interview starts, so it does not prove the participant reached the block; `[]` until then, or when the project has no variant blocks. An id can be missing from the current questionnaire if that variant was deleted after assignment. Always present, not excludable via `?exclude=`.","example":[{"vb_pricing":["vc_monthly"]},{"vb_concepts":["vc_x","vc_z"]}]},"excluded_from_analysis":{"type":"boolean","description":"Whether the participant is left out of the project's analysis (Summary, themes, and charts). Set it with `PATCH /participants/{participant_id}`.","example":false},"summary":{"type":["object","null"],"properties":{"sections":{"type":"array","items":{"type":"object","properties":{"heading":{"type":"string","description":"Section heading"},"content":{"type":"string","description":"Section content (markdown)"}},"required":["heading","content"]},"description":"Structured summary sections"}},"required":["sections"],"description":"AI-generated participant summary. Null when `summary_status` is `unavailable` or `error`. Excludable via ?exclude=summary"},"summary_status":{"type":"string","enum":["available","unavailable","error"],"description":"Disambiguates why `summary` is null: `available` = summary present, `unavailable` = not yet generated, `error` = summary JSON failed parse. Absent when the section is excluded via `?exclude=summary`."},"booked_interviews":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantBooking"},"description":"The interview slots this participant booked on a human-moderated project, newest start first. A cancel and rebook adds a row, so earlier rows are the history. `[]` on AI-moderated projects and for participants who never booked. Excludable via ?exclude=booked_interviews"},"custom_fields":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantCustomField"},"description":"The project's custom fields (participants table columns, including Tags and AI-coded categories) that hold a value for this participant, in column order. The API leaves out a field the participant holds no value in, so `[]` means the participant holds no values. Set them with `PATCH /projects/{project_id}/participants/{participant_id}`. Leave them out with `?exclude=custom_fields`."},"total_duration_seconds":{"type":"integer","minimum":0,"description":"Total interview duration across all sessions (seconds)","example":568},"payment":{"type":["object","null"],"properties":{"method":{"type":"string","enum":["credits","manual"],"description":"Payment method used when the participant was accepted"},"amount_cents":{"type":"integer","exclusiveMinimum":0,"description":"Incentive amount paid, in cents (USD)","example":5000},"payment_intent_id":{"type":"string","description":"Stripe PaymentIntent ID associated with the payment","example":"pi_3PxYzA2eZvKYlo2C1234abcd"}},"required":["method","amount_cents","payment_intent_id"],"description":"Payment details for accepted participants. `null` for participants not yet accepted or not subject to incentive payments."},"rejection_reason":{"type":["string","null"],"description":"Reason provided when the participant was rejected. `null` if not rejected or no reason was given.","example":"Participant did not meet the screening criteria."},"rejected_by":{"type":["object","null"],"properties":{"type":{"type":"string","enum":["user","api_key"],"description":"Whether the action was performed by a human user or an API key"},"id":{"type":"string","description":"Identifier of the user or API key","example":"user_2xYzAbCdEfGhIjKlMnOpQrSt"},"label":{"type":"string","description":"Display name of the user or API key label","example":"Arjan Singh"}},"required":["type","id","label"],"description":"Who rejected the participant. `null` if not rejected."},"pii_level":{"type":"string","enum":["none","first-name-only","full"],"deprecated":true,"description":"DEPRECATED, read `pii_acl` instead. Round-DOWN projection of the caller's effective PII access that never reports \"full\" for partial access. Retained for back-compat with integrations written before per-field PII access existed."},"pii_acl":{"type":"object","properties":{"name":{"type":"string","enum":["full","first","none"]},"email":{"type":"boolean"},"profile_picture_url":{"type":"boolean"}},"required":["name","email","profile_picture_url"],"description":"The PII ACL identity axes granted to the caller. Reports the grant, not the effective visibility: when `anonymize_participant_names` is true, `email` is withheld even though this may report `email: true`."},"anonymize_participant_names":{"type":"boolean","description":"Whether participant anonymization is enabled for this project. When true, `email` is always absent regardless of the caller's email grant, and `name` is an anonymized alias.","example":false},"name":{"type":"string","description":"Participant name (full or first per the name axis). Absent when the name axis is none."},"email":{"type":["string","null"],"description":"Participant email (null if not on file). Absent when the email axis is denied, and always absent when the project has participant anonymization enabled (`anonymize_participant_names`)."},"profile_picture_url":{"type":["string","null"],"description":"Profile picture URL (null if none). Absent when the picture axis is denied."},"profile":{"type":"object","properties":{"job_title":{"type":"string","description":"Current job title","example":"Product Manager"},"gender":{"type":"string","description":"Self-reported gender","example":"Female"},"age":{"type":"string","description":"Age or age range","example":"25-34"},"level_of_education":{"type":"string","description":"Highest education level","example":"Bachelor's degree"},"race_ethnicity":{"type":"string","description":"Self-reported race or ethnicity"},"household_income_in_usd":{"type":"string","description":"Household income bracket in USD","example":"$75,000 - $99,999"},"seniority":{"type":"string","description":"Professional seniority level","example":"Senior"},"location":{"type":"string","description":"Geographic location","example":"San Francisco, CA"},"company_size":{"type":"string","description":"Size of employer organization","example":"51-200 employees"}},"description":"Demographic profile (excludable via ?exclude=profile)"},"recordings":{"type":"array","items":{"$ref":"#/components/schemas/Recording"},"description":"Session recordings for this participant, sorted chronologically (oldest first). Excludable via ?exclude=recordings"},"screener_responses":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener response identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"question_text":{"type":"string","description":"The screener question text","example":"Which of the following products have you purchased in the last 3 months?"},"question_type":{"type":"string","description":"Question type (radio, checkbox, sliderScale, singleLineTextbox, multiLineTextbox, matrix)","example":"checkbox"},"response":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text. For a matrix question, the picked cell as \"Row: Column\"."},"value":{"anyOf":[{"type":"string"},{"type":"number"}]},"is_selected":{"type":"boolean"},"row":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the row of this cell"},"column":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the column of this cell"}},"required":["value"]},"description":"Answer options for this question"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-06-01T12:00:00.000Z"}},"required":["id","question_text","question_type","response","created_at"]},"description":"Screener responses, sorted chronologically (oldest first). Excludable via ?exclude=screener_responses"},"survey_responses":{"type":"array","items":{"$ref":"#/components/schemas/SurveyResponseListItem"},"description":"Survey responses, sorted chronologically (oldest first). Excludable via ?exclude=survey_responses"}},"required":["id","status","created_at","quota","quota_ids","variant_assignments","excluded_from_analysis","total_duration_seconds","payment","rejection_reason","rejected_by","pii_level","pii_acl","anonymize_participant_names"],"x-tags":["Participants"]},"ParticipantDetailQuery":{"type":"object","properties":{"exclude":{"type":"string","description":"Comma-separated list of sections to exclude from the response. Valid values: recordings, screener_responses, survey_responses, summary, profile, booked_interviews, custom_fields","example":"screener_responses,survey_responses"}},"additionalProperties":false,"x-tags":["Participants"]},"QuotaGroup":{"type":"object","properties":{"id":{"type":"string","description":"Quota group identifier"},"name":{"type":"string","description":"Group name, unique among the project's live groups","example":"Gender"},"quota_count":{"type":"integer","minimum":0,"description":"Live quotas in this group. A group must be empty before it can be deleted.","example":3},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 timestamp"}},"required":["id","name","quota_count","created_at"],"x-tags":["Quotas"]},"QuotaGroupListResponse":{"type":"object","properties":{"quota_groups":{"type":"array","items":{"$ref":"#/components/schemas/QuotaGroup"}},"meta":{"type":"object","properties":{"total":{"type":"integer","minimum":0,"description":"Total number of quota groups on the project"}},"required":["total"]}},"required":["quota_groups","meta"],"x-tags":["Quotas"]},"CreateQuotaGroupBody":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100,"description":"Group name (1-100 chars), unique among the project's live groups. Names one dimension of the sample plan, for example \"Gender\" or \"Income\".","example":"Gender"}},"required":["name"],"x-tags":["Quotas"]},"UpdateQuotaGroupBody":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100,"description":"New group name (1-100 chars), unique among the project's live groups"}},"required":["name"],"x-tags":["Quotas"]},"QuotaBehavior":{"type":"string","enum":["close_when_full","allow_overflow"],"description":"How the quota behaves at capacity. `close_when_full` blocks new participants; `allow_overflow` continues accepting.","example":"close_when_full","x-tags":["Quotas"]},"QuotaStatus":{"type":"string","enum":["open","filled"],"description":"Computed status. `open` when under limit or behavior is allow_overflow. `filled` when close_when_full and completed_count >= limit.","example":"open","x-tags":["Quotas"]},"QuotaFilter":{"type":"object","properties":{"screener_question":{"type":"string","description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"],"x-tags":["Quotas"]},"QuotaGroupRef":{"type":"object","properties":{"id":{"type":"string","description":"Quota group identifier","example":"cm7a9qk2x0001jv04qgrp001"},"name":{"type":"string","description":"Quota group name","example":"Gender"}},"required":["id","name"],"x-tags":["Quotas"]},"QuotaFilterGroup":{"type":"object","properties":{"filters":{"type":"array","items":{"type":"object","properties":{"screener_question":{"type":"string","description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"]},"minItems":1,"description":"Filters AND-ed within this group"}},"required":["filters"],"x-tags":["Quotas"]},"QuotaCriteria":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"type":"object","properties":{"screener_question":{"type":"string","description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"]},"minItems":1,"description":"Filters AND-ed within this group"}},"required":["filters"]},"description":"Filter groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form)"}},"required":["filter_groups"],"x-tags":["Quotas"]},"QuotaListItem":{"type":"object","properties":{"id":{"type":"string","description":"Unique quota identifier","example":"cm7a9qk2x0001jv04quota01"},"name":{"type":"string","description":"Display name","example":"Senior PMs (US)"},"limit":{"type":"integer","exclusiveMinimum":0,"description":"Target participant count","example":10},"behavior":{"type":"string","enum":["close_when_full","allow_overflow"],"description":"How the quota behaves at capacity. `close_when_full` blocks new participants; `allow_overflow` continues accepting.","example":"close_when_full"},"status":{"type":"string","enum":["open","filled"],"description":"Computed status. `open` when under limit or behavior is allow_overflow. `filled` when close_when_full and completed_count >= limit.","example":"open"},"completed_count":{"type":"integer","minimum":0,"description":"Participants who completed their interview and were approved toward this quota. Counts toward the `limit`.","example":7},"qualified_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have passed the screener and are awaiting researcher invite. Approximates the in-app \"Qualified\" column; for panel participants, may transiently include screener-failures still pending vendor reconciliation (these appear under \"Disqualified\" in-app). Eventually consistent with vendor status — see the recruitment-order `counts.as_of` field for the freshness contract (Respondent webhook-driven; User Interviews cron only refreshes when new sign-ups arrive).","example":3},"invited_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have been invited but have not yet completed an interview. Same freshness contract as `qualified_count`.","example":2},"criteria":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"type":"object","properties":{"screener_question":{"type":"string","description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"]},"minItems":1,"description":"Filters AND-ed within this group"}},"required":["filters"]},"description":"Filter groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form)"}},"required":["filter_groups"]},"group":{"type":["object","null"],"properties":{"id":{"type":"string","description":"Quota group identifier","example":"cm7a9qk2x0001jv04qgrp001"},"name":{"type":"string","description":"Quota group name","example":"Gender"}},"required":["id","name"],"description":"Quota group this quota belongs to, or `null` when ungrouped. Quotas in the same group are mutually exclusive buckets of one dimension (for example every gender option), and a participant is counted in one quota per group. Read-only here: place a quota with `group_id` on create or update, and read the groups on the project from `quota_groups` on the quota list."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-02-01T10:00:00.000Z"}},"required":["id","name","limit","behavior","status","completed_count","qualified_count","invited_count","criteria","group","created_at"],"x-tags":["Quotas"]},"QuotaListResponse":{"type":"object","properties":{"quotas":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique quota identifier","example":"cm7a9qk2x0001jv04quota01"},"name":{"type":"string","description":"Display name","example":"Senior PMs (US)"},"limit":{"type":"integer","exclusiveMinimum":0,"description":"Target participant count","example":10},"behavior":{"type":"string","enum":["close_when_full","allow_overflow"],"description":"How the quota behaves at capacity. `close_when_full` blocks new participants; `allow_overflow` continues accepting.","example":"close_when_full"},"status":{"type":"string","enum":["open","filled"],"description":"Computed status. `open` when under limit or behavior is allow_overflow. `filled` when close_when_full and completed_count >= limit.","example":"open"},"completed_count":{"type":"integer","minimum":0,"description":"Participants who completed their interview and were approved toward this quota. Counts toward the `limit`.","example":7},"qualified_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have passed the screener and are awaiting researcher invite. Approximates the in-app \"Qualified\" column; for panel participants, may transiently include screener-failures still pending vendor reconciliation (these appear under \"Disqualified\" in-app). Eventually consistent with vendor status — see the recruitment-order `counts.as_of` field for the freshness contract (Respondent webhook-driven; User Interviews cron only refreshes when new sign-ups arrive).","example":3},"invited_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have been invited but have not yet completed an interview. Same freshness contract as `qualified_count`.","example":2},"criteria":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"type":"object","properties":{"screener_question":{"type":"string","description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"]},"minItems":1,"description":"Filters AND-ed within this group"}},"required":["filters"]},"description":"Filter groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form)"}},"required":["filter_groups"]},"group":{"type":["object","null"],"properties":{"id":{"type":"string","description":"Quota group identifier","example":"cm7a9qk2x0001jv04qgrp001"},"name":{"type":"string","description":"Quota group name","example":"Gender"}},"required":["id","name"],"description":"Quota group this quota belongs to, or `null` when ungrouped. Quotas in the same group are mutually exclusive buckets of one dimension (for example every gender option), and a participant is counted in one quota per group. Read-only here: place a quota with `group_id` on create or update, and read the groups on the project from `quota_groups` on the quota list."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-02-01T10:00:00.000Z"}},"required":["id","name","limit","behavior","status","completed_count","qualified_count","invited_count","criteria","group","created_at"]},"description":"All quotas for the project (max 100, no pagination)"},"quota_groups":{"type":"array","items":{"$ref":"#/components/schemas/QuotaGroup"},"description":"Every live quota group on the project, oldest first, empty groups included. Each quota's `group` points at one of these by id. Empty when the project has no groups."},"meta":{"type":"object","properties":{"total":{"type":"integer","minimum":0,"description":"Total number of quotas matching the filter","example":3}},"required":["total"]}},"required":["quotas","quota_groups","meta"],"x-tags":["Quotas"]},"CreateQuotaBody":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100,"description":"Display name (1-100 chars)","example":"Senior PMs (US)"},"limit":{"type":"integer","exclusiveMinimum":0,"description":"Target participant count","example":10},"behavior":{"type":"string","enum":["close_when_full","allow_overflow"],"default":"close_when_full","description":"Fill behavior. Defaults to close_when_full.","example":"close_when_full"},"criteria":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"type":"object","properties":{"screener_question":{"type":"string","minLength":1,"description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"]},"minItems":1,"description":"Filters AND-ed within this group"}},"required":["filters"]},"minItems":1,"description":"Filter groups OR-ed together. Filters within each group are AND-ed. At most 20 groups and 20 filters per group. At most one group may hold several filters, unless every multi-filter group shares a filter, e.g. [[A, C], [B, C]] for \"(A or B) and C\"; other shapes are rejected."}},"required":["filter_groups"],"description":"Filter groups defining which participants count toward this quota"},"group_id":{"type":["string","null"],"minLength":1,"description":"Quota group to create the quota in (ids come from `quota_groups` on the quota list). Omit or send `null` for an ungrouped quota. The criteria must not overlap any quota already in the group (409). The project must have quota groups turned on in its settings, or this returns 422."}},"required":["name","limit","criteria"],"x-tags":["Quotas"]},"UpdateQuotaBody":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100,"description":"Display name (1-100 chars)"},"limit":{"type":"integer","exclusiveMinimum":0,"description":"Target participant count"},"behavior":{"type":"string","enum":["close_when_full","allow_overflow"],"description":"How the quota behaves at capacity. `close_when_full` blocks new participants; `allow_overflow` continues accepting.","example":"close_when_full"},"criteria":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"type":"object","properties":{"screener_question":{"type":"string","minLength":1,"description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"]},"minItems":1,"description":"Filters AND-ed within this group"}},"required":["filters"]},"minItems":1,"description":"Filter groups OR-ed together. Filters within each group are AND-ed. At most 20 groups and 20 filters per group. At most one group may hold several filters, unless every multi-filter group shares a filter, e.g. [[A, C], [B, C]] for \"(A or B) and C\"; other shapes are rejected."}},"required":["filter_groups"],"description":"Full replacement of criteria (not a partial patch). Must include all filter_groups and filters."},"group_id":{"type":["string","null"],"minLength":1,"description":"Move the quota into a quota group (ids come from `quota_groups` on the quota list), or send `null` to ungroup it. Omit to leave the group unchanged. The criteria must not overlap any quota already in the target group (409), and a quota with live grouped participants cannot be ungrouped (409). The project must have quota groups turned on in its settings, or this returns 422."}},"x-tags":["Quotas"]},"ParticipantUpdatePayment":{"type":"object","properties":{"method":{"type":"string","enum":["credits","manual"],"description":"Payment method to use when accepting the participant"},"amount_cents":{"type":"integer","exclusiveMinimum":0,"maximum":100000,"description":"Incentive amount in cents (USD). Must be a positive integer up to 100000 ($1000).","example":5000},"payment_intent_id":{"type":"string","description":"Stripe PaymentIntent ID for manual card payments","example":"pi_3PxYzA2eZvKYlo2C1234abcd"}},"required":["method","amount_cents","payment_intent_id"],"x-tags":["Participants"]},"ParticipantUpdateBody":{"type":"object","properties":{"status":{"type":"string","enum":["accepted","rejected"],"description":"Target lifecycle status. `accepted` completes quality review and triggers payment. `rejected` removes the participant from the project. Omit to leave the status unchanged."},"payment_method":{"type":"string","enum":["credits","manual"],"description":"Payment method for accepted participants. When status is 'accepted', both payment_method and amount_cents are required for self-recruited participants. Check credits balance via GET /credits/balance before paying."},"amount_cents":{"type":"integer","exclusiveMinimum":0,"maximum":100000,"description":"Incentive amount in cents (USD). Required when `payment_method` is provided. Maximum 100000 ($1000) per participant.","example":5000},"reason":{"type":"string","minLength":1,"maxLength":1000,"description":"Rejection reason. Optional but recommended when status is 'rejected' for audit trail. Stored as rejection_reason on the participant record.","example":"Participant did not meet the screening criteria."},"name":{"type":"string","minLength":1,"maxLength":100,"description":"Participant name (1-100 characters, trimmed). Requires full name access (the caller's `pii_acl.name` is `full`), else 403. Refused with 422 when the project anonymizes participant names, because the stored name is then an alias.","example":"Jane Doe"},"custom_fields":{"type":"object","additionalProperties":{"anyOf":[{"type":"string","maxLength":5000},{"type":"array","items":{"type":"string"}},{"type":"null"}]},"description":"Custom field values to set, keyed by field id from `custom_fields` on `GET /projects/{project_id}`. A `text` field takes a string, a `single_select` field one option `value`, a `multi_select` field a list of option `value`s (the whole list it should hold). `null` clears the field. Fields left out keep their values. An unknown field id, a value that is not an option, or the wrong shape returns 422. A participant identifier value another participant holds returns 409.","example":{"custom_8f2c1a7e":["power_user"]}},"excluded_from_analysis":{"type":"boolean","description":"Whether the participant is left out of the project's analysis (Summary, themes, and charts). Setting `false` includes them again.","example":true},"quota_ids":{"type":"array","items":{"type":"string","minLength":1},"maxItems":100,"description":"Every quota the participant belongs to, replacing their current set (read it from `quota_ids` on the participant first). Leaving a quota out removes the participant from it, and `[]` removes them from every quota. Takes at most one quota per quota group, and at most one quota when the project has quota groups turned off (422). A quota that is not live on this project returns 404. Screener criteria are not checked: this is a researcher override. A participant whose interview counts carries that completion into each quota they are added to; removing them releases it. Requires access to every demographic field, because reads hide quota membership without it: otherwise `403` with `code: demographics_access_required`.","example":["cm7a9qk2x0001jv04quota01"]}},"x-tags":["Participants"]},"TranscriptUtterance":{"type":"object","properties":{"speaker":{"type":"string","description":"Resolved speaker name or fallback label","example":"Moderator"},"speaker_role":{"type":["string","null"],"enum":["moderator","participant",null],"description":"AI-classified role: moderator (interviewer) or participant (interviewee). Null for legacy sessions processed before role classification was introduced.","example":"moderator"},"start_ms":{"type":"integer","minimum":0,"description":"Utterance start time in milliseconds","example":160},"end_ms":{"type":"integer","minimum":0,"description":"Utterance end time in milliseconds","example":5320},"text":{"type":"string","description":"Utterance text content","example":"Tell me about your experience with the product."},"confidence":{"type":["number","null"],"description":"Transcription confidence score (0-1), null if unavailable","example":0.97}},"required":["speaker","speaker_role","start_ms","end_ms","text","confidence"],"x-tags":["Transcripts"]},"TranscriptListQuery":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0,"default":500,"description":"Maximum utterances to return (default 500, max 2000)","example":500},"cursor":{"type":"string","description":"Opaque pagination cursor from a previous response. Encodes position across multiple recordings."},"full":{"type":["boolean","null"],"default":false,"description":"Return all utterances across all recordings with no pagination. Overrides limit and cursor."},"speaker_role":{"type":"string","enum":["participant","moderator"],"description":"Filter to only moderator or participant utterances","example":"participant"},"exclude":{"type":"string","description":"Comma-separated sections to leave out. Valid values: questions. `questions` drops `item_id`, `turn_kind`, `follow_up_number`, `has_question_data` and `meta.questions`, and skips the question timing lookup.","example":"questions"}},"x-tags":["Transcripts"]},"TranscriptListUtterance":{"type":"object","properties":{"speaker":{"type":"string","description":"Resolved speaker name or fallback label","example":"Moderator"},"speaker_role":{"type":["string","null"],"enum":["moderator","participant",null],"description":"AI-classified role: moderator (interviewer) or participant (interviewee). Null for legacy sessions processed before role classification was introduced.","example":"moderator"},"start_ms":{"type":"integer","minimum":0,"description":"Utterance start time in milliseconds","example":160},"end_ms":{"type":"integer","minimum":0,"description":"Utterance end time in milliseconds","example":5320},"text":{"type":"string","description":"Utterance text content","example":"Tell me about your experience with the product."},"confidence":{"type":["number","null"],"description":"Transcription confidence score (0-1), null if unavailable","example":0.97},"recording_id":{"type":"string","description":"Recording this utterance belongs to","example":"cm7d6jn1r0004jy04lm5x8b7t"},"item_id":{"type":["string","null"],"description":"Question guide item this utterance was said under; matches the item `id` from the questionnaire endpoint. Null before the first question, after the last one, or when the recording has no question data (human-moderated or uploaded). Omitted with `exclude=questions`.","example":"b7d1c9e2-4f3a-4c8e-9a1d-2e5f6a7b8c9d"},"turn_kind":{"type":["string","null"],"enum":["main","follow_up",null],"description":"Which exchange of its guide question this utterance belongs to: main or follow_up. The participant's answer takes the label of the question before it. May gain values. Null when `item_id` is null, when the question has no stored conversation, or when its moderator turns do not match the questions asked. Omitted with `exclude=questions`.","example":"follow_up"},"follow_up_number":{"type":["integer","null"],"exclusiveMinimum":0,"description":"Which follow-up (1, 2, ...) when `turn_kind` is `follow_up`, else null. Omitted with `exclude=questions`.","example":1}},"required":["speaker","speaker_role","start_ms","end_ms","text","confidence","recording_id"],"x-tags":["Transcripts"]},"TranscriptRecordingMeta":{"type":"object","properties":{"recording_id":{"type":"string","description":"Recording identifier","example":"cm7d6jn1r0004jy04lm5x8b7t"},"utterance_count":{"type":"integer","minimum":0,"description":"Total utterances in this recording","example":87},"duration_seconds":{"type":"integer","minimum":0,"description":"Recording duration in seconds","example":568},"has_question_data":{"type":"boolean","description":"Whether this recording has question timing, so its utterances can carry an `item_id`. False for human-moderated and uploaded recordings. Omitted with `exclude=questions`.","example":true}},"required":["recording_id","utterance_count","duration_seconds"],"x-tags":["Transcripts"]},"TranscriptQuestion":{"type":"object","properties":{"item_id":{"type":"string","description":"Question guide item","example":"b7d1c9e2-4f3a-4c8e-9a1d-2e5f6a7b8c9d"},"recording_id":{"type":"string","description":"Recording the question was asked in","example":"cm7d6jn1r0004jy04lm5x8b7t"},"question_text":{"type":"string","description":"The question text the AI moderator worked from, in the guide language (may differ from the transcript language when the interview was conducted in another language)","example":"How did you decide which plan to buy?"},"start_ms":{"type":"integer","minimum":0,"description":"Window start, on the same clock as utterance `start_ms`. An utterance belongs to this question when `start_ms <= utterance.start_ms < end_ms`.","example":8000},"end_ms":{"type":["integer","null"],"minimum":0,"description":"Window end (exclusive), or null when the question was still running when the recording ended","example":58000},"start_estimated":{"type":"boolean","description":"True when the start could not be anchored to the moderator transcript line asking the question and is estimated instead, so utterances near this boundary may belong to the neighbouring question","example":false},"follow_up_count":{"type":["integer","null"],"minimum":0,"description":"How many follow-ups the AI moderator asked in this window. Null when `turn_source` is null.","example":2},"turn_source":{"type":["string","null"],"enum":["agent","inferred",null],"description":"Where this question's `turn_kind` labels come from. `agent`: recorded by the AI moderator when it asked each follow-up, and placed on the transcript by time. `inferred`: interviews before the moderator recorded this, and questions without dynamic follow-ups; each moderator turn (what it says between two participant replies) counts as the next follow-up, so a repeated question counts as one. Null when the question has no stored conversation or its turns do not match the questions asked.","example":"agent"}},"required":["item_id","recording_id","question_text","start_ms","end_ms","start_estimated","follow_up_count","turn_source"],"x-tags":["Transcripts"]},"TranscriptListResponse":{"type":"object","properties":{"utterances":{"type":"array","items":{"$ref":"#/components/schemas/TranscriptListUtterance"},"description":"Transcript utterances in chronological order across recordings"},"meta":{"type":"object","properties":{"recordings":{"type":"array","items":{"$ref":"#/components/schemas/TranscriptRecordingMeta"},"description":"Per-recording utterance counts and durations, in chronological order"},"questions":{"type":"array","items":{"$ref":"#/components/schemas/TranscriptQuestion"},"description":"Question windows across all recordings, in recording order then start time. Omitted with `exclude=questions`."},"total_utterance_count":{"type":"integer","minimum":0,"description":"Total utterances across all recordings"},"pagination":{"type":["object","null"],"properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"],"description":"Pagination info, or null when ?full=true"}},"required":["recordings","total_utterance_count","pagination"]}},"required":["utterances","meta"],"x-tags":["Transcripts"]},"ParticipantDetailListItem":{"type":"object","properties":{"id":{"type":"string","description":"Unique participant identifier","example":"cm7c4hp5w0003jm04kx8v2n9r"},"status":{"type":"string","enum":["unqualified","qualified","invited","in_review","rejected","accepted"],"description":"Participant status in the study lifecycle","example":"accepted"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-06-01T12:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last modification timestamp","example":"2025-06-15T09:30:00.000Z"},"interview_count":{"type":"integer","minimum":0,"description":"Number of completed interviews","example":1},"total_duration_seconds":{"type":"integer","minimum":0,"description":"Total interview duration across all interviews (seconds)","example":568},"survey_response_count":{"type":"integer","minimum":0,"description":"Number of survey responses","example":5},"screener_response_count":{"type":"integer","minimum":0,"description":"Number of screener question responses","example":3},"variant_assignments":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantVariantAssignment"},"description":"Which variant the participant was assigned in each variant block, one object per block. A block holds several ids when its `variant_subset_size` is above 1. Assignment happens when the interview starts, so it does not prove the participant reached the block; `[]` until then, or when the project has no variant blocks. An id can be missing from the current questionnaire if that variant was deleted after assignment. Always present, not excludable via `?exclude=`.","example":[{"vb_pricing":["vc_monthly"]},{"vb_concepts":["vc_x","vc_z"]}]},"excluded_from_analysis":{"type":"boolean","description":"Whether the participant is left out of the project's analysis (Summary, themes, and charts). Set it with `PATCH /participants/{participant_id}`.","example":false},"booked_interviews":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantBooking"},"description":"The interview slots this participant booked on a human-moderated project, newest start first. A cancel and rebook adds a row, so earlier rows are the history. `[]` on AI-moderated projects and for participants who never booked."},"profile":{"type":"object","properties":{"job_title":{"type":"string","description":"Current job title","example":"Product Manager"},"gender":{"type":"string","description":"Self-reported gender","example":"Female"},"age":{"type":"string","description":"Age or age range","example":"25-34"},"level_of_education":{"type":"string","description":"Highest education level","example":"Bachelor's degree"},"race_ethnicity":{"type":"string","description":"Self-reported race or ethnicity"},"household_income_in_usd":{"type":"string","description":"Household income bracket in USD","example":"$75,000 - $99,999"},"seniority":{"type":"string","description":"Professional seniority level","example":"Senior"},"location":{"type":"string","description":"Geographic location","example":"San Francisco, CA"},"company_size":{"type":"string","description":"Size of employer organization","example":"51-200 employees"}},"description":"Demographic profile resolved from profile cache and custom fields"},"quota":{"type":["object","null"],"properties":{"id":{"type":"string","description":"Quota identifier","example":"cm7f2kp9w0008jm04xt5v3n7s"},"name":{"type":"string","description":"Quota name","example":"Women 25-34"}},"required":["id","name"],"description":"Quota assignment. Always present: `null` when the participant is not assigned to a quota, or when the caller's PII access hides any demographic field, because a quota names a demographic group."},"quota_ids":{"type":"array","items":{"type":"string"},"description":"Every quota the participant belongs to, primary first, including quotas already completed. A project using quota groups counts a participant in one quota per group, so this can hold several ids; otherwise it is `[quota.id]` or `[]`. `[]` for a caller whose PII access hides any demographic field. Always present, not excludable via `?exclude=`.","example":["cm7f2kp9w0008jm04xt5v3n7s"]},"custom_fields":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantCustomField"},"description":"The project's custom fields (participants table columns, including Tags and AI-coded categories) that hold a value for this participant, in column order. The API leaves out a field the participant holds no value in, so `[]` means the participant holds no values. Set them with `PATCH /projects/{project_id}/participants/{participant_id}`."},"summary":{"type":["object","null"],"properties":{"sections":{"type":"array","items":{"type":"object","properties":{"heading":{"type":"string","description":"Section heading"},"content":{"type":"string","description":"Section content (markdown)"}},"required":["heading","content"]},"description":"Structured summary sections"}},"required":["sections"],"description":"AI-generated participant summary, or `null` when there is none."},"recordings":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Recording"},"description":"The participant's first recordings, oldest first"},"has_more":{"type":"boolean","description":"Whether the participant has more recordings than `data` holds"}},"required":["data","has_more"],"description":"Absent when the recordings axis is denied."},"screener_responses":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener response identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"question_text":{"type":"string","description":"The screener question text","example":"Which of the following products have you purchased in the last 3 months?"},"question_type":{"type":"string","description":"Question type (radio, checkbox, sliderScale, singleLineTextbox, multiLineTextbox, matrix)","example":"checkbox"},"response":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text. For a matrix question, the picked cell as \"Row: Column\"."},"value":{"anyOf":[{"type":"string"},{"type":"number"}]},"is_selected":{"type":"boolean"},"row":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the row of this cell"},"column":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the column of this cell"}},"required":["value"]},"description":"Answer options for this question"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-06-01T12:00:00.000Z"}},"required":["id","question_text","question_type","response","created_at"]},"description":"The participant's first screener responses, oldest first"},"has_more":{"type":"boolean","description":"Whether the participant has more screener responses than `data` holds"}},"required":["data","has_more"],"description":"Absent when the screener responses axis is denied."},"survey_responses":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SurveyResponseListItem"},"description":"The participant's first survey responses, oldest first"},"has_more":{"type":"boolean","description":"Whether the participant has more survey responses than `data` holds"}},"required":["data","has_more"],"description":"Absent when the survey responses axis is denied."},"name":{"type":"string","description":"Participant display name (requires participants:read:pii scope)","example":"Jane Doe"},"email":{"type":["string","null"],"description":"Participant email address (requires participants:read:pii scope). Always absent when the project has participant anonymization enabled (`anonymize_participant_names`).","example":"jane@example.com"},"profile_picture_url":{"type":["string","null"],"description":"URL to profile picture (requires participants:read:pii scope)","example":"https://example.com/photo.jpg"}},"required":["id","status","created_at","updated_at","interview_count","total_duration_seconds","survey_response_count","screener_response_count","variant_assignments","excluded_from_analysis","booked_interviews","quota","quota_ids","custom_fields","summary"],"x-tags":["Participants"]},"ParticipantDetailListResponse":{"type":"object","properties":{"participants":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantDetailListItem"},"description":"Participants sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]},"anonymize_participant_names":{"type":"boolean","description":"Whether participant anonymization is enabled for this project. When true, Strella never returns participant `email` anywhere — API, MCP, or exports — regardless of the caller's `participants:read:email` grant, and `name` values are anonymized aliases. Toggled via the project's Privacy settings or `PATCH /projects` (`anonymize_participant_names`).","example":false}},"required":["participants","meta","anonymize_participant_names"],"x-tags":["Participants"]},"ScreenerMatrixInput":{"type":"object","properties":{"selection_mode":{"type":"string","enum":["single","multiple"],"default":"single","description":"`single`: participants pick one column per row. `multiple`: participants pick any number of columns per row."},"qualify_when":{"type":"string","enum":["all_rows","any_row"],"default":"all_rows","description":"`all_rows`: the question qualifies only when every row passes. `any_row`: it qualifies when any one row passes."},"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing column ID, kept when the question is updated in place and already has this column. Any other value is replaced with a new ID."},"text":{"type":"string","minLength":1,"maxLength":300,"example":"Daily"}},"required":["text"]},"maxItems":7,"description":"Answer columns shared by every row (2-7)."},"rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing row ID, kept when the question is updated in place and already has this row. Any other value is replaced with a new ID."},"text":{"type":"string","minLength":1,"maxLength":300,"example":"Slack"},"cells":{"type":"object","additionalProperties":{"type":"string","enum":["1","2","3"],"description":"How picking this cell counts. Single select: \"1\" qualify, \"2\" disqualify. Multiple selection: \"1\" may select, \"2\" must select, \"3\" disqualify."},"description":"Map of column text to how picking that cell counts. Every column the map leaves out counts as \"1\"."}},"required":["text"]},"maxItems":10,"description":"Rows, each answered on the shared columns (2-10)."}},"required":["columns","rows"],"description":"The grid of a matrix question. Labels must be unique within rows and within columns.","x-tags":["Screeners"]},"ScreenerMatrix":{"type":"object","properties":{"selection_mode":{"type":"string","enum":["single","multiple"],"description":"`single`: participants pick one column per row. `multiple`: participants pick any number of columns per row."},"qualify_when":{"type":"string","enum":["all_rows","any_row"],"description":"`all_rows`: the question qualifies only when every row passes. `any_row`: it qualifies when any one row passes."},"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","example":"Daily"}},"required":["id","text"]}},"rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","example":"Slack"},"cells":{"type":"object","additionalProperties":{"type":"string","enum":["1","2","3"],"description":"How picking this cell counts. Single select: \"1\" qualify, \"2\" disqualify. Multiple selection: \"1\" may select, \"2\" must select, \"3\" disqualify."},"description":"Map of column text to how picking that cell counts, for every column."}},"required":["id","text","cells"]}}},"required":["selection_mode","qualify_when","columns","rows"],"description":"The grid of a matrix question.","x-tags":["Screeners"]},"ScreenerSkipLogic":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["answer_routing"],"description":"Radio question: each answer routes to a different question"},"destinations":{"type":"object","additionalProperties":{"type":"object","properties":{"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["destination_question_id"]},"description":"Map of answer text to destination. Keys match the answer text values."}},"required":["type","destinations"]},{"type":"object","properties":{"type":{"type":"string","enum":["condition"],"description":"Checkbox question: conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"answer_text":{"type":"string","description":"Answer text to match against"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether the answer should be selected or not"}},"required":["answer_text","match"]},"minItems":1,"description":"Conditions to evaluate (primary + additional)"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["slider_threshold"],"description":"Slider question: numeric threshold conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"operator":{"type":"string","enum":["lt","lte","eq","gte","gt"],"description":"Numeric comparison: lt = less than, lte = less than or equal, eq = equal, gte = greater than or equal, gt = greater than."},"threshold":{"type":"number","description":"Numeric threshold value compared against the slider answer."}},"required":["operator","threshold"]},"minItems":1,"description":"One or more numeric threshold conditions (primary + additional)."},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple threshold conditions."},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of a later question, null for end of screener, or an empty string for the next question"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["matrix_condition"],"description":"Matrix question: conditions on cells evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"row_text":{"type":"string","description":"Text of the row to match"},"column_text":{"type":"string","description":"Text of the column to match"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether that cell should be selected or not"}},"required":["row_text","column_text","match"]},"minItems":1,"description":"Cell conditions to evaluate"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]}],"description":"Skip logic configuration for a screener question. Discriminated by `type`: `answer_routing` (radio), `condition` (checkbox), `slider_threshold` (slider_scale), `matrix_condition` (matrix).","x-tags":["Screeners"]},"ScreenerQuestionType":{"type":"string","enum":["radio","checkbox","slider_scale","single_line_text","multi_line_text","matrix"],"x-tags":["Screeners"]},"ScreenerAnswerInput":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":300,"description":"Answer text displayed to participants","example":"Yes, within the last month"},"answer_value":{"type":"string","enum":["1","2","3"],"description":"Qualifying value — see ScreenerAnswerValue for per-type semantics","example":"1"}},"required":["text","answer_value"],"x-tags":["Screeners"]},"ScreenerAnswerOutput":{"type":"object","properties":{"text":{"type":"string","description":"Answer text displayed to participants","example":"Yes, within the last month"},"answer_value":{"type":"string","description":"Radio: \"1\" qualify, \"2\" disqualify. Checkbox: \"1\" may select, \"2\" must select, \"3\" disqualify.","example":"1"}},"required":["text","answer_value"],"x-tags":["Screeners"]},"ScreenerQuestionInput":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing question ID. When provided and matches an existing question, the question is updated in place — preserving its ID so downstream references stay linked. When omitted, a new ID is generated.","example":"cm7g3nq8r0007jm04wv5t2k6s"},"text":{"type":"string","minLength":1,"maxLength":500,"description":"Question text","example":"Which products have you purchased recently?"},"question_type":{"type":"string","enum":["radio","checkbox","slider_scale","single_line_text","multi_line_text","matrix"],"description":"Type of screener question","example":"radio"},"is_required":{"type":"boolean","default":true,"description":"Whether the participant must answer before continuing. Defaults to `true`, the same as a question added in the app. Send `false` to let participants skip it.","example":true},"answers":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":300,"description":"Answer text displayed to participants","example":"Yes, within the last month"},"answer_value":{"type":"string","enum":["1","2","3"],"description":"Qualifying value — see ScreenerAnswerValue for per-type semantics","example":"1"}},"required":["text","answer_value"]},"description":"Answer options (required for radio and checkbox, ignored for other types)"},"include_other_option":{"type":"boolean","description":"Include a free-text \"Other\" option (radio and checkbox only). Refused on matrix and not stored on the other types.","example":false},"other_option_answer_value":{"type":"string","enum":["1","2","3"],"description":"Qualifying value for the \"Other\" option (radio and checkbox only, not stored on other types)","example":"1"},"slider_config":{"type":"object","properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1}},"required":["min_value","max_value","step"],"description":"Slider configuration (required for slider_scale). Refused on matrix and not stored on the other types."},"matrix":{"type":"object","properties":{"selection_mode":{"type":"string","enum":["single","multiple"],"default":"single","description":"`single`: participants pick one column per row. `multiple`: participants pick any number of columns per row."},"qualify_when":{"type":"string","enum":["all_rows","any_row"],"default":"all_rows","description":"`all_rows`: the question qualifies only when every row passes. `any_row`: it qualifies when any one row passes."},"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing column ID, kept when the question is updated in place and already has this column. Any other value is replaced with a new ID."},"text":{"type":"string","minLength":1,"maxLength":300,"example":"Daily"}},"required":["text"]},"maxItems":7,"description":"Answer columns shared by every row (2-7)."},"rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing row ID, kept when the question is updated in place and already has this row. Any other value is replaced with a new ID."},"text":{"type":"string","minLength":1,"maxLength":300,"example":"Slack"},"cells":{"type":"object","additionalProperties":{"type":"string","enum":["1","2","3"],"description":"How picking this cell counts. Single select: \"1\" qualify, \"2\" disqualify. Multiple selection: \"1\" may select, \"2\" must select, \"3\" disqualify."},"description":"Map of column text to how picking that cell counts. Every column the map leaves out counts as \"1\"."}},"required":["text"]},"maxItems":10,"description":"Rows, each answered on the shared columns (2-10)."}},"required":["columns","rows"],"description":"Rows, columns and per-cell qualification (required for matrix, not allowed on other types)"},"skip_logic_id":{"type":"string","minLength":1,"description":"Stable identifier referenced by other questions' `skip_logic.destinations[].destination_question_id` / `destination_question_id`. Provide on update to preserve referencing rules; omitted on create (server generates).","example":"sl_a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"skip_logic":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["answer_routing"],"description":"Radio question: each answer routes to a different question"},"destinations":{"type":"object","additionalProperties":{"type":"object","properties":{"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["destination_question_id"]},"description":"Map of answer text to destination. Keys match the answer text values."}},"required":["type","destinations"]},{"type":"object","properties":{"type":{"type":"string","enum":["condition"],"description":"Checkbox question: conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"answer_text":{"type":"string","description":"Answer text to match against"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether the answer should be selected or not"}},"required":["answer_text","match"]},"minItems":1,"description":"Conditions to evaluate (primary + additional)"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["slider_threshold"],"description":"Slider question: numeric threshold conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"operator":{"type":"string","enum":["lt","lte","eq","gte","gt"],"description":"Numeric comparison: lt = less than, lte = less than or equal, eq = equal, gte = greater than or equal, gt = greater than."},"threshold":{"type":"number","description":"Numeric threshold value compared against the slider answer."}},"required":["operator","threshold"]},"minItems":1,"description":"One or more numeric threshold conditions (primary + additional)."},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple threshold conditions."},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of a later question, null for end of screener, or an empty string for the next question"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["matrix_condition"],"description":"Matrix question: conditions on cells evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"row_text":{"type":"string","description":"Text of the row to match"},"column_text":{"type":"string","description":"Text of the column to match"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether that cell should be selected or not"}},"required":["row_text","column_text","match"]},"minItems":1,"description":"Cell conditions to evaluate"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"null"}],"description":"Skip logic configuration. Discriminated by `type`: `answer_routing` (radio), `condition` (checkbox), `slider_threshold` (slider_scale), `matrix_condition` (matrix). Pass `null` to clear an existing rule. Omit to leave the existing rule untouched on update."}},"required":["text","question_type"],"x-tags":["Screeners"]},"PutScreenerBody":{"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing question ID. When provided and matches an existing question, the question is updated in place — preserving its ID so downstream references stay linked. When omitted, a new ID is generated.","example":"cm7g3nq8r0007jm04wv5t2k6s"},"text":{"type":"string","minLength":1,"maxLength":500,"description":"Question text","example":"Which products have you purchased recently?"},"question_type":{"type":"string","enum":["radio","checkbox","slider_scale","single_line_text","multi_line_text","matrix"],"description":"Type of screener question","example":"radio"},"is_required":{"type":"boolean","default":true,"description":"Whether the participant must answer before continuing. Defaults to `true`, the same as a question added in the app. Send `false` to let participants skip it.","example":true},"answers":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":300,"description":"Answer text displayed to participants","example":"Yes, within the last month"},"answer_value":{"type":"string","enum":["1","2","3"],"description":"Qualifying value — see ScreenerAnswerValue for per-type semantics","example":"1"}},"required":["text","answer_value"]},"description":"Answer options (required for radio and checkbox, ignored for other types)"},"include_other_option":{"type":"boolean","description":"Include a free-text \"Other\" option (radio and checkbox only). Refused on matrix and not stored on the other types.","example":false},"other_option_answer_value":{"type":"string","enum":["1","2","3"],"description":"Qualifying value for the \"Other\" option (radio and checkbox only, not stored on other types)","example":"1"},"slider_config":{"type":"object","properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1}},"required":["min_value","max_value","step"],"description":"Slider configuration (required for slider_scale). Refused on matrix and not stored on the other types."},"matrix":{"type":"object","properties":{"selection_mode":{"type":"string","enum":["single","multiple"],"default":"single","description":"`single`: participants pick one column per row. `multiple`: participants pick any number of columns per row."},"qualify_when":{"type":"string","enum":["all_rows","any_row"],"default":"all_rows","description":"`all_rows`: the question qualifies only when every row passes. `any_row`: it qualifies when any one row passes."},"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing column ID, kept when the question is updated in place and already has this column. Any other value is replaced with a new ID."},"text":{"type":"string","minLength":1,"maxLength":300,"example":"Daily"}},"required":["text"]},"maxItems":7,"description":"Answer columns shared by every row (2-7)."},"rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing row ID, kept when the question is updated in place and already has this row. Any other value is replaced with a new ID."},"text":{"type":"string","minLength":1,"maxLength":300,"example":"Slack"},"cells":{"type":"object","additionalProperties":{"type":"string","enum":["1","2","3"],"description":"How picking this cell counts. Single select: \"1\" qualify, \"2\" disqualify. Multiple selection: \"1\" may select, \"2\" must select, \"3\" disqualify."},"description":"Map of column text to how picking that cell counts. Every column the map leaves out counts as \"1\"."}},"required":["text"]},"maxItems":10,"description":"Rows, each answered on the shared columns (2-10)."}},"required":["columns","rows"],"description":"Rows, columns and per-cell qualification (required for matrix, not allowed on other types)"},"skip_logic_id":{"type":"string","minLength":1,"description":"Stable identifier referenced by other questions' `skip_logic.destinations[].destination_question_id` / `destination_question_id`. Provide on update to preserve referencing rules; omitted on create (server generates).","example":"sl_a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"skip_logic":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["answer_routing"],"description":"Radio question: each answer routes to a different question"},"destinations":{"type":"object","additionalProperties":{"type":"object","properties":{"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["destination_question_id"]},"description":"Map of answer text to destination. Keys match the answer text values."}},"required":["type","destinations"]},{"type":"object","properties":{"type":{"type":"string","enum":["condition"],"description":"Checkbox question: conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"answer_text":{"type":"string","description":"Answer text to match against"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether the answer should be selected or not"}},"required":["answer_text","match"]},"minItems":1,"description":"Conditions to evaluate (primary + additional)"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["slider_threshold"],"description":"Slider question: numeric threshold conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"operator":{"type":"string","enum":["lt","lte","eq","gte","gt"],"description":"Numeric comparison: lt = less than, lte = less than or equal, eq = equal, gte = greater than or equal, gt = greater than."},"threshold":{"type":"number","description":"Numeric threshold value compared against the slider answer."}},"required":["operator","threshold"]},"minItems":1,"description":"One or more numeric threshold conditions (primary + additional)."},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple threshold conditions."},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of a later question, null for end of screener, or an empty string for the next question"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["matrix_condition"],"description":"Matrix question: conditions on cells evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"row_text":{"type":"string","description":"Text of the row to match"},"column_text":{"type":"string","description":"Text of the column to match"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether that cell should be selected or not"}},"required":["row_text","column_text","match"]},"minItems":1,"description":"Cell conditions to evaluate"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"null"}],"description":"Skip logic configuration. Discriminated by `type`: `answer_routing` (radio), `condition` (checkbox), `slider_threshold` (slider_scale), `matrix_condition` (matrix). Pass `null` to clear an existing rule. Omit to leave the existing rule untouched on update."}},"required":["text","question_type"]},"minItems":1,"maxItems":40,"description":"Ordered list of screener questions (1-40). Replaces all existing questions. Use DELETE to remove all questions."}},"required":["questions"],"x-tags":["Screeners"]},"ScreenerQuestionItem":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener question identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"text":{"type":"string","description":"Question text","example":"Which products have you purchased recently?"},"question_type":{"type":"string","enum":["radio","checkbox","slider_scale","single_line_text","multi_line_text","matrix"],"description":"Type of screener question"},"is_required":{"type":"boolean","description":"Whether the question must be answered"},"position":{"type":"integer","description":"Display position (0-based)","example":0},"answers":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text displayed to participants","example":"Yes, within the last month"},"answer_value":{"type":"string","description":"Radio: \"1\" qualify, \"2\" disqualify. Checkbox: \"1\" may select, \"2\" must select, \"3\" disqualify.","example":"1"}},"required":["text","answer_value"]},"description":"Answer options"},"include_other_option":{"type":"boolean","description":"Whether an \"Other\" free-text option is included. Always `false` on question types other than radio and checkbox."},"other_option_answer_value":{"type":"string","description":"Qualifying value for the \"Other\" option. Always `\"1\"` on question types other than radio and checkbox."},"slider_config":{"type":["object","null"],"properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1}},"required":["min_value","max_value","step"],"description":"Slider configuration (null for non-slider questions)"},"matrix":{"type":["object","null"],"properties":{"selection_mode":{"type":"string","enum":["single","multiple"],"description":"`single`: participants pick one column per row. `multiple`: participants pick any number of columns per row."},"qualify_when":{"type":"string","enum":["all_rows","any_row"],"description":"`all_rows`: the question qualifies only when every row passes. `any_row`: it qualifies when any one row passes."},"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","example":"Daily"}},"required":["id","text"]}},"rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","example":"Slack"},"cells":{"type":"object","additionalProperties":{"type":"string","enum":["1","2","3"],"description":"How picking this cell counts. Single select: \"1\" qualify, \"2\" disqualify. Multiple selection: \"1\" may select, \"2\" must select, \"3\" disqualify."},"description":"Map of column text to how picking that cell counts, for every column."}},"required":["id","text","cells"]}}},"required":["selection_mode","qualify_when","columns","rows"],"description":"Rows, columns and per-cell qualification (null for non-matrix questions)"},"skip_logic_id":{"type":["string","null"],"description":"Stable identifier for skip logic references. Other questions reference this ID in their skip logic destinations.","example":"sl_a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"skip_logic":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["answer_routing"],"description":"Radio question: each answer routes to a different question"},"destinations":{"type":"object","additionalProperties":{"type":"object","properties":{"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["destination_question_id"]},"description":"Map of answer text to destination. Keys match the answer text values."}},"required":["type","destinations"]},{"type":"object","properties":{"type":{"type":"string","enum":["condition"],"description":"Checkbox question: conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"answer_text":{"type":"string","description":"Answer text to match against"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether the answer should be selected or not"}},"required":["answer_text","match"]},"minItems":1,"description":"Conditions to evaluate (primary + additional)"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["slider_threshold"],"description":"Slider question: numeric threshold conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"operator":{"type":"string","enum":["lt","lte","eq","gte","gt"],"description":"Numeric comparison: lt = less than, lte = less than or equal, eq = equal, gte = greater than or equal, gt = greater than."},"threshold":{"type":"number","description":"Numeric threshold value compared against the slider answer."}},"required":["operator","threshold"]},"minItems":1,"description":"One or more numeric threshold conditions (primary + additional)."},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple threshold conditions."},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of a later question, null for end of screener, or an empty string for the next question"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["matrix_condition"],"description":"Matrix question: conditions on cells evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"row_text":{"type":"string","description":"Text of the row to match"},"column_text":{"type":"string","description":"Text of the column to match"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether that cell should be selected or not"}},"required":["row_text","column_text","match"]},"minItems":1,"description":"Cell conditions to evaluate"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"null"}],"description":"Skip logic configuration. Determines which question to show next based on the answer. Null when no skip logic is configured."},"is_draft":{"type":"boolean","description":"Whether this question is a draft (unpublished). Only present when `include_draft=true` is passed."}},"required":["id","text","question_type","is_required","position","answers","include_other_option","other_option_answer_value","slider_config","matrix","skip_logic_id","skip_logic"],"x-tags":["Screeners"]},"ScreenerResponse":{"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener question identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"text":{"type":"string","description":"Question text","example":"Which products have you purchased recently?"},"question_type":{"type":"string","enum":["radio","checkbox","slider_scale","single_line_text","multi_line_text","matrix"],"description":"Type of screener question"},"is_required":{"type":"boolean","description":"Whether the question must be answered"},"position":{"type":"integer","description":"Display position (0-based)","example":0},"answers":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text displayed to participants","example":"Yes, within the last month"},"answer_value":{"type":"string","description":"Radio: \"1\" qualify, \"2\" disqualify. Checkbox: \"1\" may select, \"2\" must select, \"3\" disqualify.","example":"1"}},"required":["text","answer_value"]},"description":"Answer options"},"include_other_option":{"type":"boolean","description":"Whether an \"Other\" free-text option is included. Always `false` on question types other than radio and checkbox."},"other_option_answer_value":{"type":"string","description":"Qualifying value for the \"Other\" option. Always `\"1\"` on question types other than radio and checkbox."},"slider_config":{"type":["object","null"],"properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1}},"required":["min_value","max_value","step"],"description":"Slider configuration (null for non-slider questions)"},"matrix":{"type":["object","null"],"properties":{"selection_mode":{"type":"string","enum":["single","multiple"],"description":"`single`: participants pick one column per row. `multiple`: participants pick any number of columns per row."},"qualify_when":{"type":"string","enum":["all_rows","any_row"],"description":"`all_rows`: the question qualifies only when every row passes. `any_row`: it qualifies when any one row passes."},"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","example":"Daily"}},"required":["id","text"]}},"rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","example":"Slack"},"cells":{"type":"object","additionalProperties":{"type":"string","enum":["1","2","3"],"description":"How picking this cell counts. Single select: \"1\" qualify, \"2\" disqualify. Multiple selection: \"1\" may select, \"2\" must select, \"3\" disqualify."},"description":"Map of column text to how picking that cell counts, for every column."}},"required":["id","text","cells"]}}},"required":["selection_mode","qualify_when","columns","rows"],"description":"Rows, columns and per-cell qualification (null for non-matrix questions)"},"skip_logic_id":{"type":["string","null"],"description":"Stable identifier for skip logic references. Other questions reference this ID in their skip logic destinations.","example":"sl_a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"skip_logic":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["answer_routing"],"description":"Radio question: each answer routes to a different question"},"destinations":{"type":"object","additionalProperties":{"type":"object","properties":{"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["destination_question_id"]},"description":"Map of answer text to destination. Keys match the answer text values."}},"required":["type","destinations"]},{"type":"object","properties":{"type":{"type":"string","enum":["condition"],"description":"Checkbox question: conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"answer_text":{"type":"string","description":"Answer text to match against"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether the answer should be selected or not"}},"required":["answer_text","match"]},"minItems":1,"description":"Conditions to evaluate (primary + additional)"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["slider_threshold"],"description":"Slider question: numeric threshold conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"operator":{"type":"string","enum":["lt","lte","eq","gte","gt"],"description":"Numeric comparison: lt = less than, lte = less than or equal, eq = equal, gte = greater than or equal, gt = greater than."},"threshold":{"type":"number","description":"Numeric threshold value compared against the slider answer."}},"required":["operator","threshold"]},"minItems":1,"description":"One or more numeric threshold conditions (primary + additional)."},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple threshold conditions."},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of a later question, null for end of screener, or an empty string for the next question"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["matrix_condition"],"description":"Matrix question: conditions on cells evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"row_text":{"type":"string","description":"Text of the row to match"},"column_text":{"type":"string","description":"Text of the column to match"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether that cell should be selected or not"}},"required":["row_text","column_text","match"]},"minItems":1,"description":"Cell conditions to evaluate"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"null"}],"description":"Skip logic configuration. Determines which question to show next based on the answer. Null when no skip logic is configured."},"is_draft":{"type":"boolean","description":"Whether this question is a draft (unpublished). Only present when `include_draft=true` is passed."}},"required":["id","text","question_type","is_required","position","answers","include_other_option","other_option_answer_value","slider_config","matrix","skip_logic_id","skip_logic"]},"description":"Screener questions sorted by position (ascending). Changes via PUT are immediately live."}},"required":["questions"],"x-tags":["Screeners"]},"ProjectListQuery":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"cursor":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"name":{"type":"string","description":"Case-insensitive contains match on project name and participant-facing name","example":"onboarding"},"slug":{"type":"string","description":"Exact slug match. Mutually exclusive with slug_contains — providing both returns 422.","example":"my-project-6b864d"},"slug_contains":{"type":"string","description":"Case-insensitive partial match on project slug","example":"my-project"},"workspace_id":{"type":"string","description":"Filter by workspace ID"},"detail":{"type":"string","enum":["full"],"description":"When set to \"full\", each list item includes all fields from the single-resource endpoint (e.g., description, screener_required for projects; embedded sessions for interviews; profile for participants). Omit for lighter responses when only summary fields are needed. Pagination behavior (limit, cursor, has_more) is unchanged.","example":"full"},"min_interviews_count":{"type":["integer","null"],"minimum":0,"description":"Minimum number of completed interviews (inclusive). 1 participant = 1 interview.","example":"1"},"has_screener":{"type":"string","enum":["true","false"],"description":"Filter by whether the project has published screener questions","example":"true"},"has_recordings":{"type":"string","enum":["true","false"],"description":"Filter by whether the project has at least one session with a playable recording, in any layout","example":"true"},"min_questionnaire_items_count":{"type":["integer","null"],"minimum":0,"description":"Minimum number of questionnaire items (inclusive)","example":"1"},"has_section_blocks":{"type":"string","enum":["true","false"],"description":"Filter by whether the questionnaire contains section blocks","example":"true"},"has_unpublished_changes":{"type":"string","enum":["true","false"],"description":"Filter by whether the questionnaire has unpublished draft changes","example":"false"},"min_in_review_count":{"type":["integer","null"],"minimum":0,"description":"Minimum number of participants in in_review status (inclusive)","example":"1"},"min_accepted_count":{"type":["integer","null"],"minimum":0,"description":"Minimum number of accepted participants (inclusive)","example":"1"},"min_survey_responses_count":{"type":["integer","null"],"minimum":0,"description":"Minimum number of survey responses (inclusive)","example":"1"},"max_interviews_count":{"type":["integer","null"],"minimum":0,"description":"Maximum number of completed interviews (inclusive)","example":"20"},"max_draft_orders_count":{"type":["integer","null"],"minimum":0,"description":"Maximum number of draft recruitment orders (inclusive). Use 0 to find projects with no pending drafts.","example":"0"},"has_share_link":{"type":"string","enum":["true","false"],"description":"Filter by share link availability. true = questionnaire published at least once.","example":"true"},"created_after":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"created_before":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"updated_after":{"type":"string","format":"date-time","description":"Filter results updated after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"updated_before":{"type":"string","format":"date-time","description":"Filter results updated before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"sort":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"}},"x-tags":["Projects"]},"ProjectListItem":{"type":"object","properties":{"id":{"type":"string","description":"Unique project identifier","example":"cm7a9qk2x0001jv04hn3w8m5p"},"project_name":{"type":"string","description":"Internal project name (falls back to participant-facing name)","example":"Q4 Onboarding Research"},"participant_facing_name":{"type":"string","description":"Name shown to participants","example":"Product Experience Interview"},"slug":{"type":"string","description":"URL-safe project slug","example":"vk7d0kv4VXPkbNjP8sTj8QrYF"},"organization_id":{"type":"string","description":"Organization that owns this project"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-10-15T14:30:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last-modified timestamp. Use ?sort=updated_at:desc for incremental sync — compare against your stored checkpoint and stop when results are older than your last run.","example":"2025-11-20T09:15:00.000Z"},"participants_count":{"type":"integer","minimum":0,"description":"Number of unique participants with completed recording and analysis","example":15},"survey_responses_count":{"type":"integer","minimum":0,"description":"Number of structured survey responses collected during sessions","example":42},"has_screener":{"type":"boolean","description":"Whether the project has published screener questions","example":true},"moderation_mode":{"type":"string","enum":["ai","human"],"description":"Who runs each interview: `ai` (the AI moderator) or `human` (a researcher on a scheduled video call). Set at creation and cannot be changed.","example":"ai"},"anonymize_participant_names":{"type":["boolean","null"],"description":"Whether participant anonymization is enabled for this project. When true, Strella never returns participant `email` anywhere — API, MCP, or exports — regardless of the caller's `participants:read:email` grant, and participant names are anonymized aliases. `null` when the project has no questionnaire yet.","example":false},"questionnaire_items_count":{"type":"integer","minimum":0,"description":"Number of items in the active questionnaire","example":8},"has_section_blocks":{"type":"boolean","description":"Whether the questionnaire contains section blocks","example":true},"has_unpublished_changes":{"type":"boolean","description":"Whether the questionnaire has unpublished draft changes (draftItemsV2 differs from itemsV2)","example":false},"unqualified_count":{"type":"integer","minimum":0,"description":"Participants in screener funnel (started or incomplete)","example":10},"qualified_count":{"type":"integer","minimum":0,"description":"Participants who passed screener and are waiting to be invited","example":4},"invited_count":{"type":"integer","minimum":0,"description":"Participants approved to join interview","example":3},"in_review_count":{"type":"integer","minimum":0,"description":"Participants who completed an interview and are awaiting researcher quality review","example":2},"accepted_count":{"type":"integer","minimum":0,"description":"Participants accepted by researcher (paid and finalized)","example":5},"rejected_count":{"type":"integer","minimum":0,"description":"Participants who failed screener, quality review, or did not attend","example":1},"draft_orders_count":{"type":"integer","minimum":0,"description":"Number of draft (unpublished) recruitment orders","example":0},"workspace_id":{"type":"string","description":"Workspace ID, omitted when project has no workspace"},"description":{"type":"string","description":"Project description. Omitted when not set (use PATCH with null to clear, or omit to leave unchanged).","example":"Exploring onboarding friction for new users"}},"required":["id","project_name","participant_facing_name","slug","organization_id","created_at","updated_at","participants_count","survey_responses_count","has_screener","moderation_mode","anonymize_participant_names","questionnaire_items_count","has_section_blocks","has_unpublished_changes","unqualified_count","qualified_count","invited_count","in_review_count","accepted_count","rejected_count","draft_orders_count"],"x-tags":["Projects"]},"ProjectListResponse":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique project identifier","example":"cm7a9qk2x0001jv04hn3w8m5p"},"project_name":{"type":"string","description":"Internal project name (falls back to participant-facing name)","example":"Q4 Onboarding Research"},"participant_facing_name":{"type":"string","description":"Name shown to participants","example":"Product Experience Interview"},"slug":{"type":"string","description":"URL-safe project slug","example":"vk7d0kv4VXPkbNjP8sTj8QrYF"},"organization_id":{"type":"string","description":"Organization that owns this project"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-10-15T14:30:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last-modified timestamp. Use ?sort=updated_at:desc for incremental sync — compare against your stored checkpoint and stop when results are older than your last run.","example":"2025-11-20T09:15:00.000Z"},"participants_count":{"type":"integer","minimum":0,"description":"Number of unique participants with completed recording and analysis","example":15},"survey_responses_count":{"type":"integer","minimum":0,"description":"Number of structured survey responses collected during sessions","example":42},"has_screener":{"type":"boolean","description":"Whether the project has published screener questions","example":true},"moderation_mode":{"type":"string","enum":["ai","human"],"description":"Who runs each interview: `ai` (the AI moderator) or `human` (a researcher on a scheduled video call). Set at creation and cannot be changed.","example":"ai"},"anonymize_participant_names":{"type":["boolean","null"],"description":"Whether participant anonymization is enabled for this project. When true, Strella never returns participant `email` anywhere — API, MCP, or exports — regardless of the caller's `participants:read:email` grant, and participant names are anonymized aliases. `null` when the project has no questionnaire yet.","example":false},"questionnaire_items_count":{"type":"integer","minimum":0,"description":"Number of items in the active questionnaire","example":8},"has_section_blocks":{"type":"boolean","description":"Whether the questionnaire contains section blocks","example":true},"has_unpublished_changes":{"type":"boolean","description":"Whether the questionnaire has unpublished draft changes (draftItemsV2 differs from itemsV2)","example":false},"unqualified_count":{"type":"integer","minimum":0,"description":"Participants in screener funnel (started or incomplete)","example":10},"qualified_count":{"type":"integer","minimum":0,"description":"Participants who passed screener and are waiting to be invited","example":4},"invited_count":{"type":"integer","minimum":0,"description":"Participants approved to join interview","example":3},"in_review_count":{"type":"integer","minimum":0,"description":"Participants who completed an interview and are awaiting researcher quality review","example":2},"accepted_count":{"type":"integer","minimum":0,"description":"Participants accepted by researcher (paid and finalized)","example":5},"rejected_count":{"type":"integer","minimum":0,"description":"Participants who failed screener, quality review, or did not attend","example":1},"draft_orders_count":{"type":"integer","minimum":0,"description":"Number of draft (unpublished) recruitment orders","example":0},"workspace_id":{"type":"string","description":"Workspace ID, omitted when project has no workspace"},"description":{"type":"string","description":"Project description. Omitted when not set (use PATCH with null to clear, or omit to leave unchanged).","example":"Exploring onboarding friction for new users"}},"required":["id","project_name","participant_facing_name","slug","organization_id","created_at","updated_at","participants_count","survey_responses_count","has_screener","moderation_mode","anonymize_participant_names","questionnaire_items_count","has_section_blocks","has_unpublished_changes","unqualified_count","qualified_count","invited_count","in_review_count","accepted_count","rejected_count","draft_orders_count"]},"description":"Projects sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["projects","meta"],"x-tags":["Projects"]},"ProjectDetail":{"type":"object","properties":{"id":{"type":"string","description":"Unique project identifier","example":"cm7a9qk2x0001jv04hn3w8m5p"},"project_name":{"type":"string","description":"Internal project name (falls back to participant-facing name)","example":"Q4 Onboarding Research"},"participant_facing_name":{"type":"string","description":"Name shown to participants","example":"Product Experience Interview"},"slug":{"type":"string","description":"URL-safe project slug","example":"vk7d0kv4VXPkbNjP8sTj8QrYF"},"organization_id":{"type":"string","description":"Organization that owns this project"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-10-15T14:30:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last-modified timestamp. Use ?sort=updated_at:desc for incremental sync — compare against your stored checkpoint and stop when results are older than your last run.","example":"2025-11-20T09:15:00.000Z"},"participants_count":{"type":"integer","minimum":0,"description":"Number of unique participants with completed recording and analysis","example":15},"survey_responses_count":{"type":"integer","minimum":0,"description":"Number of structured survey responses collected during sessions","example":42},"has_screener":{"type":"boolean","description":"Whether the project has published screener questions","example":true},"moderation_mode":{"type":"string","enum":["ai","human"],"description":"Who runs each interview: `ai` (the AI moderator) or `human` (a researcher on a scheduled video call). Set at creation and cannot be changed.","example":"ai"},"anonymize_participant_names":{"type":["boolean","null"],"description":"Whether participant names are anonymized in the researcher view","example":false},"questionnaire_items_count":{"type":"integer","minimum":0,"description":"Number of items in the active questionnaire","example":8},"has_section_blocks":{"type":"boolean","description":"Whether the questionnaire contains section blocks","example":true},"has_unpublished_changes":{"type":"boolean","description":"Whether the questionnaire has unpublished draft changes (draftItemsV2 differs from itemsV2)","example":false},"unqualified_count":{"type":"integer","minimum":0,"description":"Participants in screener funnel (started or incomplete)","example":10},"qualified_count":{"type":"integer","minimum":0,"description":"Participants who passed screener and are waiting to be invited","example":4},"invited_count":{"type":"integer","minimum":0,"description":"Participants approved to join interview","example":3},"in_review_count":{"type":"integer","minimum":0,"description":"Participants who completed an interview and are awaiting researcher quality review","example":2},"accepted_count":{"type":"integer","minimum":0,"description":"Participants accepted by researcher (paid and finalized)","example":5},"rejected_count":{"type":"integer","minimum":0,"description":"Participants who failed screener, quality review, or did not attend","example":1},"draft_orders_count":{"type":"integer","minimum":0,"description":"Number of draft (unpublished) recruitment orders","example":0},"workspace_id":{"type":"string","description":"Workspace ID, omitted when project has no workspace"},"description":{"type":"string","description":"Project description. Omitted when not set (use PATCH with null to clear, or omit to leave unchanged).","example":"Exploring onboarding friction for new users"},"screener_required":{"type":"boolean","description":"Whether a screener is required before participation","example":false},"questionnaire_id":{"type":"string","description":"ID of the active questionnaire, omitted when no questionnaire exists","example":"cm7e8kp3s0005jv04qr9w4f6n"},"research_objectives":{"type":["string","null"],"description":"Specific research questions to investigate","example":"What friction points exist in the first-run experience?"},"product_description":{"type":["string","null"],"description":"Description of the product being researched","example":"A SaaS project management tool for small teams"},"target_audience":{"type":["string","null"],"description":"Target audience for the research","example":"Product managers aged 25-45"},"project_context":{"type":["string","null"],"description":"Secure, business-internal research context. Never shown to participants. Only included when `?project_context=true` is passed.","example":"We are launching a redesigned checkout next month and need to validate the new flow before rollout"},"default_language":{"type":["string","null"],"description":"Default interview language (ISO 639-1 code)","example":"en"},"participant_target_count":{"type":["integer","null"],"description":"Target number of participants","example":20},"auto_invite":{"type":["boolean","null"],"description":"Whether qualified participants are automatically invited to interview. API-created recruitment orders always have per-order auto-invite disabled regardless of this field — this setting applies to self-recruited participants only.","example":true},"close_date":{"type":["string","null"],"format":"date-time","description":"ISO 8601 date when the project closes for new participants","example":"2026-06-01T00:00:00.000Z"},"email_required":{"type":["boolean","null"],"description":"Whether participant email is required","example":true},"name_required":{"type":["boolean","null"],"description":"Whether participant name is required","example":true},"tts_provider":{"type":["string","null"],"description":"TTS provider for the AI moderator. Determines which voices are available. Call GET /projects/settings for the full catalog.","example":"openai"},"moderator_voice":{"type":["string","null"],"description":"Voice identifier for the AI moderator","example":"sage"},"moderator_name":{"type":["string","null"],"description":"Display name of the AI moderator shown to participants","example":"Strella"},"greet_participant_by_name":{"type":["boolean","null"],"description":"Whether the moderator speaks the participant's name in its opening greeting (\"Hi Abe, I'm Strella\" vs \"Hi there, I'm Strella\"). Affects the spoken greeting only — distinct from `anonymize_participant_names`, which anonymizes the name everywhere. With this off the real name is still stored and still keys results and transcripts. `null` when the project has no questionnaire yet.","example":true},"voice_speed":{"type":["number","null"],"description":"Voice speed multiplier (0.5 to 2.0)","example":1},"general_moderator_prompt":{"type":["string","null"],"description":"Custom system prompt for the moderator","example":"Be concise and focus on pain points"},"default_follow_up_depth":{"type":["integer","null"],"description":"Default follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High","example":2},"video_sharing_requirement":{"type":["string","null"],"enum":["required_to_join","disabled",null],"description":"Camera recording for participants: `required_to_join` (on) or `disabled` (off)","example":"required_to_join"},"screen_sharing_requirement":{"type":["string","null"],"enum":["enabled","disabled",null],"description":"Screen sharing requirement for participants","example":"enabled"},"device_restriction":{"type":["string","null"],"enum":["desktop_only","mobile_only","no_restriction",null],"description":"Device restriction for interview sessions","example":"desktop_only"},"push_to_talk":{"type":["boolean","null"],"description":"Whether push-to-talk is enabled. Requires desktop_only.","example":false},"blur_participant_camera":{"type":["boolean","null"],"description":"Whether to blur the participant camera feed. Requires webcam enabled and desktop_only.","example":false},"blur_participant_screen_share":{"type":["boolean","null"],"description":"Whether to blur the participant screen share feed. Requires screen sharing enabled.","example":false},"allow_disabling_screen_recording":{"type":["boolean","null"],"description":"Whether participants can disable screen recording. Requires screen sharing enabled.","example":false},"screen_share_display_surface":{"type":["string","null"],"enum":["window","monitor",null],"description":"Screen share capture mode: window (single window) or monitor (entire screen). Requires screen sharing enabled.","example":"window"},"require_screen_share_verification":{"type":["boolean","null"],"description":"Whether to verify screen share content before proceeding. Has no effect while screen sharing is off.","example":true},"voice_modulation_enabled":{"type":["boolean","null"],"description":"Whether voice anonymization is enabled for participants","example":false},"default_captions_enabled":{"type":["boolean","null"],"description":"Whether captions are enabled by default during interviews","example":false},"session_timeout":{"anyOf":[{"type":"number","enum":[30]},{"type":"number","enum":[60]},{"type":"number","enum":[120]},{"type":"number","enum":[180]},{"type":"number","enum":[300]},{"type":"number","enum":[600]},{"type":"number","enum":[900]},{"type":"null"}],"description":"Session inactivity timeout in seconds. Valid values: 30, 60, 120, 180, 300, 600, 900.","example":300},"pronunciation_guide":{"type":["array","null"],"items":{"type":"object","properties":{"word":{"type":"string","description":"Word or term","example":"Strella"},"pronunciation":{"type":"string","description":"Phonetic pronunciation","example":"STREL-uh"}},"required":["word","pronunciation"]},"description":"Pronunciation dictionary for the moderator"},"allowed_languages":{"type":"array","items":{"type":"string"},"description":"Languages the moderator supports (ISO 639-1 codes). Empty array means all languages.","example":["en","es"]},"analysis_language":{"type":"string","description":"Language for AI-generated analysis output (summaries, themes)","example":"en-US"},"manually_closed":{"type":"boolean","description":"Whether the project is manually closed for new participants. Set via PATCH.","example":false},"reject_when_quota_full":{"type":"boolean","description":"Whether participants are rejected once every quota they match is full (UI label: \"Reject participants when a quota is full\"). Only takes effect once the project has at least one active quota; with no active quotas, participants are never rejected on this basis.","example":true},"require_quota_match":{"type":"boolean","description":"Whether participants who match no quota are rejected (UI label: \"Require participants to match a quota\"). Only takes effect once the project has at least one active quota; with no active quotas, participants are never rejected on this basis.","example":false},"custom_fields":{"type":"array","items":{"$ref":"#/components/schemas/CustomFieldDefinition"},"description":"Every custom field on the project's participants table, including Tags and AI-coded categories, in column order. To change them, send the whole list back as `custom_fields` on `PATCH /projects/{project_id}`."},"share_url":{"type":"string","description":"Participant-facing interview link. Absent until the first PUT /questionnaire/items call publishes the questionnaire. Share this URL with participants or embed in outreach flows.","example":"https://app.strella.io/onboard/abc123"}},"required":["id","project_name","participant_facing_name","slug","organization_id","created_at","updated_at","participants_count","survey_responses_count","has_screener","moderation_mode","anonymize_participant_names","questionnaire_items_count","has_section_blocks","has_unpublished_changes","unqualified_count","qualified_count","invited_count","in_review_count","accepted_count","rejected_count","draft_orders_count","screener_required","research_objectives","product_description","target_audience","default_language","participant_target_count","auto_invite","close_date","email_required","name_required","tts_provider","moderator_voice","moderator_name","greet_participant_by_name","voice_speed","general_moderator_prompt","default_follow_up_depth","video_sharing_requirement","screen_sharing_requirement","device_restriction","push_to_talk","blur_participant_camera","blur_participant_screen_share","allow_disabling_screen_recording","screen_share_display_surface","require_screen_share_verification","voice_modulation_enabled","default_captions_enabled","session_timeout","pronunciation_guide","allowed_languages","analysis_language","manually_closed","reject_when_quota_full","require_quota_match","custom_fields"],"x-tags":["Projects"]},"ProjectDetailListResponse":{"type":"object","properties":{"projects":{"type":"array","items":{"$ref":"#/components/schemas/ProjectDetail"},"description":"Projects sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["projects","meta"],"x-tags":["Projects"]},"QuestionnaireChoice":{"type":"object","properties":{"id":{"type":"string","description":"Choice identifier"},"content":{"type":"string","description":"Choice text"},"exclusive":{"type":"boolean","description":"Present and true on an exclusive option of a multi-select multiple_choice item, such as \"None of the above\": picking it clears every other pick. Exclusive options are listed last."}},"required":["id","content"],"x-tags":["Questionnaires"]},"QuestionnaireItem":{"type":"object","properties":{"id":{"type":"string","description":"Stable item identifier. Referenced by survey responses, interview progress, transcript segments, and analysis summaries. Preserve on round-trip edits."},"item_type":{"type":"string","description":"Item type (question, task, multiple_choice, section_block, etc.)"},"content":{"type":"string","description":"Question, task, or statement text"},"choices":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Choice identifier"},"content":{"type":"string","description":"Choice text"},"exclusive":{"type":"boolean","description":"Present and true on an exclusive option of a multi-select multiple_choice item, such as \"None of the above\": picking it clears every other pick. Exclusive options are listed last."}},"required":["id","content"]},"description":"Answer choices (for multiple_choice/ranking items)"},"children":{"type":"array","items":{"$ref":"#/components/schemas/QuestionnaireItem"},"description":"Nested items (for container types like section_block)"},"dynamic_followups":{"type":"boolean","description":"Whether the AI moderator generates follow-up questions"},"follow_up_guidance":{"type":"string","description":"Guidance for the AI moderator when generating follow-ups"},"follow_up_depth":{"type":"number","description":"AI moderator follow-up depth. Allowed values: 0 (off), 1 (low), 2 (medium), 3 (high). Present on follow-up-capable items (question, multiple_choice, ranking, free_response, slider, rating_scale, nps, matrix, card_sort, tree_test) with follow-ups enabled."},"media_type":{"type":"string","enum":["image","embed","website","figma","video","text"],"description":"Media type. Allowed values: image, embed, website, figma, video. Present on media, start_media, and end_media items."},"label":{"type":"string","description":"Display label for media_block containers, shown in the guide editor tree. Distinct from `content`, which holds the media URL. Absent when the block has no label."},"watermark":{"type":"boolean","description":"Whether the media is marked with a tiled per-participant watermark during the interview. Present on media_block items where it is enabled."},"image_fit":{"type":"string","enum":["contain","fit-width"],"description":"How an image media_block fits the participant's screen: contain or fit-width. Present only on image media_block items set to fit-width, since contain is the default."},"block_media_interaction":{"type":"boolean","description":"Whether the embed or prototype is covered while this item is being asked, so the participant answers before interacting with it. Present only on items inside a media_block with media_type embed or figma, where it is set."},"media_redirect_url":{"type":"string","description":"The http(s) address the embed or prototype reloads at when the interview reaches this item. Present only on items inside a media_block with media_type embed, or figma with no imported prototype, where it is set. Inside a figma block it is the embed.figma.com form."},"figma_prototype":{"type":["object","null"],"properties":{"name":{"type":["string","null"],"description":"The prototype's name in Figma"},"screens":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Screen id to send as figma_start_screen or figma_goal_screen","example":"11:7"},"name":{"type":"string","description":"Screen name in Figma"}},"required":["id","name"]},"description":"The screens of the prototype, in the order the guide editor lists them"}},"required":["name","screens"],"description":"The prototype imported in Strella for this figma media_block, with the screens its tasks can start on or end on. null when no prototype is imported: import one in the Strella guide builder, which needs a Figma sign-in. Present only on media_block items with media_type figma."},"figma_task_type":{"type":"string","enum":["free_explore","goal_based"],"description":"The guide editor's \"Task type\" for a task inside a figma media_block with a prototype imported in Strella: free_explore lets the participant click around, and goal_based ends the task when the participant reaches figma_goal_screen. Strella stores free_explore when a task there has no type. Valid only on a task in such a block: the API rejects it with 422 anywhere else. Present only on a task in such a block."},"figma_start_screen":{"type":"string","description":"The guide editor's \"Start screen\": the screen id the prototype opens on for this task, from the `figma_prototype.screens` of its media_block. Valid only on a task inside a figma media_block with an imported prototype. An empty string clears it. Present only when set to a screen the prototype still has."},"figma_goal_screen":{"type":"string","description":"The guide editor's \"Goal screen\": the screen id that ends a goal_based task when the participant reaches it, from the `figma_prototype.screens` of its media_block. Valid only with figma_task_type goal_based. An empty string clears it. Present only on a goal_based task, when set to a screen the prototype still has."},"blur_screen_recording":{"type":"boolean","description":"The guide editor's \"Blur recording for privacy\". When true, Strella blurs the participant's screen share in the recording while this item is asked. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422. Present only on an item where it applies, when set."},"verbatim":{"type":"boolean","description":"The guide editor's \"Ask question verbatim\". When true, the moderator reads this item exactly as written instead of adapting it to what the participant already said. Follow-up questions stay adaptive. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422. Present only on an item where it applies, when set."},"participant_controlled":{"type":"boolean","description":"The guide editor's \"Participant-controlled\". When true, the moderator waits after asking this item until the participant clicks to move on. Valid on question, free_response, multiple_choice, ranking, rating_scale and slider items. Setting it to true on any other item type is rejected with 422. It has no effect while the project moves to the next question only when the participant clicks, for every question. Present only on an item where it applies, when set."},"randomize":{"type":"boolean","description":"Whether to randomize display order. Present on multiple_choice and ranking items (choice order), card_sort items (card order), and matrix items (row order)."},"multi_select":{"type":"boolean","description":"Allow multiple selections. Present on multiple_choice items."},"min_selections":{"type":"integer","minimum":1,"description":"Minimum number of selections required. Present on multiple_choice items with multi_select=true. Participants cannot continue until they meet this minimum."},"max_selections":{"type":"integer","minimum":1,"description":"Maximum number of selections allowed. Present on multiple_choice items with multi_select=true. The UI prevents selecting beyond this cap."},"rating_style":{"type":"string","enum":["likert","faces","stars"],"description":"RatingScale-only display mode. `likert` (default) renders text labels per position. `faces` renders 5 colored emoji (locks scale size to 5). `stars` renders 5 filled stars. Data shape unchanged regardless of style."},"nps_style":{"type":"string","enum":["buttons","slider"],"description":"NPS-only display mode for the fixed 0-10 scale. `buttons` (default) renders an 11-button strip the participant taps once. `slider` renders a 0-10 handle the participant drags. Data shape unchanged regardless of style: the answer is the chosen integer either way."},"tree_style":{"type":"string","enum":["focused","full"],"description":"TreeTest-only display mode. `focused` (default) drills down: opening a section hides its siblings, so only the path the participant chose stays on screen. `full` keeps every top-level section visible and expands sections in place. Data shape unchanged regardless of style: the recorded navigation events are the same either way."},"tree_branches_selectable":{"type":"boolean","description":"TreeTest-only. Whether participants may answer with a branch (a node that has children). Default false: branches only open and close and the answer is a leaf."},"media_upload_kinds":{"type":"array","items":{"type":"string","enum":["image","video","document"]},"description":"File kinds the participant may upload as their answer. Present on media_upload items; absent means all kinds are accepted."},"card_sort_type":{"type":"string","enum":["open","closed","hybrid"],"description":"Who defines the groups for a card_sort item. Absent means `open`."},"card_sort_cards":{"type":"array","items":{"type":"string"},"description":"Card labels participants sort. Present on card_sort items."},"card_sort_groups":{"type":"array","items":{"type":"string"},"description":"Predefined group titles. Present on closed and hybrid card_sort items."},"tree_test_nodes":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Stable node identifier — recorded navigation paths key on it."},"label":{"type":"string"},"depth":{"type":"integer","description":"Nesting depth in the depth-first flat list; 0 is a root."}},"required":["id","label","depth"]},"description":"The label hierarchy participants navigate, as a depth-first flat list. Present on tree_test items."},"tree_test_tasks":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"correct_node_ids":{"type":"array","items":{"type":"string"}}},"required":["id","correct_node_ids"]},"description":"Findability tasks; the task prompt is the item content. Present on tree_test items."},"matrix_rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Choice identifier"},"content":{"type":"string","description":"Choice text"}},"required":["id","content"]},"description":"Grid rows — the items being asked about. Present on matrix items. Survey responses key on the row id."},"matrix_columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Choice identifier"},"content":{"type":"string","description":"Choice text"}},"required":["id","content"]},"description":"Grid columns — the mutually exclusive options; the participant picks one per row. Present on matrix items."},"include_other_option":{"type":"boolean","description":"Include an \"Other\" free-text option. Present on multiple_choice items."},"character_limit":{"type":"number","description":"Maximum character count for participant responses. Present on free_response items."},"slider_config":{"type":"object","properties":{"min_value":{"type":"integer","description":"Minimum slider value"},"max_value":{"type":"integer","description":"Maximum slider value"},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment"},"min_label":{"type":"string","description":"Label for the minimum end"},"max_label":{"type":"string","description":"Label for the maximum end"}},"required":["min_value","max_value","step"],"description":"Slider configuration. Present on slider items."},"conditions":{"type":["array","null"],"items":{"type":"object","properties":{"source":{"type":"string","enum":["screener","in_call_survey","quota"],"description":"Data source for the condition: screener (pre-interview), in_call_survey (during interview), or quota (post-screener cohort assignment)."},"item_id":{"type":"string","description":"Identifier of the source question whose answer is evaluated. Omitted for `source: \"quota\"` (quota membership is referenced by id in `selected_option_values`, not by a question)."},"operator":{"type":"string","enum":["is","is_not","lt","gt","eq","lte","gte"],"description":"Comparison operator: \"is\" requires a match, \"is_not\" requires no match"},"logical_join":{"type":"string","enum":["and","or"],"description":"How this condition combines with the next condition in the array. Omitted on the last condition."},"selected_option_values":{"type":"array","items":{"type":"string"},"description":"Option values that satisfy the condition. For `source: \"quota\"`, this is the list of quota IDs."}},"required":["source","operator","selected_option_values"]},"description":"Visibility conditions for container items (section_block, media_block, variant_block). Determines which screener answers or in-call survey responses must match for this section to be shown during the interview. Null when no conditions are configured. Absent on leaf items. Deprecated: use `audience_rules` for new integrations — both fields are derived from the same internal data."},"audience_rules":{"type":["object","null"],"properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/AudienceFilter"},"minItems":1,"maxItems":20,"description":"Filters AND-ed within this group."}},"required":["filters"],"description":"A group of filters. Filters within a group are AND-ed together."},"minItems":1,"maxItems":20,"description":"Groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form — same shape as quota criteria.) At most one group may hold several filters, unless every multi-filter group shares a filter, e.g. [[A, C], [B, C]] for \"(A or B) and C\"; other shapes are rejected."}},"required":["filter_groups"],"description":"Audience rules in DNF (Disjunctive Normal Form) — gate section visibility on prior screener / in-call survey answers, slider thresholds (`kind: numeric`), or quota membership. Canonical write/read shape. Null when no rules are configured. Absent on leaf items."},"variant_subset_size":{"type":"integer","minimum":1,"description":"For variant_block: how many variants each participant sees out of the total children. Absent (treated as 1) when not configured. Ignored on non-variant_block items."}},"required":["id","item_type","content"],"x-tags":["Questionnaires"]},"QuestionnaireDetail":{"type":"object","properties":{"id":{"type":"string","description":"Questionnaire identifier"},"published":{"type":"boolean","description":"Whether the questionnaire is published"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp"},"items":{"type":"array","items":{"$ref":"#/components/schemas/QuestionnaireItem"},"description":"Question guide items (tree structure). Every PUT /questionnaire/items write auto-publishes, so this always reflects the latest saved state."},"draft_items":{"type":"array","items":{"$ref":"#/components/schemas/QuestionnaireItem"},"description":"Draft questionnaire items (unpublished changes from the web dashboard). Only present when `include_draft=true` is passed AND draft content differs from the published items."}},"required":["id","published","created_at","items"],"description":"The questionnaire (interview script) for a project.\n\n**items** — the live questionnaire the AI moderator follows during interviews. Every `PUT /questionnaire/items` call automatically publishes the new items (no separate publish step). The recommended round-trip workflow is: GET → modify → PUT, preserving item `id` values.\n\nItems form a tree. Container types (section_block, media_block) hold children. Leaf types (question, task, multiple_choice, etc.) do not.\n\nExample:\n```json\n{\n  \"id\": \"clk_abc123\",\n  \"published\": true,\n  \"created_at\": \"2025-06-01T12:00:00.000Z\",\n  \"items\": [\n    {\n      \"id\": \"sec_1\", \"item_type\": \"section_block\", \"content\": \"Onboarding Experience\",\n      \"children\": [\n        {\n          \"id\": \"q_1\", \"item_type\": \"question\", \"content\": \"Walk me through your first day.\",\n          \"dynamic_followups\": true, \"follow_up_depth\": 2\n        },\n        {\n          \"id\": \"mc_1\", \"item_type\": \"multiple_choice\", \"content\": \"How satisfied were you?\",\n          \"choices\": [\n            { \"id\": \"c_1\", \"content\": \"Very satisfied\" },\n            { \"id\": \"c_2\", \"content\": \"Somewhat satisfied\" },\n            { \"id\": \"c_3\", \"content\": \"Not satisfied\" }\n          ],\n          \"randomize\": true, \"include_other_option\": true\n        },\n        {\n          \"id\": \"fr_1\", \"item_type\": \"free_response\", \"content\": \"Any additional thoughts?\",\n          \"character_limit\": 500\n        }\n      ]\n    },\n    {\n      \"id\": \"m_1\", \"item_type\": \"media\", \"content\": \"https://embed.figma.com/proto/abc123/My-Prototype?embed-host=Strella&footer=false&viewport-controls=true&scaling=contain\",\n      \"media_type\": \"figma\"\n    }\n  ]\n}\n```","x-tags":["Questionnaires"]},"CreateItemBody":{"type":"object","properties":{"parent_id":{"type":["string","null"],"minLength":1,"maxLength":128,"description":"Parent container ID. null = insert at root level.","example":"sec_abc123"},"position":{"type":"integer","minimum":0,"description":"0-based index in parent's children. Omit to append at end.","example":0},"item":{"$ref":"#/components/schemas/QuestionnaireInputItem"}},"required":["parent_id","item"],"description":"Add a new item to the questionnaire tree.","x-tags":["Questionnaires"]},"ReplaceItemBody":{"oneOf":[{"type":"object","properties":{"content":{"type":"string","maxLength":10000,"description":"Question, task, or statement text. Required on every item except an image media_block, which may be empty until its image is uploaded in the guide builder.","example":"What challenges do you face with onboarding?"},"choices":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Choice identifier. Optional on write. If provided, preserved (round-trip safe). If omitted, generated."},"content":{"type":"string","description":"Choice text","example":"Very satisfied"},"exclusive":{"type":"boolean","description":"Marks an exclusive option, such as \"None of the above\": picking it clears every other pick. Applies to multiple_choice items with multi_select true; on any other item the flag is dropped. Exclusive options are listed last."}},"required":["content"]},"description":"Answer choices — required (min 2) for multiple_choice and ranking items. Optional for rating_scale: omitted or empty stores the default labels (5-point likert); send 4, 5, or 7 entries to pick a scale size, and the stored item always carries the full label set."},"media_type":{"type":"string","enum":["image","embed","website","figma","video","text"],"description":"Media type for media_block containers. Determines how the content URL is rendered.","example":"image"},"label":{"type":"string","maxLength":10000,"description":"Display label for a media_block, shown in the guide editor tree. `content` holds the URL; this names it. Omit to keep the stored label; send an empty string to clear it. Only valid on media_block items.","example":"Prototype walkthrough"},"dynamic_followups":{"type":"boolean","default":false,"description":"Whether the AI moderator generates follow-up questions","example":false},"follow_up_guidance":{"type":"string","maxLength":2000,"description":"Guidance for the AI moderator when generating follow-ups","example":"Probe deeper into workflow pain points"},"follow_up_depth":{"anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]}],"description":"Follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High","example":2},"randomize":{"type":"boolean","description":"Whether to randomize choice order","example":false},"multi_select":{"type":"boolean","description":"Allow multiple selections (multiple_choice items)","example":false},"include_other_option":{"type":"boolean","description":"Include an \"Other\" free-text option (multiple_choice items)","example":false},"block_media_interaction":{"type":"boolean","description":"Only valid on an item inside a media_block with media_type embed or figma. When true, the embed or prototype is covered while this item is being asked, so the participant answers before interacting with it. Setting it to true anywhere else is rejected with 422.","example":true},"image_fit":{"type":"string","enum":["contain","fit-width"],"description":"Image media_block only. How the image fits the participant's screen: `contain` (Fit width and height, the default: the whole image fits the screen) or `fit-width` (Fit width: the image spans the screen width and scrolls vertically). Omit to keep the stored value; send `contain` to reset it. Ignored on other items.","example":"fit-width"},"media_redirect_url":{"anyOf":[{"type":"string","maxLength":2048,"format":"uri"},{"type":"string","enum":[""]}],"description":"Only valid on an item inside a media_block with media_type embed, or figma with no prototype imported in Strella. When the interview reaches this item, the embed or prototype reloads at this http(s) address, and later items keep showing it until another item sets one. Inside a figma block it must be a Figma prototype, design, board, slides or deck link, and Strella stores it in the embed form the block content uses. An empty string clears it. A non-empty value anywhere else is rejected with 422.","example":"https://www.figma.com/proto/abc123/Checkout?node-id=11-7"},"figma_task_type":{"type":"string","enum":["free_explore","goal_based"],"description":"The guide editor's \"Task type\" for a task inside a figma media_block with a prototype imported in Strella: free_explore lets the participant click around, and goal_based ends the task when the participant reaches figma_goal_screen. Strella stores free_explore when a task there has no type. Valid only on a task in such a block: the API rejects it with 422 anywhere else.","example":"goal_based"},"figma_start_screen":{"type":"string","maxLength":128,"description":"The guide editor's \"Start screen\": the screen id the prototype opens on for this task, from the `figma_prototype.screens` of its media_block. Valid only on a task inside a figma media_block with an imported prototype. An empty string clears it.","example":"11:1"},"figma_goal_screen":{"type":"string","maxLength":128,"description":"The guide editor's \"Goal screen\": the screen id that ends a goal_based task when the participant reaches it, from the `figma_prototype.screens` of its media_block. Valid only with figma_task_type goal_based. An empty string clears it.","example":"11:7"},"blur_screen_recording":{"type":"boolean","description":"The guide editor's \"Blur recording for privacy\". When true, Strella blurs the participant's screen share in the recording while this item is asked. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422.","example":true},"verbatim":{"type":"boolean","description":"The guide editor's \"Ask question verbatim\". When true, the moderator reads this item exactly as written instead of adapting it to what the participant already said. Follow-up questions stay adaptive. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422.","example":true},"participant_controlled":{"type":"boolean","description":"The guide editor's \"Participant-controlled\". When true, the moderator waits after asking this item until the participant clicks to move on. Valid on question, free_response, multiple_choice, ranking, rating_scale and slider items. Setting it to true on any other item type is rejected with 422. It has no effect while the project moves to the next question only when the participant clicks, for every question.","example":true},"character_limit":{"type":"integer","description":"Character limit for free_response items","example":500},"slider_config":{"type":"object","properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1},"min_label":{"type":"string","maxLength":20,"description":"Label for the minimum end of the slider","example":"Not at all likely"},"max_label":{"type":"string","maxLength":20,"description":"Label for the maximum end of the slider","example":"Extremely likely"}},"required":["min_value","max_value","step"],"description":"Slider configuration — required for slider items. Defines the numeric scale range, step increment, and optional endpoint labels."},"only_ask_if":{},"item_type":{"type":"string","enum":["section_block"],"description":"Type of questionnaire item","example":"section_block"},"audience_rules":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/AudienceFilter"},"minItems":1,"maxItems":20,"description":"Filters AND-ed within this group."}},"required":["filters"],"description":"A group of filters. Filters within a group are AND-ed together."},"minItems":1,"maxItems":20,"description":"Groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form — same shape as quota criteria.) At most one group may hold several filters, unless every multi-filter group shares a filter, e.g. [[A, C], [B, C]] for \"(A or B) and C\"; other shapes are rejected."}},"required":["filter_groups"],"description":"Audience rules — gate section visibility on prior screener / in-call survey answers or quota membership. Writable only on section_block items (matches the Question Guide editor). DNF shape: filter_groups OR-ed, filters within a group AND-ed."}},"required":["content","item_type"]},{"type":"object","properties":{"content":{"type":"string","maxLength":10000,"description":"Question, task, or statement text. Required on every item except an image media_block, which may be empty until its image is uploaded in the guide builder.","example":"What challenges do you face with onboarding?"},"choices":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Choice identifier. Optional on write. If provided, preserved (round-trip safe). If omitted, generated."},"content":{"type":"string","description":"Choice text","example":"Very satisfied"},"exclusive":{"type":"boolean","description":"Marks an exclusive option, such as \"None of the above\": picking it clears every other pick. Applies to multiple_choice items with multi_select true; on any other item the flag is dropped. Exclusive options are listed last."}},"required":["content"]},"description":"Answer choices — required (min 2) for multiple_choice and ranking items. Optional for rating_scale: omitted or empty stores the default labels (5-point likert); send 4, 5, or 7 entries to pick a scale size, and the stored item always carries the full label set."},"media_type":{"type":"string","enum":["image","embed","website","figma","video","text"],"description":"Media type for media_block containers. Determines how the content URL is rendered.","example":"image"},"label":{"type":"string","maxLength":10000,"description":"Display label for a media_block, shown in the guide editor tree. `content` holds the URL; this names it. Omit to keep the stored label; send an empty string to clear it. Only valid on media_block items.","example":"Prototype walkthrough"},"dynamic_followups":{"type":"boolean","default":false,"description":"Whether the AI moderator generates follow-up questions","example":false},"follow_up_guidance":{"type":"string","maxLength":2000,"description":"Guidance for the AI moderator when generating follow-ups","example":"Probe deeper into workflow pain points"},"follow_up_depth":{"anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]}],"description":"Follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High","example":2},"randomize":{"type":"boolean","description":"Whether to randomize choice order","example":false},"multi_select":{"type":"boolean","description":"Allow multiple selections (multiple_choice items)","example":false},"include_other_option":{"type":"boolean","description":"Include an \"Other\" free-text option (multiple_choice items)","example":false},"block_media_interaction":{"type":"boolean","description":"Only valid on an item inside a media_block with media_type embed or figma. When true, the embed or prototype is covered while this item is being asked, so the participant answers before interacting with it. Setting it to true anywhere else is rejected with 422.","example":true},"image_fit":{"type":"string","enum":["contain","fit-width"],"description":"Image media_block only. How the image fits the participant's screen: `contain` (Fit width and height, the default: the whole image fits the screen) or `fit-width` (Fit width: the image spans the screen width and scrolls vertically). Omit to keep the stored value; send `contain` to reset it. Ignored on other items.","example":"fit-width"},"media_redirect_url":{"anyOf":[{"type":"string","maxLength":2048,"format":"uri"},{"type":"string","enum":[""]}],"description":"Only valid on an item inside a media_block with media_type embed, or figma with no prototype imported in Strella. When the interview reaches this item, the embed or prototype reloads at this http(s) address, and later items keep showing it until another item sets one. Inside a figma block it must be a Figma prototype, design, board, slides or deck link, and Strella stores it in the embed form the block content uses. An empty string clears it. A non-empty value anywhere else is rejected with 422.","example":"https://www.figma.com/proto/abc123/Checkout?node-id=11-7"},"figma_task_type":{"type":"string","enum":["free_explore","goal_based"],"description":"The guide editor's \"Task type\" for a task inside a figma media_block with a prototype imported in Strella: free_explore lets the participant click around, and goal_based ends the task when the participant reaches figma_goal_screen. Strella stores free_explore when a task there has no type. Valid only on a task in such a block: the API rejects it with 422 anywhere else.","example":"goal_based"},"figma_start_screen":{"type":"string","maxLength":128,"description":"The guide editor's \"Start screen\": the screen id the prototype opens on for this task, from the `figma_prototype.screens` of its media_block. Valid only on a task inside a figma media_block with an imported prototype. An empty string clears it.","example":"11:1"},"figma_goal_screen":{"type":"string","maxLength":128,"description":"The guide editor's \"Goal screen\": the screen id that ends a goal_based task when the participant reaches it, from the `figma_prototype.screens` of its media_block. Valid only with figma_task_type goal_based. An empty string clears it.","example":"11:7"},"blur_screen_recording":{"type":"boolean","description":"The guide editor's \"Blur recording for privacy\". When true, Strella blurs the participant's screen share in the recording while this item is asked. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422.","example":true},"verbatim":{"type":"boolean","description":"The guide editor's \"Ask question verbatim\". When true, the moderator reads this item exactly as written instead of adapting it to what the participant already said. Follow-up questions stay adaptive. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422.","example":true},"participant_controlled":{"type":"boolean","description":"The guide editor's \"Participant-controlled\". When true, the moderator waits after asking this item until the participant clicks to move on. Valid on question, free_response, multiple_choice, ranking, rating_scale and slider items. Setting it to true on any other item type is rejected with 422. It has no effect while the project moves to the next question only when the participant clicks, for every question.","example":true},"character_limit":{"type":"integer","description":"Character limit for free_response items","example":500},"slider_config":{"type":"object","properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1},"min_label":{"type":"string","maxLength":20,"description":"Label for the minimum end of the slider","example":"Not at all likely"},"max_label":{"type":"string","maxLength":20,"description":"Label for the maximum end of the slider","example":"Extremely likely"}},"required":["min_value","max_value","step"],"description":"Slider configuration — required for slider items. Defines the numeric scale range, step increment, and optional endpoint labels."},"only_ask_if":{},"item_type":{"type":"string","enum":["question","task","statement","multiple_choice","ranking","free_response","slider","rating_scale","nps","media_upload","matrix","card_sort","tree_test","media_block","variant_block","variant_child"],"description":"Type of questionnaire item","example":"question"},"audience_rules":{}},"required":["content","item_type"]}],"description":"Full replacement of a questionnaire item. item_type is required. id comes from the URL path. children are preserved — use child-level CRUD to modify them.","x-tags":["Questionnaires"]},"PatchItemBody":{"type":"object","properties":{"content":{"type":"string","minLength":1,"maxLength":10000,"description":"Question, task, or statement text.","example":"What challenges do you face with onboarding?"},"choices":{"type":["array","null"],"items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"Choice identifier. Optional on write. If provided, preserved (round-trip safe). If omitted, generated."},"content":{"type":"string","description":"Choice text","example":"Very satisfied"},"exclusive":{"type":"boolean","description":"Marks an exclusive option, such as \"None of the above\": picking it clears every other pick. Applies to multiple_choice items with multi_select true; on any other item the flag is dropped. Exclusive options are listed last."}},"required":["content"]},"description":"Answer choices. null clears existing choices. Required (min 2) for multiple_choice and ranking items — validated after merge. Optional for rating_scale: absent or empty renders the localized default labels."},"media_type":{"type":["string","null"],"enum":["image","embed","website","figma","video","text",null],"description":"Media type for media_block containers. null clears the value.","example":"image"},"label":{"type":["string","null"],"maxLength":10000,"description":"Display label for a media_block, shown in the guide editor tree. `content` holds the URL; this names it. null clears the label. Only valid on media_block items — rejected with 422 on any other type.","example":"Prototype walkthrough"},"dynamic_followups":{"type":["boolean","null"],"description":"Whether the AI moderator generates follow-up questions. null clears the value.","example":false},"follow_up_guidance":{"type":["string","null"],"maxLength":2000,"description":"Guidance for the AI moderator when generating follow-ups. null clears the value.","example":"Probe deeper into workflow pain points"},"follow_up_depth":{"anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]},{"type":"null"}],"description":"Follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High. null clears the value.","example":2},"randomize":{"type":["boolean","null"],"description":"Whether to randomize choice order. null clears the value.","example":false},"multi_select":{"type":["boolean","null"],"description":"Allow multiple selections (multiple_choice items). null clears the value.","example":false},"include_other_option":{"type":["boolean","null"],"description":"Include an \"Other\" free-text option (multiple_choice items). null clears the value.","example":false},"block_media_interaction":{"type":["boolean","null"],"description":"Only valid on an item inside a media_block with media_type embed or figma. When true, the embed or prototype is covered while this item is being asked, so the participant answers before interacting with it. Setting it to true anywhere else is rejected with 422. null clears the value.","example":true},"image_fit":{"type":["string","null"],"enum":["contain","fit-width",null],"description":"Image media_block only. How the image fits the participant's screen: `contain` or `fit-width`. null or `contain` resets it. Ignored on other items.","example":"fit-width"},"media_redirect_url":{"anyOf":[{"type":"string","maxLength":2048,"format":"uri"},{"type":"string","enum":[""]},{"type":"null"}],"description":"Only valid on an item inside a media_block with media_type embed, or figma with no prototype imported in Strella. When the interview reaches this item, the embed or prototype reloads at this http(s) address, and later items keep showing it until another item sets one. Inside a figma block it must be a Figma prototype, design, board, slides or deck link, and Strella stores it in the embed form the block content uses. An empty string clears it. A non-empty value anywhere else is rejected with 422. null also clears it.","example":"https://example.com/checkout"},"figma_task_type":{"type":["string","null"],"enum":["free_explore","goal_based",null],"description":"The guide editor's \"Task type\" for a task inside a figma media_block with a prototype imported in Strella: free_explore lets the participant click around, and goal_based ends the task when the participant reaches figma_goal_screen. Strella stores free_explore when a task there has no type. Valid only on a task in such a block: the API rejects it with 422 anywhere else. null stores free_explore.","example":"goal_based"},"figma_start_screen":{"type":["string","null"],"maxLength":128,"description":"The guide editor's \"Start screen\": the screen id the prototype opens on for this task, from the `figma_prototype.screens` of its media_block. Valid only on a task inside a figma media_block with an imported prototype. An empty string clears it. null also clears it.","example":"11:1"},"figma_goal_screen":{"type":["string","null"],"maxLength":128,"description":"The guide editor's \"Goal screen\": the screen id that ends a goal_based task when the participant reaches it, from the `figma_prototype.screens` of its media_block. Valid only with figma_task_type goal_based. An empty string clears it. null also clears it.","example":"11:7"},"blur_screen_recording":{"type":["boolean","null"],"description":"The guide editor's \"Blur recording for privacy\". When true, Strella blurs the participant's screen share in the recording while this item is asked. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422. null clears the value.","example":true},"verbatim":{"type":["boolean","null"],"description":"The guide editor's \"Ask question verbatim\". When true, the moderator reads this item exactly as written instead of adapting it to what the participant already said. Follow-up questions stay adaptive. Valid on a question, task, statement or survey item. Setting it to true on a section_block, media_block, variant_block or variant_child is rejected with 422. null clears the value.","example":true},"participant_controlled":{"type":["boolean","null"],"description":"The guide editor's \"Participant-controlled\". When true, the moderator waits after asking this item until the participant clicks to move on. Valid on question, free_response, multiple_choice, ranking, rating_scale and slider items. Setting it to true on any other item type is rejected with 422. It has no effect while the project moves to the next question only when the participant clicks, for every question. null clears the value.","example":true},"character_limit":{"type":["integer","null"],"description":"Character limit for free_response items. null clears the value.","example":500},"slider_config":{"type":["object","null"],"properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1},"min_label":{"type":"string","maxLength":20,"description":"Label for the minimum end of the slider","example":"Not at all likely"},"max_label":{"type":"string","maxLength":20,"description":"Label for the maximum end of the slider","example":"Extremely likely"}},"required":["min_value","max_value","step"],"description":"Slider configuration. null clears the value. Required for slider items — validated after merge."},"audience_rules":{"type":["object","null"],"properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/AudienceFilter"},"minItems":1,"maxItems":20,"description":"Filters AND-ed within this group."}},"required":["filters"],"description":"A group of filters. Filters within a group are AND-ed together."},"minItems":1,"maxItems":20,"description":"Groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form — same shape as quota criteria.) At most one group may hold several filters, unless every multi-filter group shares a filter, e.g. [[A, C], [B, C]] for \"(A or B) and C\"; other shapes are rejected."}},"required":["filter_groups"],"description":"Audience rules — gate section visibility on prior screener / in-call survey answers or quota membership. Writable only on section_block items (matches the Question Guide editor). DNF shape: filter_groups OR-ed, filters within a group AND-ed. null clears existing rules. Container-only constraint validated after merge."},"only_ask_if":{}},"description":"Partial update of a questionnaire item. All fields are optional. Omitted fields are preserved. null values clear the field. item_type and children cannot be changed via PATCH.","x-tags":["Questionnaires"]},"RecruitmentOrder":{"type":"object","properties":{"id":{"type":"string","description":"Recruitment order ID (cuid).","example":"cm7f2kp3s0001jv04ab9c4d6e"},"project_id":{"type":"string","description":"Parent project ID.","example":"cm7a9qk2x0001jv04hn3w8m5p"},"name":{"type":"string","description":"Display name shown in the app and to panelists. Auto-disambiguated server-side: a base title (caller-supplied or project name) is suffixed with \" 2\", \" 3\", … if it would collide with another order in the same project across both panels. Omitted on legacy rows that pre-date the field.","example":"Acme Onboarding Study 2"},"panel":{"type":"string","enum":["respondent","user_interviews"],"description":"Recruitment vendor panel.","example":"respondent"},"status":{"type":"string","enum":["pending","recruiting","filled","closed","paused"],"description":"Current lifecycle status of the recruitment order.","example":"pending"},"participant_count":{"type":"integer","description":"Requested number of participants.","example":10},"incentive_per_participant":{"type":"integer","description":"Per-participant incentive in whole dollars (USD).","example":25},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp.","example":"2026-03-20T12:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of the last modification.","example":"2026-03-21T10:15:00.000Z"},"submitted_at":{"type":["string","null"],"format":"date-time","description":"When the order was submitted to the vendor. Null if still pending.","example":null},"sandbox":{"type":"boolean","enum":[true],"description":"Present and `true` only when the order was created in sandbox mode (via `X-Sandbox: true` header or `sandbox: true` body field). Omitted on real orders. Sandbox orders never reach the recruitment panel — a synthetic self-recruited participant + session is generated instead so integrators can verify their end-to-end flow without spending credits.","example":true},"auto_invite":{"type":"boolean","description":"Strella's internal AI auto-approval for this order. When `true`, screener passers rated 4+ by Strella's automated evaluation are auto-invited to interview without manual review. This is NOT the vendor panel's auto-invite: the recruitment panel never auto-admits participants regardless of this flag (Respondent orders always send `automations.autoinvite: false`; User Interviews orders always send `requireApproval: true`).","example":false},"counts":{"type":"object","properties":{"qualified":{"type":"integer","minimum":0,"description":"Participants who passed the screener.","example":15},"invited":{"type":"integer","minimum":0,"description":"Participants invited to interview.","example":12},"completed":{"type":"integer","minimum":0,"description":"Participants who completed and were accepted.","example":8},"pending":{"type":"integer","minimum":0,"description":"Participants awaiting screener results or review.","example":4},"as_of":{"type":["string","null"],"format":"date-time","description":"When these counts were last refreshed from the vendor. Null if never synced. Respondent: webhook-driven, near-real-time. User Interviews: refreshed by the 10-min cron only when new participants arrive — status changes made on the vendor side without new sign-ups will not bump `as_of`, so counts can be stale for arbitrarily long in steady state.","example":"2026-04-28T15:42:00.000Z"}},"required":["qualified","invited","completed","pending","as_of"],"description":"Participant progress counts for the order."}},"required":["id","project_id","panel","status","participant_count","incentive_per_participant","created_at","updated_at","submitted_at","auto_invite","counts"],"x-tags":["Recruitment"]},"RespondentCodeValidationError":{"type":"object","properties":{"field":{"type":"string","description":"Path to the offending targeting field, e.g. `targeting.countries[0].value`.","example":"targeting.countries[0].value"},"value":{"type":"string","description":"The submitted code that is not in the live catalog.","example":"ZZ"},"catalog_field":{"type":"string","description":"Catalog field that was checked, matching a key on `GET /recruitment/respondent/options`.","example":"countries"}},"required":["field","value","catalog_field"],"x-tags":["Recruitment"]},"RecruitmentOrderPatchBody":{"type":"object","properties":{"status":{"type":"string","enum":["recruiting","paused","closed"],"description":"Target lifecycle status for the recruitment order. The server derives the transition (submit, pause, unpause, close) from the current status and the requested target.\n\n- `recruiting` — submit a draft order (charges credits, calls the panel) or resume a paused User Interviews order\n- `paused` — pause an active order; the panel stops sourcing participants\n- `closed` — close the order and auto-refund unused credits\n\nPanel constraints: Respondent — 'closed' is unsupported (v1), 'paused' is terminal (cannot resume). User Interviews — all transitions supported."}},"required":["status"],"x-tags":["Recruitment"]},"CreateUserInterviewsDraftBody":{"type":"object","properties":{"target_market":{"type":"string","enum":["b2c","b2b"],"description":"Market type: \"b2c\" or \"b2b\".","example":"b2c"},"participant_count":{"type":"integer","minimum":1,"maximum":100,"description":"Number of participants to recruit (1-100).","example":10},"incentive_per_participant":{"type":"integer","minimum":1,"maximum":200,"description":"Per-participant incentive in whole dollars USD ($1-$200).","example":30},"targeting":{"type":"array","items":{"type":"object","additionalProperties":{"anyOf":[{"type":"object","properties":{"$gte":{"type":"integer","minimum":18},"$lte":{"type":"integer","maximum":99}},"required":["$gte","$lte"]},{"type":"object","properties":{"$in":{"type":"array","items":{"type":"string","minLength":1},"minItems":1}},"required":["$in"]},{"type":"object","properties":{"$eq":{"type":"string","minLength":1}},"required":["$eq"]}]}},"default":[],"description":"Targeting criteria. Each element is an object with one key (characteristic slug) and one value (operator object). Call GET /recruitment/user-interviews/options for valid slugs."},"title":{"type":"string","minLength":1,"maxLength":200,"description":"Study title shown to panelists. Used as the base for auto-disambiguation: if the title (or project name when omitted) already exists on another order in this project across either panel, \" 2\", \" 3\", … is appended so the resolved name is unique. The final name is returned in the response `name` field."},"description":{"type":"string","maxLength":2000,"description":"Study description shown to panelists. Defaults to project description."},"preparation_instructions":{"type":"string","maxLength":2000,"description":"Preparation instructions for participants. Defaults to \"This is an AI-led interview.\""},"device_requirements":{"type":"array","items":{"type":"string"},"description":"Required device types (e.g., \"desktop\", \"mobile\")."},"auto_invite":{"type":"boolean","description":"Strella's internal AI auto-approval for this order. When `true`, screener passers rated 4+ by Strella's automated evaluation are auto-invited to interview without manual review. This is NOT the vendor panel's auto-invite: User Interviews orders always send `requireApproval: true` (participants are always held for panel-side manual review). Defaults to the project's auto-recruit setting when omitted.","example":false},"sandbox":{"type":"boolean","description":"Sandbox mode. When true, the order is created with `sandbox: true`, the User Interviews integration is skipped, and a synthetic self-recruited participant + session is generated for end-to-end testing. Equivalent to sending the `X-Sandbox: true` request header.","example":false}},"required":["target_market","participant_count","incentive_per_participant"],"additionalProperties":false,"x-tags":["Recruitment"]},"RecruitmentOrderRespondentDetail":{"type":"object","properties":{"id":{"type":"string","description":"Recruitment order ID (cuid).","example":"cm7f2kp3s0001jv04ab9c4d6e"},"project_id":{"type":"string","description":"Parent project ID.","example":"cm7a9qk2x0001jv04hn3w8m5p"},"name":{"type":"string","description":"Display name shown in the app and to panelists. Auto-disambiguated server-side: a base title (caller-supplied or project name) is suffixed with \" 2\", \" 3\", … if it would collide with another order in the same project across both panels. Omitted on legacy rows that pre-date the field.","example":"Acme Onboarding Study 2"},"panel":{"type":"string","enum":["respondent"]},"status":{"type":"string","enum":["pending","recruiting","filled","closed","paused"],"description":"Current lifecycle status of the recruitment order.","example":"pending"},"participant_count":{"type":"integer","description":"Requested number of participants.","example":10},"incentive_per_participant":{"type":"integer","description":"Per-participant incentive in whole dollars (USD).","example":25},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp.","example":"2026-03-20T12:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of the last modification.","example":"2026-03-21T10:15:00.000Z"},"submitted_at":{"type":["string","null"],"format":"date-time","description":"When the order was submitted to the vendor. Null if still pending.","example":null},"sandbox":{"type":"boolean","enum":[true],"description":"Present and `true` only when the order was created in sandbox mode (via `X-Sandbox: true` header or `sandbox: true` body field). Omitted on real orders. Sandbox orders never reach the recruitment panel — a synthetic self-recruited participant + session is generated instead so integrators can verify their end-to-end flow without spending credits.","example":true},"auto_invite":{"type":"boolean","description":"Strella's internal AI auto-approval for this order. When `true`, screener passers rated 4+ by Strella's automated evaluation are auto-invited to interview without manual review. This is NOT the vendor panel's auto-invite: the recruitment panel never auto-admits participants regardless of this flag (Respondent orders always send `automations.autoinvite: false`; User Interviews orders always send `requireApproval: true`).","example":false},"counts":{"type":"object","properties":{"qualified":{"type":"integer","minimum":0,"description":"Participants who passed the screener.","example":15},"invited":{"type":"integer","minimum":0,"description":"Participants invited to interview.","example":12},"completed":{"type":"integer","minimum":0,"description":"Participants who completed and were accepted.","example":8},"pending":{"type":"integer","minimum":0,"description":"Participants awaiting screener results or review.","example":4},"as_of":{"type":["string","null"],"format":"date-time","description":"When these counts were last refreshed from the vendor. Null if never synced. Respondent: webhook-driven, near-real-time. User Interviews: refreshed by the 10-min cron only when new participants arrive — status changes made on the vendor side without new sign-ups will not bump `as_of`, so counts can be stale for arbitrarily long in steady state.","example":"2026-04-28T15:42:00.000Z"}},"required":["qualified","invited","completed","pending","as_of"],"description":"Participant progress counts for the order."},"targeting":{"oneOf":[{"type":"object","properties":{"countries":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Target countries. Required. Use codes from GET /recruitment/respondent/options."},"age_groups":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target age ranges. Omit for all ages."},"genders":{"type":["object","null"],"properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"],"description":"Target gender. Null or omit for no gender filter."},"household_incomes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target household income brackets."},"education_levels":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target education levels."},"ethnicities":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target ethnicities."},"market_type":{"type":"string","enum":["b2b"]},"job_titles":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Required for B2B. Job title IDs from GET /recruitment/respondent/job-titles."},"job_functions":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Required for B2B. Job function codes from GET /recruitment/respondent/options."},"industries":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Required for B2B. Industry IDs from GET /recruitment/respondent/options."},"seniority_levels":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Seniority levels. Omit for all levels."},"company_sizes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Company size filter. Omit for all sizes."}},"required":["countries","market_type","job_titles","job_functions","industries"]},{"type":"object","properties":{"countries":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Target countries. Required. Use codes from GET /recruitment/respondent/options."},"age_groups":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target age ranges. Omit for all ages."},"genders":{"type":["object","null"],"properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"],"description":"Target gender. Null or omit for no gender filter."},"household_incomes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target household income brackets."},"education_levels":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target education levels."},"ethnicities":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target ethnicities."},"market_type":{"type":"string","enum":["b2c"]},"research_topics":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"maxItems":2,"description":"Required for B2C. Research topic codes (1-2) from GET /recruitment/respondent/options."}},"required":["countries","market_type","research_topics"]}],"description":"Respondent.io targeting criteria for this order."}},"required":["id","project_id","panel","status","participant_count","incentive_per_participant","created_at","updated_at","submitted_at","auto_invite","counts","targeting"],"x-tags":["Recruitment"]},"RecruitmentOrderUserInterviewsDetail":{"type":"object","properties":{"id":{"type":"string","description":"Recruitment order ID (cuid).","example":"cm7f2kp3s0001jv04ab9c4d6e"},"project_id":{"type":"string","description":"Parent project ID.","example":"cm7a9qk2x0001jv04hn3w8m5p"},"name":{"type":"string","description":"Display name shown in the app and to panelists. Auto-disambiguated server-side: a base title (caller-supplied or project name) is suffixed with \" 2\", \" 3\", … if it would collide with another order in the same project across both panels. Omitted on legacy rows that pre-date the field.","example":"Acme Onboarding Study 2"},"panel":{"type":"string","enum":["user_interviews"]},"status":{"type":"string","enum":["pending","recruiting","filled","closed","paused"],"description":"Current lifecycle status of the recruitment order.","example":"pending"},"participant_count":{"type":"integer","description":"Requested number of participants.","example":10},"incentive_per_participant":{"type":"integer","description":"Per-participant incentive in whole dollars (USD).","example":25},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp.","example":"2026-03-20T12:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of the last modification.","example":"2026-03-21T10:15:00.000Z"},"submitted_at":{"type":["string","null"],"format":"date-time","description":"When the order was submitted to the vendor. Null if still pending.","example":null},"sandbox":{"type":"boolean","enum":[true],"description":"Present and `true` only when the order was created in sandbox mode (via `X-Sandbox: true` header or `sandbox: true` body field). Omitted on real orders. Sandbox orders never reach the recruitment panel — a synthetic self-recruited participant + session is generated instead so integrators can verify their end-to-end flow without spending credits.","example":true},"auto_invite":{"type":"boolean","description":"Strella's internal AI auto-approval for this order. When `true`, screener passers rated 4+ by Strella's automated evaluation are auto-invited to interview without manual review. This is NOT the vendor panel's auto-invite: the recruitment panel never auto-admits participants regardless of this flag (Respondent orders always send `automations.autoinvite: false`; User Interviews orders always send `requireApproval: true`).","example":false},"counts":{"type":"object","properties":{"qualified":{"type":"integer","minimum":0,"description":"Participants who passed the screener.","example":15},"invited":{"type":"integer","minimum":0,"description":"Participants invited to interview.","example":12},"completed":{"type":"integer","minimum":0,"description":"Participants who completed and were accepted.","example":8},"pending":{"type":"integer","minimum":0,"description":"Participants awaiting screener results or review.","example":4},"as_of":{"type":["string","null"],"format":"date-time","description":"When these counts were last refreshed from the vendor. Null if never synced. Respondent: webhook-driven, near-real-time. User Interviews: refreshed by the 10-min cron only when new participants arrive — status changes made on the vendor side without new sign-ups will not bump `as_of`, so counts can be stale for arbitrarily long in steady state.","example":"2026-04-28T15:42:00.000Z"}},"required":["qualified","invited","completed","pending","as_of"],"description":"Participant progress counts for the order."},"targeting":{"type":"array","items":{"type":"object","additionalProperties":{}},"description":"User Interviews targeting criteria. Each element specifies a demographic or professional filter."},"target_market":{"type":"string","enum":["b2b","b2c"],"description":"Whether the order targets businesses (b2b) or consumers (b2c).","example":"b2c"}},"required":["id","project_id","panel","status","participant_count","incentive_per_participant","created_at","updated_at","submitted_at","auto_invite","counts","targeting","target_market"],"x-tags":["Recruitment"]},"PatchRespondentOrder":{"type":"object","properties":{"participant_count":{"type":"integer","minimum":1,"maximum":150,"description":"Requested number of participants (1–150).","example":10},"auto_invite":{"type":"boolean","description":"Strella's internal AI auto-approval for this order (screener passers rated 4+ auto-invited without manual review). NOT the vendor panel's auto-invite — the panel never auto-admits regardless. Omit to leave unchanged.","example":false},"incentive_per_participant":{"type":"integer","minimum":5,"maximum":200,"description":"Per-participant incentive in whole dollars USD (5–200).","example":25},"targeting":{"oneOf":[{"type":"object","properties":{"countries":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Target countries. Required. Use codes from GET /recruitment/respondent/options."},"age_groups":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target age ranges. Omit for all ages."},"genders":{"type":["object","null"],"properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"],"description":"Target gender. Null or omit for no gender filter."},"household_incomes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target household income brackets."},"education_levels":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target education levels."},"ethnicities":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target ethnicities."},"market_type":{"type":"string","enum":["b2b"]},"job_titles":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Required for B2B. Job title IDs from GET /recruitment/respondent/job-titles."},"job_functions":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Required for B2B. Job function codes from GET /recruitment/respondent/options."},"industries":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Required for B2B. Industry IDs from GET /recruitment/respondent/options."},"seniority_levels":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Seniority levels. Omit for all levels."},"company_sizes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Company size filter. Omit for all sizes."}},"required":["countries","market_type","job_titles","job_functions","industries"]},{"type":"object","properties":{"countries":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Target countries. Required. Use codes from GET /recruitment/respondent/options."},"age_groups":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target age ranges. Omit for all ages."},"genders":{"type":["object","null"],"properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"],"description":"Target gender. Null or omit for no gender filter."},"household_incomes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target household income brackets."},"education_levels":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target education levels."},"ethnicities":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target ethnicities."},"market_type":{"type":"string","enum":["b2c"]},"research_topics":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"maxItems":2,"description":"Required for B2C. Research topic codes (1-2) from GET /recruitment/respondent/options."}},"required":["countries","market_type","research_topics"]}],"description":"Respondent.io targeting criteria."},"title":{"type":"string","minLength":1,"maxLength":100,"description":"Public study title shown to panelists (max 100 chars). On rename, the supplied title is the base for auto-disambiguation: \" 2\", \" 3\", … is appended if it would collide with another order in the same project across either panel. Sending the order's current title is a no-op. The resolved name is returned in the response `name` field.","example":"Acme Onboarding Study"}},"additionalProperties":false,"x-tags":["Recruitment"]},"PatchUserInterviewsOrder":{"type":"object","properties":{"target_market":{"type":"string","enum":["b2b","b2c"],"description":"Whether the order targets businesses (b2b) or consumers (b2c).","example":"b2c"},"participant_count":{"type":"integer","minimum":1,"maximum":100,"description":"Requested number of participants (1–100).","example":10},"auto_invite":{"type":"boolean","description":"Strella's internal AI auto-approval for this order (screener passers rated 4+ auto-invited without manual review). NOT the vendor panel's auto-invite — the panel never auto-admits regardless. Omit to leave unchanged.","example":false},"incentive_per_participant":{"type":"integer","minimum":1,"maximum":200,"description":"Per-participant incentive in whole dollars USD (1–200).","example":30},"targeting":{"type":"array","items":{"type":"object","additionalProperties":{"anyOf":[{"type":"object","properties":{"$gte":{"type":"integer","minimum":18},"$lte":{"type":"integer","maximum":99}},"required":["$gte","$lte"]},{"type":"object","properties":{"$in":{"type":"array","items":{"type":"string","minLength":1},"minItems":1}},"required":["$in"]},{"type":"object","properties":{"$eq":{"type":"string","minLength":1}},"required":["$eq"]}]}},"description":"User Interviews targeting criteria. Each criterion specifies a demographic or professional filter."},"title":{"type":"string","minLength":1,"maxLength":200,"description":"Public study title shown to panelists (max 200 chars). On rename, the supplied title is used as the base for auto-disambiguation: \" 2\", \" 3\", … is appended if it would collide with another order in the same project across either panel. Sending the order's current title is a no-op. The resolved name is returned in the response `name` field.","example":"UX Research — Mobile Banking"},"description":{"type":"string","maxLength":2000,"description":"Detailed description shown to potential participants (max 2000 chars).","example":"We are looking for users who regularly use mobile banking apps to participate in a 30-minute interview."}},"additionalProperties":false,"x-tags":["Recruitment"]},"ShareLinkStatus":{"type":"string","enum":["active","disabled","complete"],"description":"Share link status. `active`: accepting participants. `disabled`: closed to new participants. `complete`: reached its participant limit (set automatically on read when the resolved limit is hit).","example":"active","x-tags":["Share Links"]},"ShareLinkListItem":{"type":"object","properties":{"id":{"type":"string","description":"Share link identifier.","example":"b7c1e2f3-4a5b-6c7d-8e9f-0a1b2c3d4e5f"},"name":{"type":"string","description":"Human-readable label for the share link.","example":"LinkedIn outreach"},"url_slug":{"type":["string","null"],"description":"Custom URL label the participant-facing token was derived from. Null on links created before custom URLs, or when no slug was set.","example":"linkedin-outreach"},"share_url":{"type":"string","description":"Full participant-facing interview link. Share this URL with participants or embed it in outreach.","example":"https://app.strella.io/onboard/linkedin-outreach-1a2b3c4d"},"status":{"type":"string","enum":["active","disabled","complete"],"description":"Share link status. `active`: accepting participants. `disabled`: closed to new participants. `complete`: reached its participant limit (set automatically on read when the resolved limit is hit).","example":"active"},"participant_limit_enabled":{"type":"boolean","description":"Whether this link has its own participant limit. False when the link inherits the project participant target, has its limit turned off, or has the limit turned on with no number set; `participant_limit` then holds the project target or null.","example":true},"participant_limit":{"type":["integer","null"],"description":"Resolved cap on participants for this link. Null means no limit. Resolves the per-link limit, falling back to the project participant target when the link inherits.","example":50},"require_screener":{"type":"boolean","description":"Resolved: whether participants must pass the screener before the interview.","example":true},"auto_invite":{"type":"boolean","description":"Resolved: whether Strella's AI auto-invites screener passers rated 4+ on this link (internal approval, never the vendor panel).","example":false},"collect_email":{"type":"boolean","description":"Resolved: whether the link collects participant email.","example":true},"collect_names":{"type":"boolean","description":"Resolved: whether the link collects participant name.","example":true},"first_name_only":{"type":"boolean","description":"Whether the link asks for a first name only, with no last name. Always false while the link does not collect names. A per-link setting with no project value to inherit.","example":false},"send_reminder_emails":{"type":"boolean","description":"Resolved: whether reminder emails are sent to participants of this link.","example":false},"call_finished_redirect_url":{"type":["string","null"],"description":"Resolved redirect URL after the interview completes. Null when unset at both link and project level.","example":"https://example.com/thank-you"},"screenout_redirect_url":{"type":["string","null"],"description":"Resolved redirect URL when a participant is screened out. Null when unset.","example":null},"quota_full_redirect_url":{"type":["string","null"],"description":"Resolved redirect URL when the quota is full. Null when unset.","example":null},"call_dropout_redirect_url":{"type":["string","null"],"description":"Resolved redirect URL when a participant drops out mid-call. Null when unset.","example":null},"participant_count":{"type":"integer","minimum":0,"description":"Number of participants who onboarded through this link (non-deleted).","example":12},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp.","example":"2026-03-20T12:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of the last modification.","example":"2026-03-21T10:15:00.000Z"}},"required":["id","name","url_slug","share_url","status","participant_limit_enabled","participant_limit","require_screener","auto_invite","collect_email","collect_names","first_name_only","send_reminder_emails","call_finished_redirect_url","screenout_redirect_url","quota_full_redirect_url","call_dropout_redirect_url","participant_count","created_at","updated_at"],"x-tags":["Share Links"]},"ShareLinkListQuery":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"cursor":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"sort":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"}},"x-tags":["Share Links"]},"ShareLinkListResponse":{"type":"object","properties":{"share_links":{"type":"array","items":{"$ref":"#/components/schemas/ShareLinkListItem"},"description":"Share links for the project, each with every setting fully resolved (per-link override falling back to the project value). Sorted per the ?sort query param (default \"created_at:desc\")."},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["share_links","meta"],"x-tags":["Share Links"]},"CreateShareLinkBody":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80,"description":"Human-readable label (1-80 chars). Must be unique per project.","example":"LinkedIn outreach"},"url_slug":{"type":["string","null"],"maxLength":80,"description":"Optional custom URL label the participant-facing token is derived from. Omit or null for a random token.","example":"linkedin-outreach"}},"required":["name"],"additionalProperties":false,"x-tags":["Share Links"]},"UpdateShareLinkBody":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80,"description":"Human-readable label (1-80 chars). Must be unique per project."},"url_slug":{"type":["string","null"],"maxLength":80,"description":"Custom URL label. Changing it regenerates the participant token, so the previous share URL stops resolving. Null clears the slug."},"status":{"type":"string","enum":["active","disabled","complete"],"description":"Set the link status.","example":"active"},"participant_limit_enabled":{"type":["boolean","null"],"description":"Whether this link has its own participant limit. `null` inherits the project setting; `false` turns off this link's own cap, and the project limit still applies; `true` uses `participant_limit`."},"participant_limit":{"type":["integer","null"],"exclusiveMinimum":0,"description":"Per-link participant cap (used when `participant_limit_enabled` is true). `null` falls back to the project participant target."},"require_screener":{"type":["boolean","null"],"description":"Whether participants must pass the screener. `null` inherits the project setting."},"auto_invite":{"type":["boolean","null"],"description":"Whether Strella's AI auto-invites screener passers rated 4+ on this link (internal approval, never the vendor panel). `null` inherits the project setting."},"collect_email":{"type":["boolean","null"],"description":"Whether to collect participant email. `null` inherits the project setting."},"collect_names":{"type":["boolean","null"],"description":"Whether to collect participant name. `null` inherits the project setting."},"first_name_only":{"type":"boolean","description":"Ask for a first name only, with no last name. `true` requires the link to collect names (`collect_names`, or the project setting when the link inherits), else 422. Turning `collect_names` off turns this off. Not nullable: there is no project value to inherit."},"send_reminder_emails":{"type":["boolean","null"],"description":"Whether to send reminder emails. `null` inherits the project setting."},"call_finished_redirect_url":{"type":["string","null"],"format":"uri","description":"Per-link redirect after the interview completes. Null clears it."},"screenout_redirect_url":{"type":["string","null"],"format":"uri","description":"Per-link redirect when a participant is screened out. Null clears it."},"quota_full_redirect_url":{"type":["string","null"],"format":"uri","description":"Per-link redirect when the quota is full. Null clears it."},"call_dropout_redirect_url":{"type":["string","null"],"format":"uri","description":"Per-link redirect when a participant drops out mid-call. Null clears it."}},"additionalProperties":false,"x-tags":["Share Links"]},"ProjectSearchResult":{"type":"object","properties":{"id":{"type":"string","description":"Unique project identifier","example":"cm7a9qk2x0001jv04hn3w8m5p"},"project_name":{"type":"string","description":"Internal project name (falls back to participant-facing name)","example":"Q4 Concept Test"},"participant_facing_name":{"type":"string","description":"Name shown to participants","example":"Product Interview"},"description":{"type":["string","null"],"description":"Study description","example":"Testing the new onboarding flow"},"created_at":{"type":"string","description":"ISO 8601 creation timestamp","example":"2026-01-15T00:00:00.000Z"},"score":{"type":["number","null"],"description":"Relevance score. For keyword mode: field-weight × token-coverage. For semantic mode: null (order is the ranking). Check meta.mode to interpret.","example":3}},"required":["id","project_name","participant_facing_name","created_at","score"],"x-tags":["Projects"]},"ProjectSearchDetailResult":{"type":"object","properties":{"id":{"type":"string","description":"Unique project identifier","example":"cm7a9qk2x0001jv04hn3w8m5p"},"project_name":{"type":"string","description":"Internal project name (falls back to participant-facing name)","example":"Q4 Onboarding Research"},"participant_facing_name":{"type":"string","description":"Name shown to participants","example":"Product Experience Interview"},"slug":{"type":"string","description":"URL-safe project slug","example":"vk7d0kv4VXPkbNjP8sTj8QrYF"},"organization_id":{"type":"string","description":"Organization that owns this project"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-10-15T14:30:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last-modified timestamp. Use ?sort=updated_at:desc for incremental sync — compare against your stored checkpoint and stop when results are older than your last run.","example":"2025-11-20T09:15:00.000Z"},"participants_count":{"type":"integer","minimum":0,"description":"Number of unique participants with completed recording and analysis","example":15},"survey_responses_count":{"type":"integer","minimum":0,"description":"Number of structured survey responses collected during sessions","example":42},"has_screener":{"type":"boolean","description":"Whether the project has published screener questions","example":true},"moderation_mode":{"type":"string","enum":["ai","human"],"description":"Who runs each interview: `ai` (the AI moderator) or `human` (a researcher on a scheduled video call). Set at creation and cannot be changed.","example":"ai"},"anonymize_participant_names":{"type":["boolean","null"],"description":"Whether participant names are anonymized in the researcher view","example":false},"questionnaire_items_count":{"type":"integer","minimum":0,"description":"Number of items in the active questionnaire","example":8},"has_section_blocks":{"type":"boolean","description":"Whether the questionnaire contains section blocks","example":true},"has_unpublished_changes":{"type":"boolean","description":"Whether the questionnaire has unpublished draft changes (draftItemsV2 differs from itemsV2)","example":false},"unqualified_count":{"type":"integer","minimum":0,"description":"Participants in screener funnel (started or incomplete)","example":10},"qualified_count":{"type":"integer","minimum":0,"description":"Participants who passed screener and are waiting to be invited","example":4},"invited_count":{"type":"integer","minimum":0,"description":"Participants approved to join interview","example":3},"in_review_count":{"type":"integer","minimum":0,"description":"Participants who completed an interview and are awaiting researcher quality review","example":2},"accepted_count":{"type":"integer","minimum":0,"description":"Participants accepted by researcher (paid and finalized)","example":5},"rejected_count":{"type":"integer","minimum":0,"description":"Participants who failed screener, quality review, or did not attend","example":1},"draft_orders_count":{"type":"integer","minimum":0,"description":"Number of draft (unpublished) recruitment orders","example":0},"workspace_id":{"type":"string","description":"Workspace ID, omitted when project has no workspace"},"description":{"type":"string","description":"Project description. Omitted when not set (use PATCH with null to clear, or omit to leave unchanged).","example":"Exploring onboarding friction for new users"},"screener_required":{"type":"boolean","description":"Whether a screener is required before participation","example":false},"questionnaire_id":{"type":"string","description":"ID of the active questionnaire, omitted when no questionnaire exists","example":"cm7e8kp3s0005jv04qr9w4f6n"},"research_objectives":{"type":["string","null"],"description":"Specific research questions to investigate","example":"What friction points exist in the first-run experience?"},"product_description":{"type":["string","null"],"description":"Description of the product being researched","example":"A SaaS project management tool for small teams"},"target_audience":{"type":["string","null"],"description":"Target audience for the research","example":"Product managers aged 25-45"},"project_context":{"type":["string","null"],"description":"Secure, business-internal research context. Never shown to participants. Only included when `?project_context=true` is passed.","example":"We are launching a redesigned checkout next month and need to validate the new flow before rollout"},"default_language":{"type":["string","null"],"description":"Default interview language (ISO 639-1 code)","example":"en"},"participant_target_count":{"type":["integer","null"],"description":"Target number of participants","example":20},"auto_invite":{"type":["boolean","null"],"description":"Whether qualified participants are automatically invited to interview. API-created recruitment orders always have per-order auto-invite disabled regardless of this field — this setting applies to self-recruited participants only.","example":true},"close_date":{"type":["string","null"],"format":"date-time","description":"ISO 8601 date when the project closes for new participants","example":"2026-06-01T00:00:00.000Z"},"email_required":{"type":["boolean","null"],"description":"Whether participant email is required","example":true},"name_required":{"type":["boolean","null"],"description":"Whether participant name is required","example":true},"tts_provider":{"type":["string","null"],"description":"TTS provider for the AI moderator. Determines which voices are available. Call GET /projects/settings for the full catalog.","example":"openai"},"moderator_voice":{"type":["string","null"],"description":"Voice identifier for the AI moderator","example":"sage"},"moderator_name":{"type":["string","null"],"description":"Display name of the AI moderator shown to participants","example":"Strella"},"greet_participant_by_name":{"type":["boolean","null"],"description":"Whether the moderator speaks the participant's name in its opening greeting (\"Hi Abe, I'm Strella\" vs \"Hi there, I'm Strella\"). Affects the spoken greeting only — distinct from `anonymize_participant_names`, which anonymizes the name everywhere. With this off the real name is still stored and still keys results and transcripts. `null` when the project has no questionnaire yet.","example":true},"voice_speed":{"type":["number","null"],"description":"Voice speed multiplier (0.5 to 2.0)","example":1},"general_moderator_prompt":{"type":["string","null"],"description":"Custom system prompt for the moderator","example":"Be concise and focus on pain points"},"default_follow_up_depth":{"type":["integer","null"],"description":"Default follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High","example":2},"video_sharing_requirement":{"type":["string","null"],"enum":["required_to_join","disabled",null],"description":"Camera recording for participants: `required_to_join` (on) or `disabled` (off)","example":"required_to_join"},"screen_sharing_requirement":{"type":["string","null"],"enum":["enabled","disabled",null],"description":"Screen sharing requirement for participants","example":"enabled"},"device_restriction":{"type":["string","null"],"enum":["desktop_only","mobile_only","no_restriction",null],"description":"Device restriction for interview sessions","example":"desktop_only"},"push_to_talk":{"type":["boolean","null"],"description":"Whether push-to-talk is enabled. Requires desktop_only.","example":false},"blur_participant_camera":{"type":["boolean","null"],"description":"Whether to blur the participant camera feed. Requires webcam enabled and desktop_only.","example":false},"blur_participant_screen_share":{"type":["boolean","null"],"description":"Whether to blur the participant screen share feed. Requires screen sharing enabled.","example":false},"allow_disabling_screen_recording":{"type":["boolean","null"],"description":"Whether participants can disable screen recording. Requires screen sharing enabled.","example":false},"screen_share_display_surface":{"type":["string","null"],"enum":["window","monitor",null],"description":"Screen share capture mode: window (single window) or monitor (entire screen). Requires screen sharing enabled.","example":"window"},"require_screen_share_verification":{"type":["boolean","null"],"description":"Whether to verify screen share content before proceeding. Has no effect while screen sharing is off.","example":true},"voice_modulation_enabled":{"type":["boolean","null"],"description":"Whether voice anonymization is enabled for participants","example":false},"default_captions_enabled":{"type":["boolean","null"],"description":"Whether captions are enabled by default during interviews","example":false},"session_timeout":{"anyOf":[{"type":"number","enum":[30]},{"type":"number","enum":[60]},{"type":"number","enum":[120]},{"type":"number","enum":[180]},{"type":"number","enum":[300]},{"type":"number","enum":[600]},{"type":"number","enum":[900]},{"type":"null"}],"description":"Session inactivity timeout in seconds. Valid values: 30, 60, 120, 180, 300, 600, 900.","example":300},"pronunciation_guide":{"type":["array","null"],"items":{"type":"object","properties":{"word":{"type":"string","description":"Word or term","example":"Strella"},"pronunciation":{"type":"string","description":"Phonetic pronunciation","example":"STREL-uh"}},"required":["word","pronunciation"]},"description":"Pronunciation dictionary for the moderator"},"allowed_languages":{"type":"array","items":{"type":"string"},"description":"Languages the moderator supports (ISO 639-1 codes). Empty array means all languages.","example":["en","es"]},"analysis_language":{"type":"string","description":"Language for AI-generated analysis output (summaries, themes)","example":"en-US"},"manually_closed":{"type":"boolean","description":"Whether the project is manually closed for new participants. Set via PATCH.","example":false},"reject_when_quota_full":{"type":"boolean","description":"Whether participants are rejected once every quota they match is full (UI label: \"Reject participants when a quota is full\"). Only takes effect once the project has at least one active quota; with no active quotas, participants are never rejected on this basis.","example":true},"require_quota_match":{"type":"boolean","description":"Whether participants who match no quota are rejected (UI label: \"Require participants to match a quota\"). Only takes effect once the project has at least one active quota; with no active quotas, participants are never rejected on this basis.","example":false},"custom_fields":{"type":"array","items":{"$ref":"#/components/schemas/CustomFieldDefinition"},"description":"Every custom field on the project's participants table, including Tags and AI-coded categories, in column order. To change them, send the whole list back as `custom_fields` on `PATCH /projects/{project_id}`."},"share_url":{"type":"string","description":"Participant-facing interview link. Absent until the first PUT /questionnaire/items call publishes the questionnaire. Share this URL with participants or embed in outreach flows.","example":"https://app.strella.io/onboard/abc123"},"score":{"type":["number","null"],"description":"Relevance score (see ProjectSearchResult.score)","example":3}},"required":["id","project_name","participant_facing_name","slug","organization_id","created_at","updated_at","participants_count","survey_responses_count","has_screener","moderation_mode","anonymize_participant_names","questionnaire_items_count","has_section_blocks","has_unpublished_changes","unqualified_count","qualified_count","invited_count","in_review_count","accepted_count","rejected_count","draft_orders_count","screener_required","research_objectives","product_description","target_audience","default_language","participant_target_count","auto_invite","close_date","email_required","name_required","tts_provider","moderator_voice","moderator_name","greet_participant_by_name","voice_speed","general_moderator_prompt","default_follow_up_depth","video_sharing_requirement","screen_sharing_requirement","device_restriction","push_to_talk","blur_participant_camera","blur_participant_screen_share","allow_disabling_screen_recording","screen_share_display_surface","require_screen_share_verification","voice_modulation_enabled","default_captions_enabled","session_timeout","pronunciation_guide","allowed_languages","analysis_language","manually_closed","reject_when_quota_full","require_quota_match","custom_fields","score"],"x-tags":["Projects"]},"ProjectSearchResponse":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique project identifier","example":"cm7a9qk2x0001jv04hn3w8m5p"},"project_name":{"type":"string","description":"Internal project name (falls back to participant-facing name)","example":"Q4 Concept Test"},"participant_facing_name":{"type":"string","description":"Name shown to participants","example":"Product Interview"},"description":{"type":["string","null"],"description":"Study description","example":"Testing the new onboarding flow"},"created_at":{"type":"string","description":"ISO 8601 creation timestamp","example":"2026-01-15T00:00:00.000Z"},"score":{"type":["number","null"],"description":"Relevance score. For keyword mode: field-weight × token-coverage. For semantic mode: null (order is the ranking). Check meta.mode to interpret.","example":3}},"required":["id","project_name","participant_facing_name","created_at","score"]}},"meta":{"type":"object","properties":{"mode":{"type":"string","enum":["keyword","semantic"],"description":"The search mode that produced these results. For \"auto\" requests, this shows which mode was actually used.","example":"keyword"},"pagination":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["mode","pagination"]}},"required":["results","meta"],"x-tags":["Projects"]},"ProjectSearchDetailResponse":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique project identifier","example":"cm7a9qk2x0001jv04hn3w8m5p"},"project_name":{"type":"string","description":"Internal project name (falls back to participant-facing name)","example":"Q4 Onboarding Research"},"participant_facing_name":{"type":"string","description":"Name shown to participants","example":"Product Experience Interview"},"slug":{"type":"string","description":"URL-safe project slug","example":"vk7d0kv4VXPkbNjP8sTj8QrYF"},"organization_id":{"type":"string","description":"Organization that owns this project"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-10-15T14:30:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last-modified timestamp. Use ?sort=updated_at:desc for incremental sync — compare against your stored checkpoint and stop when results are older than your last run.","example":"2025-11-20T09:15:00.000Z"},"participants_count":{"type":"integer","minimum":0,"description":"Number of unique participants with completed recording and analysis","example":15},"survey_responses_count":{"type":"integer","minimum":0,"description":"Number of structured survey responses collected during sessions","example":42},"has_screener":{"type":"boolean","description":"Whether the project has published screener questions","example":true},"moderation_mode":{"type":"string","enum":["ai","human"],"description":"Who runs each interview: `ai` (the AI moderator) or `human` (a researcher on a scheduled video call). Set at creation and cannot be changed.","example":"ai"},"anonymize_participant_names":{"type":["boolean","null"],"description":"Whether participant names are anonymized in the researcher view","example":false},"questionnaire_items_count":{"type":"integer","minimum":0,"description":"Number of items in the active questionnaire","example":8},"has_section_blocks":{"type":"boolean","description":"Whether the questionnaire contains section blocks","example":true},"has_unpublished_changes":{"type":"boolean","description":"Whether the questionnaire has unpublished draft changes (draftItemsV2 differs from itemsV2)","example":false},"unqualified_count":{"type":"integer","minimum":0,"description":"Participants in screener funnel (started or incomplete)","example":10},"qualified_count":{"type":"integer","minimum":0,"description":"Participants who passed screener and are waiting to be invited","example":4},"invited_count":{"type":"integer","minimum":0,"description":"Participants approved to join interview","example":3},"in_review_count":{"type":"integer","minimum":0,"description":"Participants who completed an interview and are awaiting researcher quality review","example":2},"accepted_count":{"type":"integer","minimum":0,"description":"Participants accepted by researcher (paid and finalized)","example":5},"rejected_count":{"type":"integer","minimum":0,"description":"Participants who failed screener, quality review, or did not attend","example":1},"draft_orders_count":{"type":"integer","minimum":0,"description":"Number of draft (unpublished) recruitment orders","example":0},"workspace_id":{"type":"string","description":"Workspace ID, omitted when project has no workspace"},"description":{"type":"string","description":"Project description. Omitted when not set (use PATCH with null to clear, or omit to leave unchanged).","example":"Exploring onboarding friction for new users"},"screener_required":{"type":"boolean","description":"Whether a screener is required before participation","example":false},"questionnaire_id":{"type":"string","description":"ID of the active questionnaire, omitted when no questionnaire exists","example":"cm7e8kp3s0005jv04qr9w4f6n"},"research_objectives":{"type":["string","null"],"description":"Specific research questions to investigate","example":"What friction points exist in the first-run experience?"},"product_description":{"type":["string","null"],"description":"Description of the product being researched","example":"A SaaS project management tool for small teams"},"target_audience":{"type":["string","null"],"description":"Target audience for the research","example":"Product managers aged 25-45"},"project_context":{"type":["string","null"],"description":"Secure, business-internal research context. Never shown to participants. Only included when `?project_context=true` is passed.","example":"We are launching a redesigned checkout next month and need to validate the new flow before rollout"},"default_language":{"type":["string","null"],"description":"Default interview language (ISO 639-1 code)","example":"en"},"participant_target_count":{"type":["integer","null"],"description":"Target number of participants","example":20},"auto_invite":{"type":["boolean","null"],"description":"Whether qualified participants are automatically invited to interview. API-created recruitment orders always have per-order auto-invite disabled regardless of this field — this setting applies to self-recruited participants only.","example":true},"close_date":{"type":["string","null"],"format":"date-time","description":"ISO 8601 date when the project closes for new participants","example":"2026-06-01T00:00:00.000Z"},"email_required":{"type":["boolean","null"],"description":"Whether participant email is required","example":true},"name_required":{"type":["boolean","null"],"description":"Whether participant name is required","example":true},"tts_provider":{"type":["string","null"],"description":"TTS provider for the AI moderator. Determines which voices are available. Call GET /projects/settings for the full catalog.","example":"openai"},"moderator_voice":{"type":["string","null"],"description":"Voice identifier for the AI moderator","example":"sage"},"moderator_name":{"type":["string","null"],"description":"Display name of the AI moderator shown to participants","example":"Strella"},"greet_participant_by_name":{"type":["boolean","null"],"description":"Whether the moderator speaks the participant's name in its opening greeting (\"Hi Abe, I'm Strella\" vs \"Hi there, I'm Strella\"). Affects the spoken greeting only — distinct from `anonymize_participant_names`, which anonymizes the name everywhere. With this off the real name is still stored and still keys results and transcripts. `null` when the project has no questionnaire yet.","example":true},"voice_speed":{"type":["number","null"],"description":"Voice speed multiplier (0.5 to 2.0)","example":1},"general_moderator_prompt":{"type":["string","null"],"description":"Custom system prompt for the moderator","example":"Be concise and focus on pain points"},"default_follow_up_depth":{"type":["integer","null"],"description":"Default follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High","example":2},"video_sharing_requirement":{"type":["string","null"],"enum":["required_to_join","disabled",null],"description":"Camera recording for participants: `required_to_join` (on) or `disabled` (off)","example":"required_to_join"},"screen_sharing_requirement":{"type":["string","null"],"enum":["enabled","disabled",null],"description":"Screen sharing requirement for participants","example":"enabled"},"device_restriction":{"type":["string","null"],"enum":["desktop_only","mobile_only","no_restriction",null],"description":"Device restriction for interview sessions","example":"desktop_only"},"push_to_talk":{"type":["boolean","null"],"description":"Whether push-to-talk is enabled. Requires desktop_only.","example":false},"blur_participant_camera":{"type":["boolean","null"],"description":"Whether to blur the participant camera feed. Requires webcam enabled and desktop_only.","example":false},"blur_participant_screen_share":{"type":["boolean","null"],"description":"Whether to blur the participant screen share feed. Requires screen sharing enabled.","example":false},"allow_disabling_screen_recording":{"type":["boolean","null"],"description":"Whether participants can disable screen recording. Requires screen sharing enabled.","example":false},"screen_share_display_surface":{"type":["string","null"],"enum":["window","monitor",null],"description":"Screen share capture mode: window (single window) or monitor (entire screen). Requires screen sharing enabled.","example":"window"},"require_screen_share_verification":{"type":["boolean","null"],"description":"Whether to verify screen share content before proceeding. Has no effect while screen sharing is off.","example":true},"voice_modulation_enabled":{"type":["boolean","null"],"description":"Whether voice anonymization is enabled for participants","example":false},"default_captions_enabled":{"type":["boolean","null"],"description":"Whether captions are enabled by default during interviews","example":false},"session_timeout":{"anyOf":[{"type":"number","enum":[30]},{"type":"number","enum":[60]},{"type":"number","enum":[120]},{"type":"number","enum":[180]},{"type":"number","enum":[300]},{"type":"number","enum":[600]},{"type":"number","enum":[900]},{"type":"null"}],"description":"Session inactivity timeout in seconds. Valid values: 30, 60, 120, 180, 300, 600, 900.","example":300},"pronunciation_guide":{"type":["array","null"],"items":{"type":"object","properties":{"word":{"type":"string","description":"Word or term","example":"Strella"},"pronunciation":{"type":"string","description":"Phonetic pronunciation","example":"STREL-uh"}},"required":["word","pronunciation"]},"description":"Pronunciation dictionary for the moderator"},"allowed_languages":{"type":"array","items":{"type":"string"},"description":"Languages the moderator supports (ISO 639-1 codes). Empty array means all languages.","example":["en","es"]},"analysis_language":{"type":"string","description":"Language for AI-generated analysis output (summaries, themes)","example":"en-US"},"manually_closed":{"type":"boolean","description":"Whether the project is manually closed for new participants. Set via PATCH.","example":false},"reject_when_quota_full":{"type":"boolean","description":"Whether participants are rejected once every quota they match is full (UI label: \"Reject participants when a quota is full\"). Only takes effect once the project has at least one active quota; with no active quotas, participants are never rejected on this basis.","example":true},"require_quota_match":{"type":"boolean","description":"Whether participants who match no quota are rejected (UI label: \"Require participants to match a quota\"). Only takes effect once the project has at least one active quota; with no active quotas, participants are never rejected on this basis.","example":false},"custom_fields":{"type":"array","items":{"$ref":"#/components/schemas/CustomFieldDefinition"},"description":"Every custom field on the project's participants table, including Tags and AI-coded categories, in column order. To change them, send the whole list back as `custom_fields` on `PATCH /projects/{project_id}`."},"share_url":{"type":"string","description":"Participant-facing interview link. Absent until the first PUT /questionnaire/items call publishes the questionnaire. Share this URL with participants or embed in outreach flows.","example":"https://app.strella.io/onboard/abc123"},"score":{"type":["number","null"],"description":"Relevance score (see ProjectSearchResult.score)","example":3}},"required":["id","project_name","participant_facing_name","slug","organization_id","created_at","updated_at","participants_count","survey_responses_count","has_screener","moderation_mode","anonymize_participant_names","questionnaire_items_count","has_section_blocks","has_unpublished_changes","unqualified_count","qualified_count","invited_count","in_review_count","accepted_count","rejected_count","draft_orders_count","screener_required","research_objectives","product_description","target_audience","default_language","participant_target_count","auto_invite","close_date","email_required","name_required","tts_provider","moderator_voice","moderator_name","greet_participant_by_name","voice_speed","general_moderator_prompt","default_follow_up_depth","video_sharing_requirement","screen_sharing_requirement","device_restriction","push_to_talk","blur_participant_camera","blur_participant_screen_share","allow_disabling_screen_recording","screen_share_display_surface","require_screen_share_verification","voice_modulation_enabled","default_captions_enabled","session_timeout","pronunciation_guide","allowed_languages","analysis_language","manually_closed","reject_when_quota_full","require_quota_match","custom_fields","score"]}},"meta":{"type":"object","properties":{"mode":{"type":"string","enum":["keyword","semantic"],"description":"The search mode that produced these results. For \"auto\" requests, this shows which mode was actually used.","example":"keyword"},"pagination":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["mode","pagination"]}},"required":["results","meta"],"x-tags":["Projects"]},"RespondentOptions":{"type":"object","properties":{"age_groups":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"Age range buckets for B2C and B2B targeting. Use to populate the `age_groups` array on the recruitment-order body."},"company_sizes":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"Employer headcount buckets for B2B targeting. Use to populate the `company_sizes` array on B2B recruitment orders."},"countries":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"Countries where Respondent.io can recruit participants. Required on every recruitment order — pick the `code` of the country you are targeting."},"education_levels":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"Highest level of completed education, for both B2C and B2B targeting."},"ethnicities":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"Ethnicity buckets for B2C demographic targeting. Optional on the recruitment-order body."},"genders":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"Gender values for B2C demographic targeting. Optional on the recruitment-order body."},"household_incomes":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"Household income brackets for B2C demographic targeting (in the panel-provider currency, typically USD). Optional on the recruitment-order body."},"seniority_levels":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"Seniority levels for B2B targeting (e.g. individual contributor, manager, executive)."},"research_topics":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"B2C research topic buckets that participants self-identify as relevant to. Use to narrow B2C orders to participants familiar with a domain."},"job_functions":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"B2B job function buckets (e.g. engineering, marketing). Use alongside `industries` and `seniority_levels` to target B2B participants."},"industries":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Vendor-format identifier supplied by Respondent.io. Treat as opaque: do not parse, do not assume any structure. Pass the `code` back verbatim when constructing recruitment-order targeting criteria. The `id` field that Respondent.io returns for industry records is mapped to `code` here for shape consistency with the other option lists.","example":"US"},"label":{"type":"string","description":"Human-readable name of the option, suitable for displaying in a UI label. Localized by Respondent.io.","example":"United States"}},"required":["code","label"]},"description":"B2B industry buckets (e.g. technology, healthcare). Item shape matches the other option lists — `code` is the Respondent.io industry id, `label` is the human-readable name."}},"required":["age_groups","company_sizes","countries","education_levels","ethnicities","genders","household_incomes","seniority_levels","research_topics","job_functions","industries"],"additionalProperties":false,"description":"Available targeting values for Respondent.io recruitment, grouped by category. Use this response to populate filter dropdowns and to discover which `code` values are accepted when creating a Respondent.io recruitment order. All option lists share the same item shape: `{ code, label }`.","x-tags":["Recruitment"]},"JobTitleSearchResponse":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Respondent.io job-title identifier. Pass back verbatim in recruitment-order targeting criteria.","example":"12345"},"name":{"type":"string","description":"Human-readable job title.","example":"Product Manager"}},"required":["id","name"]},"description":"Up to 20 job-title matches for the search query, ordered by relevance. Use the `id` of the result you pick when constructing the `job_titles` targeting criterion on a B2B recruitment order."}},"required":["data"],"x-tags":["Recruitment"]},"UserInterviewsOptions":{"type":"object","properties":{"characteristics":{"type":"object","additionalProperties":{"type":"object","properties":{"label":{"type":"string","description":"Human-readable name of the characteristic, suitable for displaying in a UI label or filter chip. Localized to en-US today.","example":"Gender"},"category":{"type":"string","enum":["location","most_popular","demographic","professional","products_services","technical"],"description":"High-level grouping the characteristic belongs to. Use to organize filter pickers; not part of the filter query itself.","example":"most_popular"},"type":{"type":"string","enum":["single_select","multi_select","age_range","searchable","location"],"description":"How the characteristic is intended to be rendered as a control: `single_select`, `multi_select`, `age_range`, `searchable`, or `location`. Affects which `operators` apply.","example":"multi_select"},"operators":{"type":"array","items":{"type":"string","enum":["$eq","$in","$gte","$lte"]},"description":"MongoDB-style operators valid for this characteristic when constructing a filter (`$eq`, `$in`, `$gte`, `$lte`). Other operators will be rejected by the recruitment-order endpoints.","example":["$eq","$in"]},"searchable":{"type":"boolean","description":"When `true`, the characteristic supports a free-text search-as-you-type lookup endpoint for picking values (e.g. job titles, skills). When `false`, values come from a closed enumerated set.","example":false},"description":{"type":"string","description":"One-sentence description of the targeting use case, suitable for a tooltip or wizard hint.","example":"Target specific gender demographics"},"minSearchLength":{"type":"integer","description":"Minimum query length for the vendor lookup endpoint when `searchable` is `true`. Absent on non-searchable characteristics.","example":2},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Vendor-stable identifier for this option. Pass verbatim as the value when constructing targeting criteria.","example":"female"},"label":{"type":"string","description":"Human-readable label, suitable for display.","example":"Female"}},"required":["id","label"]},"description":"Closed-set option values for this characteristic, sorted alphabetically by `id`. Present only on closed-set characteristics that fit in the response payload — searchable, location, and age_range characteristics omit this field. When absent, use the `/recruitment/user-interviews/characteristics/{slug}/options` endpoint (or the `strella_search_recruitment_options` MCP tool) to look up values.","example":[{"id":"female","label":"Female"}]}},"required":["label","category","type","operators","searchable","description"]},"description":"Available targeting characteristics for User Interviews recruitment, keyed by the User Interviews vendor identifier. Use this map to discover what filters can be applied when creating a User Interviews recruitment order. Each entry describes one characteristic — its display label, the UI control type it pairs with, and which filter operators are valid against it. The key is a vendor-format identifier supplied by User Interviews, in the form `<vendor_field_type>--<name>` (for example `multi_select--children`, `integer--age`, `single_select_with_other--race_ethnicity`). Treat this string as opaque: do not parse the prefix, do not reconstruct it on your side, do not assume the prefix list is closed. Pass the slug back verbatim when constructing recruitment-order targeting criteria. The slug + operator format is MongoDB-flavored — User Interviews exposes their participant collection schema directly. For worked examples showing how to compose `targeting` arrays, see `src/features/public-api/docs/user-interviews-targeting.md`."},"costs":{"type":"object","properties":{"b2c_per_participant_usd":{"type":"number","description":"Recruitment fee per participant for B2C orders, denominated in USD and added to the per-participant incentive when calculating the total order cost.","example":6.25},"b2b_per_participant_usd":{"type":"number","description":"Recruitment fee per participant for B2B orders, denominated in USD and added to the per-participant incentive when calculating the total order cost.","example":12.5}},"required":["b2c_per_participant_usd","b2b_per_participant_usd"],"description":"Per-participant recruitment fees charged by User Interviews on top of the per-participant incentive. Use these to calculate the total cost of a recruitment order before submission."}},"required":["characteristics","costs"],"x-tags":["Recruitment"]},"UserInterviewsCharacteristicSearchResponse":{"type":"object","properties":{"slug":{"type":"string","enum":["multi_select--skill","single_select--occupation","single_select--country","single_select--region","single_select--locality"],"description":"Echoed slug from the path.","example":"multi_select--skill"},"query":{"type":"string","description":"Echoed query string from the request.","example":"figma"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Vendor-stable identifier for this option. Pass verbatim as the value when constructing targeting criteria.","example":"female"},"label":{"type":"string","description":"Human-readable label, suitable for display.","example":"Female"}},"required":["id","label"]},"description":"Up to `limit` matches in vendor relevance order. Use the option `id` as the targeting value."},"has_more":{"type":"boolean","description":"True when the vendor returned more results than `limit`. Refine the query to narrow.","example":false}},"required":["slug","query","options","has_more"],"x-tags":["Recruitment"]},"WorkspaceListQuery":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"cursor":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"created_after":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"created_before":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"updated_after":{"type":"string","format":"date-time","description":"Filter results updated after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"updated_before":{"type":"string","format":"date-time","description":"Filter results updated before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"sort":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"}},"x-tags":["Workspaces"]},"WorkspaceListItem":{"type":"object","properties":{"id":{"type":"string","description":"Unique workspace identifier","example":"cm7b2fm9n0002rk04ys7t6d3q"},"name":{"type":"string","description":"Workspace name","example":"Engineering"},"slug":{"type":"string","description":"URL-safe workspace slug","example":"engineering-a1b2c3"},"organization_id":{"type":"string","description":"Organization that owns this workspace","example":"org_2abc3def4ghi5jkl"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-06-01T12:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last modification timestamp","example":"2025-06-15T09:30:00.000Z"},"projects_count":{"type":"integer","minimum":0,"description":"Number of non-deleted projects in this workspace","example":5}},"required":["id","name","slug","organization_id","created_at","updated_at","projects_count"],"x-tags":["Workspaces"]},"WorkspaceListResponse":{"type":"object","properties":{"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceListItem"},"description":"Workspaces sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["workspaces","meta"],"x-tags":["Workspaces"]},"CreateWorkspaceBody":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100,"description":"Workspace display name","example":"Engineering"}},"required":["name"],"x-tags":["Workspaces"]},"UpdateWorkspaceBody":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100,"description":"New workspace name. Slug is NOT updated on rename.","example":"Design"}},"required":["name"],"x-tags":["Workspaces"]},"ProjectSettingsResponse":{"type":"object","properties":{"voices":{"type":"object","properties":{"openai":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Voice identifier (string ID or UUID depending on provider)","example":"sage"},"label":{"type":"string","description":"Human-readable voice name","example":"Sage"},"description":{"type":"string","description":"Voice characteristics (gender, age, tone)","example":"Neutral · Mature · Calm · Thoughtful"},"languages":{"type":"array","items":{"type":"string"},"description":"Language codes this voice supports. Empty array means multilingual (all languages).","example":["en"]},"gender":{"type":["string","null"],"enum":["masculine","feminine","neutral",null],"description":"Voice gender","example":"feminine"}},"required":["id","label","description","languages","gender"]},"description":"OpenAI gpt-4o-mini-tts voices"},"inworld":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Voice identifier (string ID or UUID depending on provider)","example":"sage"},"label":{"type":"string","description":"Human-readable voice name","example":"Sage"},"description":{"type":"string","description":"Voice characteristics (gender, age, tone)","example":"Neutral · Mature · Calm · Thoughtful"},"languages":{"type":"array","items":{"type":"string"},"description":"Language codes this voice supports. Empty array means multilingual (all languages).","example":["en"]},"gender":{"type":["string","null"],"enum":["masculine","feminine","neutral",null],"description":"Voice gender","example":"feminine"}},"required":["id","label","description","languages","gender"]},"description":"Inworld TTS voices"},"cartesia":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Voice identifier (string ID or UUID depending on provider)","example":"sage"},"label":{"type":"string","description":"Human-readable voice name","example":"Sage"},"description":{"type":"string","description":"Voice characteristics (gender, age, tone)","example":"Neutral · Mature · Calm · Thoughtful"},"languages":{"type":"array","items":{"type":"string"},"description":"Language codes this voice supports. Empty array means multilingual (all languages).","example":["en"]},"gender":{"type":["string","null"],"enum":["masculine","feminine","neutral",null],"description":"Voice gender","example":"feminine"}},"required":["id","label","description","languages","gender"]},"description":"Cartesia voices"}},"required":["openai","inworld","cartesia"],"description":"Available moderator voices grouped by TTS provider"},"speed_options":{"type":"object","properties":{"openai":{"type":"array","items":{"type":"number"},"description":"OpenAI speed options (0.5–2.0)"},"inworld":{"type":"array","items":{"type":"number"},"description":"Inworld speed options (0.5–1.5)"},"cartesia":{"type":"array","items":{"type":"number"},"description":"Cartesia speed options (0.5–2.0)"}},"required":["openai","inworld","cartesia"],"description":"Available voice speed options grouped by TTS provider"},"enums":{"type":"object","properties":{"video_sharing_requirement":{"type":"array","items":{"type":"string"},"description":"Valid video sharing requirement values"},"screen_sharing_requirement":{"type":"array","items":{"type":"string"},"description":"Valid screen sharing requirement values"},"device_restriction":{"type":"array","items":{"type":"string"},"description":"Valid device restriction values"},"screen_share_display_surface":{"type":"array","items":{"type":"string"},"description":"Valid screen share display surface values"}},"required":["video_sharing_requirement","screen_sharing_requirement","device_restriction","screen_share_display_surface"],"description":"Valid enum values for settings fields"},"defaults":{"type":"object","properties":{"tts_provider":{"type":"string","description":"Default TTS provider (resolved from Statsig config)","example":"openai"},"moderator_voice":{"type":"string","description":"Default moderator voice ID (resolved from Statsig config)","example":"coral"},"voice_gender":{"type":"string","enum":["masculine","feminine"],"description":"Default voice gender","example":"feminine"},"moderator_name":{"type":"string","description":"Default moderator display name","example":"Strella"},"greet_participant_by_name":{"type":"boolean","description":"Default for whether the moderator speaks the participant's name in its greeting"},"voice_speed":{"type":"number","description":"Default voice speed multiplier","example":1},"default_language":{"type":"string","description":"Default interview language code","example":"en"},"auto_invite":{"type":"boolean","description":"Whether participants are auto-invited after qualifying"},"email_required":{"type":"boolean","description":"Whether participant email is required"},"name_required":{"type":"boolean","description":"Whether participant name is required"},"video_sharing_requirement":{"type":"string","enum":["required_to_join","disabled"],"description":"Default camera recording: `required_to_join` (on) or `disabled` (off)","example":"required_to_join"},"screen_sharing_requirement":{"type":"string","enum":["enabled","disabled"],"description":"Default screen sharing requirement","example":"enabled"},"device_restriction":{"type":"string","enum":["desktop_only","mobile_only","no_restriction"],"description":"Default device restriction","example":"desktop_only"},"default_follow_up_depth":{"type":"integer","description":"Default follow-up question depth (0 = off, 1–3 = low to high)","example":0},"push_to_talk":{"type":"boolean","description":"Default push-to-talk setting"},"blur_participant_camera":{"type":"boolean","description":"Default camera blur setting"},"blur_participant_screen_share":{"type":"boolean","description":"Default screen share blur setting"},"allow_disabling_screen_recording":{"type":"boolean","description":"Default allow disabling screen recording setting"},"screen_share_display_surface":{"type":"string","enum":["window","monitor"],"description":"Default screen share display surface","example":"window"},"require_screen_share_verification":{"type":"boolean","description":"Default screen share verification setting"},"voice_modulation_enabled":{"type":"boolean","description":"Default voice modulation setting"},"default_captions_enabled":{"type":"boolean","description":"Default captions enabled setting"},"session_timeout":{"anyOf":[{"type":"number","enum":[30]},{"type":"number","enum":[60]},{"type":"number","enum":[120]},{"type":"number","enum":[180]},{"type":"number","enum":[300]},{"type":"number","enum":[600]},{"type":"number","enum":[900]}],"description":"Default session inactivity timeout in seconds","example":300},"anonymize_participant_names":{"type":"boolean","description":"Default participant name anonymization setting"}},"required":["tts_provider","moderator_voice","voice_gender","moderator_name","greet_participant_by_name","voice_speed","default_language","auto_invite","email_required","name_required","video_sharing_requirement","screen_sharing_requirement","device_restriction","default_follow_up_depth","push_to_talk","blur_participant_camera","blur_participant_screen_share","allow_disabling_screen_recording","screen_share_display_surface","require_screen_share_verification","voice_modulation_enabled","default_captions_enabled","session_timeout","anonymize_participant_names"],"description":"Default values for new projects (moderator voice resolved from platform config)"}},"required":["voices","speed_options","enums","defaults"],"x-tags":["Projects"]},"CustomFieldOptionInput":{"type":"object","properties":{"label":{"type":"string","minLength":1,"maxLength":100,"description":"The option text shown in the participants table","example":"Power user"},"value":{"type":"string","minLength":1,"maxLength":100,"description":"The stored value participants hold. Defaults to the label in lower case with spaces as underscores. Send an existing option's `value` to keep the participants who hold it when you rename its label.","example":"power_user"}},"required":["label"],"additionalProperties":false,"x-tags":["Projects"]},"CustomFieldListEntry":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"An existing field's id, to keep the field and its participants' values. Leave it out to create a field.","example":"custom_8f2c1a7e-4b1d-4c9a-9f0e-2d6b3a1c5e7f"},"name":{"type":"string","minLength":1,"maxLength":100,"description":"The column name. Must differ from every other field on the project, ignoring case.","example":"Account tier"},"type":{"type":"string","enum":["text","single_select","multi_select"],"description":"`text` holds free text, `single_select` one option, `multi_select` any number of options. The API does not convert participant values when the type changes later."},"options":{"type":["array","null"],"items":{"$ref":"#/components/schemas/CustomFieldOptionInput"},"maxItems":200,"description":"The full list of options, in display order. Only `single_select` and `multi_select` fields take options."},"is_match_key":{"type":"boolean","description":"Whether this text field is the project's participant identifier, which matches a returning participant to their existing row. Only one field holds the role: setting it moves it from any other field. The API refuses it while two active participants share a value."}},"required":["name","type"],"additionalProperties":false,"x-tags":["Projects"]},"CreateProjectBody":{"type":"object","properties":{"project_name":{"type":"string","minLength":1,"maxLength":100,"description":"Internal project name. Falls back to participant-facing name if not set separately.","example":"Q4 Onboarding Research"},"participant_facing_name":{"type":"string","maxLength":100,"description":"Name shown to participants. Defaults to project_name when omitted.","example":"Product Feedback Interview"},"workspace_id":{"type":"string","maxLength":200,"description":"Workspace to assign the project to, omitted for no workspace"},"description":{"type":"string","maxLength":300,"description":"Project description visible to researchers","example":"Exploring onboarding friction for new users"},"moderation_mode":{"type":"string","enum":["ai","human"],"description":"Who runs each interview. `ai` (default): the AI moderator interviews participants on their own schedule. `human`: a researcher runs each interview live on a video call the participant books. Human-moderated projects recruit through a share link or Respondent only (not User Interviews), have a screener but no question guide, never send a phone to the Strella app, and go live in Strella once availability is set on the Scheduling tab. Cannot be changed after creation.","example":"ai"},"research_objectives":{"type":"string","minLength":1,"maxLength":5000,"description":"Research objectives (required). Newline-separated items, 2-5 recommended.","example":"Understand what drives users to cancel\nExplore unmet expectations in onboarding"},"product_description":{"type":"string","maxLength":5000,"description":"Description of the product being researched","example":"A SaaS project management tool for small teams"},"target_audience":{"type":"string","maxLength":5000,"description":"Target audience for the research","example":"Product managers aged 25-45"},"project_context":{"type":"string","maxLength":10000,"description":"Secure, business-internal research context (motivation, hypotheses, prior findings, constraints). Never shown to participants. Informs the question guide, not live moderator behavior — use general_moderator_prompt to change how the moderator behaves.","example":"We are launching a redesigned checkout next month and need to validate the new flow before rollout"},"default_language":{"type":"string","maxLength":10,"description":"Default interview language (ISO 639-1 code)","example":"en"},"participant_target_count":{"type":"integer","minimum":1,"description":"Target number of participants","example":20},"auto_invite":{"type":"boolean","description":"Whether qualified participants are automatically invited","example":true},"close_date":{"type":"string","format":"date-time","description":"ISO 8601 date when the project closes for new participants","example":"2026-06-01T00:00:00.000Z"},"email_required":{"type":"boolean","description":"Whether participant email is required","example":true},"name_required":{"type":"boolean","description":"Whether participant name is required","example":true},"moderator_voice":{"type":"string","maxLength":100,"description":"Voice identifier for the AI moderator","example":"sage"},"moderator_name":{"type":"string","maxLength":100,"description":"Display name of the AI moderator shown to participants","example":"Strella"},"greet_participant_by_name":{"type":"boolean","description":"Whether the moderator speaks the participant's name in its opening greeting. Affects the spoken greeting only — set `anonymize_participant_names` instead to anonymize the name everywhere.","example":true},"voice_speed":{"anyOf":[{"type":"number","enum":[0.5]},{"type":"number","enum":[0.7]},{"type":"number","enum":[0.75]},{"type":"number","enum":[0.8]},{"type":"number","enum":[0.9]},{"type":"number","enum":[1]},{"type":"number","enum":[1.1]},{"type":"number","enum":[1.2]},{"type":"number","enum":[1.25]},{"type":"number","enum":[1.5]},{"type":"number","enum":[1.75]},{"type":"number","enum":[2]}],"description":"Voice speed multiplier. Valid values depend on the TTS provider; this schema accepts any speed valid for any provider. Call GET /projects/settings for per-provider options.","example":1},"general_moderator_prompt":{"type":"string","maxLength":2000,"description":"Custom system prompt for the moderator","example":"Be concise and focus on pain points"},"default_follow_up_depth":{"anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]}],"description":"Default follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High","example":2},"video_sharing_requirement":{"type":"string","enum":["required_to_join","disabled"],"description":"Camera recording for participants: `required_to_join` (on) or `disabled` (off)","example":"required_to_join"},"screen_sharing_requirement":{"type":"string","enum":["enabled","disabled"],"description":"Screen sharing requirement for participants","example":"enabled"},"device_restriction":{"type":"string","enum":["desktop_only","mobile_only","no_restriction","mobile_app"],"description":"Device restriction for interview sessions. `mobile_app` is a deprecated alias for `mobile_only` (stored as `mobile_only`); when screen sharing is required, mobile participants are routed to the Strella app at join time.","example":"desktop_only"},"screen_share_display_surface":{"type":"string","enum":["window","monitor"],"description":"Screen share capture mode: window (single window) or monitor (entire screen). Requires screen sharing enabled.","example":"window"},"push_to_talk":{"type":"boolean","description":"Enable push-to-talk mode. Requires device_restriction to be desktop_only.","example":false},"blur_participant_camera":{"type":"boolean","description":"Blur the participant camera feed. Requires webcam enabled (video_sharing_requirement not disabled) and device_restriction desktop_only.","example":false},"blur_participant_screen_share":{"type":"boolean","description":"Blur the participant screen share feed. Requires screen_sharing_requirement enabled.","example":false},"allow_disabling_screen_recording":{"type":"boolean","description":"Allow participants to disable screen recording. Requires screen_sharing_requirement enabled.","example":false},"require_screen_share_verification":{"type":"boolean","description":"Verify screen share content before proceeding. Has no effect while screen sharing is off.","example":true},"voice_modulation_enabled":{"type":"boolean","description":"Enable voice anonymization for participants","example":false},"default_captions_enabled":{"type":"boolean","description":"Enable captions by default during interviews","example":false},"session_timeout":{"anyOf":[{"type":"number","enum":[30]},{"type":"number","enum":[60]},{"type":"number","enum":[120]},{"type":"number","enum":[180]},{"type":"number","enum":[300]},{"type":"number","enum":[600]},{"type":"number","enum":[900]}],"description":"Session inactivity timeout in seconds. Valid values: 30, 60, 120, 180, 300, 600, 900.","example":300},"anonymize_participant_names":{"type":"boolean","description":"Anonymize participant names in the researcher view","example":false},"pronunciation_guide":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string","description":"Word or term","example":"Strella"},"pronunciation":{"type":"string","description":"Phonetic pronunciation","example":"STREL-uh"}},"required":["word","pronunciation"]},"description":"Pronunciation dictionary for the moderator"},"allowed_languages":{"type":"array","items":{"type":"string","maxLength":10},"description":"Languages the moderator supports (ISO 639-1 codes). Empty array means all languages. Constrains participant language selection during interviews.","example":["en","es","ja"]},"analysis_language":{"type":"string","maxLength":10,"description":"Language for AI-generated analysis output (summaries, themes). Defaults to \"en-US\".","example":"en-US"}},"required":["project_name","research_objectives"],"x-tags":["Projects"]},"UpdateProjectBody":{"type":"object","properties":{"project_name":{"type":"string","minLength":1,"maxLength":100,"description":"Internal project name. Changing the name does not update the project slug.","example":"Q4 Onboarding Research"},"participant_facing_name":{"type":["string","null"],"maxLength":100,"description":"Name shown to participants, null to reset to project_name","example":"Product Feedback Interview"},"workspace_id":{"type":["string","null"],"maxLength":200,"description":"Workspace ID, null to remove workspace assignment"},"description":{"type":["string","null"],"maxLength":300,"description":"Project description visible to researchers, null to clear","example":"Exploring onboarding friction for new users"},"research_objectives":{"type":["string","null"],"maxLength":5000,"description":"Specific research questions to investigate, null to clear","example":"What friction points exist in the first-run experience?"},"product_description":{"type":["string","null"],"maxLength":5000,"description":"Description of the product being researched, null to clear","example":"A SaaS project management tool for small teams"},"target_audience":{"type":["string","null"],"maxLength":5000,"description":"Target audience for the research, null to clear","example":"Product managers aged 25-45"},"project_context":{"type":["string","null"],"maxLength":10000,"description":"Secure, business-internal research context (motivation, hypotheses, prior findings, constraints). Never shown to participants. Informs the question guide, not live moderator behavior — use general_moderator_prompt to change how the moderator behaves. Null to clear.","example":"We are launching a redesigned checkout next month and need to validate the new flow before rollout"},"default_language":{"type":"string","maxLength":10,"description":"Default interview language (ISO 639-1 code). Cannot be cleared to null — omit to leave unchanged.","example":"en"},"participant_target_count":{"type":["integer","null"],"minimum":1,"description":"Target number of participants, null to clear","example":20},"auto_invite":{"type":"boolean","description":"Whether qualified participants are automatically invited","example":true},"close_date":{"type":["string","null"],"format":"date-time","description":"ISO 8601 date when the project closes for new participants, null to clear","example":"2026-06-01T00:00:00.000Z"},"email_required":{"type":"boolean","description":"Whether participant email is required","example":true},"name_required":{"type":"boolean","description":"Whether participant name is required","example":true},"moderator_voice":{"type":"string","maxLength":100,"description":"Voice identifier for the AI moderator","example":"sage"},"moderator_name":{"type":"string","maxLength":100,"description":"Display name of the AI moderator shown to participants","example":"Strella"},"greet_participant_by_name":{"type":"boolean","description":"Whether the moderator speaks the participant's name in its opening greeting. Affects the spoken greeting only — set `anonymize_participant_names` instead to anonymize the name everywhere.","example":true},"voice_speed":{"anyOf":[{"type":"number","enum":[0.5]},{"type":"number","enum":[0.7]},{"type":"number","enum":[0.75]},{"type":"number","enum":[0.8]},{"type":"number","enum":[0.9]},{"type":"number","enum":[1]},{"type":"number","enum":[1.1]},{"type":"number","enum":[1.2]},{"type":"number","enum":[1.25]},{"type":"number","enum":[1.5]},{"type":"number","enum":[1.75]},{"type":"number","enum":[2]}],"description":"Voice speed multiplier. Valid values depend on the TTS provider; this schema accepts any speed valid for any provider. Call GET /projects/settings for per-provider options.","example":1},"general_moderator_prompt":{"type":["string","null"],"maxLength":2000,"description":"Custom system prompt for the moderator, null to clear","example":"Be concise and focus on pain points"},"default_follow_up_depth":{"anyOf":[{"type":"number","enum":[0]},{"type":"number","enum":[1]},{"type":"number","enum":[2]},{"type":"number","enum":[3]}],"description":"Default follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High","example":2},"video_sharing_requirement":{"type":"string","enum":["required_to_join","disabled"],"description":"Camera recording for participants: `required_to_join` (on) or `disabled` (off)","example":"required_to_join"},"screen_sharing_requirement":{"type":"string","enum":["enabled","disabled"],"description":"Screen sharing requirement for participants","example":"enabled"},"device_restriction":{"type":"string","enum":["desktop_only","mobile_only","no_restriction","mobile_app"],"description":"Device restriction for interview sessions. `mobile_app` is a deprecated alias for `mobile_only` (stored as `mobile_only`); when screen sharing is required, mobile participants are routed to the Strella app at join time.","example":"desktop_only"},"screen_share_display_surface":{"type":"string","enum":["window","monitor"],"description":"Screen share capture mode: window (single window) or monitor (entire screen). Requires screen sharing enabled.","example":"window"},"push_to_talk":{"type":"boolean","description":"Enable push-to-talk mode. Requires device_restriction to be desktop_only.","example":false},"blur_participant_camera":{"type":"boolean","description":"Blur the participant camera feed. Requires webcam enabled (video_sharing_requirement not disabled) and device_restriction desktop_only.","example":false},"blur_participant_screen_share":{"type":"boolean","description":"Blur the participant screen share feed. Requires screen_sharing_requirement enabled.","example":false},"allow_disabling_screen_recording":{"type":"boolean","description":"Allow participants to disable screen recording. Requires screen_sharing_requirement enabled.","example":false},"require_screen_share_verification":{"type":"boolean","description":"Verify screen share content before proceeding. Has no effect while screen sharing is off.","example":true},"voice_modulation_enabled":{"type":"boolean","description":"Enable voice anonymization for participants","example":false},"default_captions_enabled":{"type":"boolean","description":"Enable captions by default during interviews","example":false},"session_timeout":{"anyOf":[{"type":"number","enum":[30]},{"type":"number","enum":[60]},{"type":"number","enum":[120]},{"type":"number","enum":[180]},{"type":"number","enum":[300]},{"type":"number","enum":[600]},{"type":"number","enum":[900]}],"description":"Session inactivity timeout in seconds. Valid values: 30, 60, 120, 180, 300, 600, 900.","example":300},"anonymize_participant_names":{"type":"boolean","description":"Anonymize participant names in the researcher view","example":false},"pronunciation_guide":{"type":["array","null"],"items":{"type":"object","properties":{"word":{"type":"string","description":"Word or term","example":"Strella"},"pronunciation":{"type":"string","description":"Phonetic pronunciation","example":"STREL-uh"}},"required":["word","pronunciation"]},"description":"Pronunciation dictionary for the moderator, null to clear"},"allowed_languages":{"type":"array","items":{"type":"string","maxLength":10},"description":"Languages the moderator supports (ISO 639-1 codes). Empty array means all languages. Constrains participant language selection during interviews.","example":["en","es","ja"]},"analysis_language":{"type":"string","maxLength":10,"description":"Language for AI-generated analysis output (summaries, themes). Defaults to \"en-US\".","example":"en-US"},"manually_closed":{"type":"boolean","description":"Controls whether the project accepts new participants. Set to `true` to close, `false` to reopen. Both directions are supported.","example":true},"reject_when_quota_full":{"type":"boolean","description":"Whether participants are rejected once every quota they match is full (UI label: \"Reject participants when a quota is full\"). Only takes effect once the project has at least one active quota.","example":true},"require_quota_match":{"type":"boolean","description":"Whether participants who match no quota are rejected (UI label: \"Require participants to match a quota\"). Only takes effect once the project has at least one active quota.","example":false},"custom_fields":{"type":"array","items":{"$ref":"#/components/schemas/CustomFieldListEntry"},"maxItems":500,"description":"The project's whole custom field list, in column order, as `custom_fields` on a project read returns it. An entry with an `id` changes that field, and an entry without one creates a field. The API deletes a field left out of the list, along with every participant's value in it, and `[]` deletes every field. Options left out or `null` keep a field's stored options. `is_match_key` left out keeps its role."}},"description":"RFC 7396 merge-patch semantics: omitted fields are unchanged, null clears nullable fields. Uses application/json content type (not application/merge-patch+json) for client simplicity.","x-tags":["Projects"]},"PatchRecruitmentOrderBody":{"anyOf":[{"$ref":"#/components/schemas/RecruitmentOrderPatchBody"},{"$ref":"#/components/schemas/PatchRespondentOrder"},{"$ref":"#/components/schemas/PatchUserInterviewsOrder"}],"description":"PATCH body for a recruitment order. The accepted shape depends on intent:\n\n- **Lifecycle transition** — `{ \"status\": \"recruiting\" | \"paused\" | \"closed\" }`. Drives submit, pause, unpause, close based on the order's current state.\n- **Metadata edit (pending orders only)** — panel-shaped body. Respondent uses `{ participant_count?, incentive_per_participant?, targeting? }`; User Interviews uses `{ target_market?, participant_count?, incentive_per_participant?, targeting?, title?, description? }`. All fields optional; supply only what should change. Send at least one field.\n\nMixing `status` with metadata fields returns 422.","x-tags":["Recruitment"]}},"parameters":{}},"paths":{"/api/public/v1/credits/balance":{"get":{"operationId":"getCreditsBalance","tags":["Credits"],"summary":"Get credit balance","description":"Returns the current credit balance for the authenticated organization. Response may be up to 30 seconds stale due to caching.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Current credit balance","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreditBalance"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/credits/transactions":{"get":{"operationId":"listCreditTransactions","tags":["Credits"],"summary":"List credit transactions","description":"Paginated list of credit transactions for the authenticated organization, sorted by creation date (newest first). Response may be up to 60 seconds stale due to caching.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"required":false,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"required":false,"description":"Opaque cursor for the next page","name":"cursor","in":"query"},{"schema":{"type":"string","enum":["credit_purchase","credit_refund","audience_purchase","incentive_payment","manual_adjustment","expired"],"description":"Filter by transaction type","example":"credit_purchase"},"required":false,"description":"Filter by transaction type","name":"type","in":"query"},{"schema":{"type":"string","enum":["pending","completed","failed","reversed"],"description":"Filter by transaction status","example":"completed"},"required":false,"description":"Filter by transaction status","name":"status","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results created after this ISO 8601 datetime","name":"created_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results created before this ISO 8601 datetime","name":"created_before","in":"query"}],"responses":{"200":{"description":"Paginated credit transaction list","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"transactions":{"type":"array","items":{"$ref":"#/components/schemas/CreditTransactionItem"},"description":"Credit transactions sorted by creation date (newest first)"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["transactions","meta"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/participants":{"get":{"operationId":"listParticipants","tags":["Participants"],"summary":"List participants","description":"Paginated list of participants for a project. PII fields require participants:read:pii scope. Pass `detail=full` to include profile, summary, quota, and truncated sub-resource arrays.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"required":false,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"required":false,"description":"Opaque cursor for the next page","name":"cursor","in":"query"},{"schema":{"type":"string","description":"Filter by participant status (comma-separated). Valid values: unqualified (started the screener but has not submitted it), qualified (passed the screener, awaiting invite), invited, in_review, rejected, accepted","example":"accepted,in_review"},"required":false,"description":"Filter by participant status (comma-separated). Valid values: unqualified (started the screener but has not submitted it), qualified (passed the screener, awaiting invite), invited, in_review, rejected, accepted","name":"status","in":"query"},{"schema":{"type":"string","description":"Filter by participant IDs (comma-separated).","example":"abc123,def456"},"required":false,"description":"Filter by participant IDs (comma-separated).","name":"participant_ids","in":"query"},{"schema":{"type":"string","description":"Case-insensitive contains match on participant name, as far as the caller may see names: anywhere in the name with full name access, the first name only with first-name-only access. Without name access the request returns `403` with `code: name_access_required`.","example":"jane"},"required":false,"description":"Case-insensitive contains match on participant name, as far as the caller may see names: anywhere in the name with full name access, the first name only with first-name-only access. Without name access the request returns `403` with `code: name_access_required`.","name":"name","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Minimum age (inclusive). Participants with unknown age (0) are excluded. Requires access to participant age: without it, the request returns `403` with `code: age_access_required`.","example":25},"required":false,"description":"Minimum age (inclusive). Participants with unknown age (0) are excluded. Requires access to participant age: without it, the request returns `403` with `code: age_access_required`.","name":"age_min","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Maximum age (inclusive). Participants with unknown age (0) are excluded. Requires access to participant age: without it, the request returns `403` with `code: age_access_required`.","example":34},"required":false,"description":"Maximum age (inclusive). Participants with unknown age (0) are excluded. Requires access to participant age: without it, the request returns `403` with `code: age_access_required`.","name":"age_max","in":"query"},{"schema":{"type":"string","enum":["upcoming"],"description":"Filter by booking on a human-moderated project. `upcoming`: participants holding a booked slot that has not started yet. Matches no one on an AI-moderated project.","example":"upcoming"},"required":false,"description":"Filter by booking on a human-moderated project. `upcoming`: participants holding a booked slot that has not started yet. Matches no one on an AI-moderated project.","name":"booking","in":"query"},{"schema":{"type":"string","enum":["full"],"description":"When set to \"full\", each list item includes all fields from the single-resource endpoint (e.g., description, screener_required for projects; embedded sessions for interviews; profile for participants). Omit for lighter responses when only summary fields are needed. Pagination behavior (limit, cursor, has_more) is unchanged.","example":"full"},"required":false,"description":"When set to \"full\", each list item includes all fields from the single-resource endpoint (e.g., description, screener_required for projects; embedded sessions for interviews; profile for participants). Omit for lighter responses when only summary fields are needed. Pagination behavior (limit, cursor, has_more) is unchanged.","name":"detail","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results created after this ISO 8601 datetime","name":"created_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results created before this ISO 8601 datetime","name":"created_before","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results updated after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results updated after this ISO 8601 datetime","name":"updated_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results updated before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results updated before this ISO 8601 datetime","name":"updated_before","in":"query"},{"schema":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"},"required":false,"description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","name":"sort","in":"query"}],"responses":{"200":{"description":"Paginated participant list. Items are `ParticipantListItem` by default, or `ParticipantDetailListItem` when `detail=full`.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"participants":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantListItem"},"description":"Participants sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]},"anonymize_participant_names":{"type":"boolean","description":"Whether participant anonymization is enabled for this project. When true, Strella never returns participant `email` anywhere — API, MCP, or exports — regardless of the caller's `participants:read:email` grant, and `name` values are anonymized aliases. Toggled via the project's Privacy settings or `PATCH /projects` (`anonymize_participant_names`).","example":false}},"required":["participants","meta","anonymize_participant_names"]},{"type":"object","properties":{"participants":{"type":"array","items":{"$ref":"#/components/schemas/ParticipantDetailListItem"},"description":"Participants sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]},"anonymize_participant_names":{"type":"boolean","description":"Whether participant anonymization is enabled for this project. When true, Strella never returns participant `email` anywhere — API, MCP, or exports — regardless of the caller's `participants:read:email` grant, and `name` values are anonymized aliases. Toggled via the project's Privacy settings or `PATCH /projects` (`anonymize_participant_names`).","example":false}},"required":["participants","meta","anonymize_participant_names"]}]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/participants/{participant_id}":{"get":{"operationId":"getParticipantDetail","tags":["Participants"],"summary":"Get participant detail","description":"Retrieve the full participant aggregate root with recordings, screener responses, survey responses, summary, profile, and quota. Use `?exclude=` to opt out of sections. PII fields require participants:read:pii scope.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Participant identifier"},"required":true,"description":"Participant identifier","name":"participant_id","in":"path"},{"schema":{"type":"string","description":"Comma-separated list of sections to exclude from the response. Valid values: recordings, screener_responses, survey_responses, summary, profile, booked_interviews, custom_fields","example":"screener_responses,survey_responses"},"required":false,"description":"Comma-separated list of sections to exclude from the response. Valid values: recordings, screener_responses, survey_responses, summary, profile, booked_interviews, custom_fields","name":"exclude","in":"query"}],"responses":{"200":{"description":"Participant aggregate root with all embedded sections (unless excluded)","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ParticipantDetail"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Participant not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"patchParticipant","tags":["Participants"],"summary":"Update a participant","description":"Change a participant's status, rename them, set whether they are excluded from analysis, or replace the quotas they belong to. Send any combination; omitted fields are unchanged.\n\n`status: accepted` completes quality review and triggers incentive payment if configured. `status: rejected` removes the participant from the project and records a rejection reason.\n\n`name` needs full name access and is refused on a project that anonymizes participant names. `excluded_from_analysis` leaves the participant out of the project's analysis, or includes them again.\n\n`quota_ids` is the whole list of quotas the participant belongs to: read `quota_ids` from the participant, change it, and send it back. A quota left out of the list loses the participant, and a finished participant carries their completion into each quota they are added to.\n\nReturns the updated participant aggregate root with all embedded sections.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Participant identifier"},"required":true,"description":"Participant identifier","name":"participant_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ParticipantUpdateBody"}}}},"responses":{"200":{"description":"Participant updated. Returns the full participant detail.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ParticipantDetail"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"402":{"description":"Insufficient credits.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden. A rename needs full name access (`code: full_name_access_required`).","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project or participant not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/search":{"get":{"operationId":"searchProjects","tags":["Projects"],"summary":"Search projects","description":"Search across project fields with keyword matching or LLM-powered semantic ranking.\n\n**Choosing a search mode:**\n\n| Mode | Best for | Latency | How it works |\n|------|----------|---------|--------------|\n| `keyword` (default) | Name lookups, multi-word queries, filtering by known terms | ~130ms P50 | Tokenizes query, matches against project fields using substring matching. Scores by field-weight and token coverage (DNF: OR with AND boost). Precise — returns only projects containing your search terms. |\n| `semantic` | Ambiguous queries, typos, conceptual search, \"find projects about...\" | ~1.4s P50 | Sends query + project list to an LLM for natural language ranking. Understands synonyms, typos, and intent. May return false positives for nonsense queries. |\n| `auto` | When you're unsure | Varies | Tries keyword first. Falls back to semantic if keyword returns zero results. Best of both — fast for clear queries, smart for ambiguous ones. |\n\n**When to use keyword:** You know what you're looking for — a project name, a topic word, a workspace name. Multi-token queries like \"mobile onboarding Q1\" find projects matching any of those terms, ranked by how many terms match. Typos will miss (\"onbording\" won't match \"onboarding\").\n\n**When to use semantic:** You're exploring — \"the study about user feelings during checkout\" or you have a typo you can't fix. The LLM understands meaning, not just text. Slower and may return unexpected results for very specific queries.\n\n**Response shape:** Default returns a lean response (id, name, description, score). Pass `detail=full` to include all list-level fields (slug, counts) at the cost of ~400ms additional latency.\n\n**Field scoping:** Use `fields=project_name,description` to restrict which fields are searched. Useful when you know the term appears in a specific field. Omit to search all fields.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":500,"description":"Search query. Tokenized into keywords for keyword mode, passed as natural language for semantic mode.","example":"usability testing for onboarding"},"required":true,"description":"Search query. Tokenized into keywords for keyword mode, passed as natural language for semantic mode.","name":"q","in":"query"},{"schema":{"type":"string","enum":["keyword","semantic","auto"],"default":"keyword","description":"Search strategy. \"keyword\" uses fast per-field text matching. \"semantic\" uses LLM-powered natural language ranking. \"auto\" tries keyword first, falls back to semantic on zero results.","example":"keyword"},"required":false,"description":"Search strategy. \"keyword\" uses fast per-field text matching. \"semantic\" uses LLM-powered natural language ranking. \"auto\" tries keyword first, falls back to semantic on zero results.","name":"mode","in":"query"},{"schema":{"type":"string","description":"Comma-separated list of fields to search. Valid values: project_name, participant_facing_name, description, workspace_name, research_objectives. Omit to search all fields.","example":"project_name,description"},"required":false,"description":"Comma-separated list of fields to search. Valid values: project_name, participant_facing_name, description, workspace_name, research_objectives. Omit to search all fields.","name":"fields","in":"query"},{"schema":{"type":"string","enum":["full"],"description":"When set to \"full\", each list item includes all fields from the single-resource endpoint (e.g., description, screener_required for projects; embedded sessions for interviews; profile for participants). Omit for lighter responses when only summary fields are needed. Pagination behavior (limit, cursor, has_more) is unchanged.","example":"full"},"required":false,"description":"When set to \"full\", each list item includes all fields from the single-resource endpoint (e.g., description, screener_required for projects; embedded sessions for interviews; profile for participants). Omit for lighter responses when only summary fields are needed. Pagination behavior (limit, cursor, has_more) is unchanged.","name":"detail","in":"query"},{"schema":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":25,"description":"Max results to return (1–100, default 25)","example":10},"required":false,"description":"Max results to return (1–100, default 25)","name":"limit","in":"query"},{"schema":{"type":"string","description":"Narrow search to projects in this workspace"},"required":false,"description":"Narrow search to projects in this workspace","name":"workspace_id","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results created after this ISO 8601 datetime","name":"created_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results created before this ISO 8601 datetime","name":"created_before","in":"query"}],"responses":{"200":{"description":"Search results ranked by relevance. Shape depends on `detail` param: lean (default) or enriched (`detail=full`). `meta.mode` indicates which search strategy produced the results.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique project identifier","example":"cm7a9qk2x0001jv04hn3w8m5p"},"project_name":{"type":"string","description":"Internal project name (falls back to participant-facing name)","example":"Q4 Concept Test"},"participant_facing_name":{"type":"string","description":"Name shown to participants","example":"Product Interview"},"description":{"type":["string","null"],"description":"Study description","example":"Testing the new onboarding flow"},"created_at":{"type":"string","description":"ISO 8601 creation timestamp","example":"2026-01-15T00:00:00.000Z"},"score":{"type":["number","null"],"description":"Relevance score. For keyword mode: field-weight × token-coverage. For semantic mode: null (order is the ranking). Check meta.mode to interpret.","example":3}},"required":["id","project_name","participant_facing_name","created_at","score"]}},"meta":{"type":"object","properties":{"mode":{"type":"string","enum":["keyword","semantic"],"description":"The search mode that produced these results. For \"auto\" requests, this shows which mode was actually used.","example":"keyword"},"pagination":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["mode","pagination"]}},"required":["results","meta"]},{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique project identifier","example":"cm7a9qk2x0001jv04hn3w8m5p"},"project_name":{"type":"string","description":"Internal project name (falls back to participant-facing name)","example":"Q4 Onboarding Research"},"participant_facing_name":{"type":"string","description":"Name shown to participants","example":"Product Experience Interview"},"slug":{"type":"string","description":"URL-safe project slug","example":"vk7d0kv4VXPkbNjP8sTj8QrYF"},"organization_id":{"type":"string","description":"Organization that owns this project"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-10-15T14:30:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last-modified timestamp. Use ?sort=updated_at:desc for incremental sync — compare against your stored checkpoint and stop when results are older than your last run.","example":"2025-11-20T09:15:00.000Z"},"participants_count":{"type":"integer","minimum":0,"description":"Number of unique participants with completed recording and analysis","example":15},"survey_responses_count":{"type":"integer","minimum":0,"description":"Number of structured survey responses collected during sessions","example":42},"has_screener":{"type":"boolean","description":"Whether the project has published screener questions","example":true},"moderation_mode":{"type":"string","enum":["ai","human"],"description":"Who runs each interview: `ai` (the AI moderator) or `human` (a researcher on a scheduled video call). Set at creation and cannot be changed.","example":"ai"},"anonymize_participant_names":{"type":["boolean","null"],"description":"Whether participant names are anonymized in the researcher view","example":false},"questionnaire_items_count":{"type":"integer","minimum":0,"description":"Number of items in the active questionnaire","example":8},"has_section_blocks":{"type":"boolean","description":"Whether the questionnaire contains section blocks","example":true},"has_unpublished_changes":{"type":"boolean","description":"Whether the questionnaire has unpublished draft changes (draftItemsV2 differs from itemsV2)","example":false},"unqualified_count":{"type":"integer","minimum":0,"description":"Participants in screener funnel (started or incomplete)","example":10},"qualified_count":{"type":"integer","minimum":0,"description":"Participants who passed screener and are waiting to be invited","example":4},"invited_count":{"type":"integer","minimum":0,"description":"Participants approved to join interview","example":3},"in_review_count":{"type":"integer","minimum":0,"description":"Participants who completed an interview and are awaiting researcher quality review","example":2},"accepted_count":{"type":"integer","minimum":0,"description":"Participants accepted by researcher (paid and finalized)","example":5},"rejected_count":{"type":"integer","minimum":0,"description":"Participants who failed screener, quality review, or did not attend","example":1},"draft_orders_count":{"type":"integer","minimum":0,"description":"Number of draft (unpublished) recruitment orders","example":0},"workspace_id":{"type":"string","description":"Workspace ID, omitted when project has no workspace"},"description":{"type":"string","description":"Project description. Omitted when not set (use PATCH with null to clear, or omit to leave unchanged).","example":"Exploring onboarding friction for new users"},"screener_required":{"type":"boolean","description":"Whether a screener is required before participation","example":false},"questionnaire_id":{"type":"string","description":"ID of the active questionnaire, omitted when no questionnaire exists","example":"cm7e8kp3s0005jv04qr9w4f6n"},"research_objectives":{"type":["string","null"],"description":"Specific research questions to investigate","example":"What friction points exist in the first-run experience?"},"product_description":{"type":["string","null"],"description":"Description of the product being researched","example":"A SaaS project management tool for small teams"},"target_audience":{"type":["string","null"],"description":"Target audience for the research","example":"Product managers aged 25-45"},"project_context":{"type":["string","null"],"description":"Secure, business-internal research context. Never shown to participants. Only included when `?project_context=true` is passed.","example":"We are launching a redesigned checkout next month and need to validate the new flow before rollout"},"default_language":{"type":["string","null"],"description":"Default interview language (ISO 639-1 code)","example":"en"},"participant_target_count":{"type":["integer","null"],"description":"Target number of participants","example":20},"auto_invite":{"type":["boolean","null"],"description":"Whether qualified participants are automatically invited to interview. API-created recruitment orders always have per-order auto-invite disabled regardless of this field — this setting applies to self-recruited participants only.","example":true},"close_date":{"type":["string","null"],"format":"date-time","description":"ISO 8601 date when the project closes for new participants","example":"2026-06-01T00:00:00.000Z"},"email_required":{"type":["boolean","null"],"description":"Whether participant email is required","example":true},"name_required":{"type":["boolean","null"],"description":"Whether participant name is required","example":true},"tts_provider":{"type":["string","null"],"description":"TTS provider for the AI moderator. Determines which voices are available. Call GET /projects/settings for the full catalog.","example":"openai"},"moderator_voice":{"type":["string","null"],"description":"Voice identifier for the AI moderator","example":"sage"},"moderator_name":{"type":["string","null"],"description":"Display name of the AI moderator shown to participants","example":"Strella"},"greet_participant_by_name":{"type":["boolean","null"],"description":"Whether the moderator speaks the participant's name in its opening greeting (\"Hi Abe, I'm Strella\" vs \"Hi there, I'm Strella\"). Affects the spoken greeting only — distinct from `anonymize_participant_names`, which anonymizes the name everywhere. With this off the real name is still stored and still keys results and transcripts. `null` when the project has no questionnaire yet.","example":true},"voice_speed":{"type":["number","null"],"description":"Voice speed multiplier (0.5 to 2.0)","example":1},"general_moderator_prompt":{"type":["string","null"],"description":"Custom system prompt for the moderator","example":"Be concise and focus on pain points"},"default_follow_up_depth":{"type":["integer","null"],"description":"Default follow-up depth: 0=Off, 1=Low, 2=Medium, 3=High","example":2},"video_sharing_requirement":{"type":["string","null"],"enum":["required_to_join","disabled",null],"description":"Camera recording for participants: `required_to_join` (on) or `disabled` (off)","example":"required_to_join"},"screen_sharing_requirement":{"type":["string","null"],"enum":["enabled","disabled",null],"description":"Screen sharing requirement for participants","example":"enabled"},"device_restriction":{"type":["string","null"],"enum":["desktop_only","mobile_only","no_restriction",null],"description":"Device restriction for interview sessions","example":"desktop_only"},"push_to_talk":{"type":["boolean","null"],"description":"Whether push-to-talk is enabled. Requires desktop_only.","example":false},"blur_participant_camera":{"type":["boolean","null"],"description":"Whether to blur the participant camera feed. Requires webcam enabled and desktop_only.","example":false},"blur_participant_screen_share":{"type":["boolean","null"],"description":"Whether to blur the participant screen share feed. Requires screen sharing enabled.","example":false},"allow_disabling_screen_recording":{"type":["boolean","null"],"description":"Whether participants can disable screen recording. Requires screen sharing enabled.","example":false},"screen_share_display_surface":{"type":["string","null"],"enum":["window","monitor",null],"description":"Screen share capture mode: window (single window) or monitor (entire screen). Requires screen sharing enabled.","example":"window"},"require_screen_share_verification":{"type":["boolean","null"],"description":"Whether to verify screen share content before proceeding. Has no effect while screen sharing is off.","example":true},"voice_modulation_enabled":{"type":["boolean","null"],"description":"Whether voice anonymization is enabled for participants","example":false},"default_captions_enabled":{"type":["boolean","null"],"description":"Whether captions are enabled by default during interviews","example":false},"session_timeout":{"anyOf":[{"type":"number","enum":[30]},{"type":"number","enum":[60]},{"type":"number","enum":[120]},{"type":"number","enum":[180]},{"type":"number","enum":[300]},{"type":"number","enum":[600]},{"type":"number","enum":[900]},{"type":"null"}],"description":"Session inactivity timeout in seconds. Valid values: 30, 60, 120, 180, 300, 600, 900.","example":300},"pronunciation_guide":{"type":["array","null"],"items":{"type":"object","properties":{"word":{"type":"string","description":"Word or term","example":"Strella"},"pronunciation":{"type":"string","description":"Phonetic pronunciation","example":"STREL-uh"}},"required":["word","pronunciation"]},"description":"Pronunciation dictionary for the moderator"},"allowed_languages":{"type":"array","items":{"type":"string"},"description":"Languages the moderator supports (ISO 639-1 codes). Empty array means all languages.","example":["en","es"]},"analysis_language":{"type":"string","description":"Language for AI-generated analysis output (summaries, themes)","example":"en-US"},"manually_closed":{"type":"boolean","description":"Whether the project is manually closed for new participants. Set via PATCH.","example":false},"reject_when_quota_full":{"type":"boolean","description":"Whether participants are rejected once every quota they match is full (UI label: \"Reject participants when a quota is full\"). Only takes effect once the project has at least one active quota; with no active quotas, participants are never rejected on this basis.","example":true},"require_quota_match":{"type":"boolean","description":"Whether participants who match no quota are rejected (UI label: \"Require participants to match a quota\"). Only takes effect once the project has at least one active quota; with no active quotas, participants are never rejected on this basis.","example":false},"custom_fields":{"type":"array","items":{"$ref":"#/components/schemas/CustomFieldDefinition"},"description":"Every custom field on the project's participants table, including Tags and AI-coded categories, in column order. To change them, send the whole list back as `custom_fields` on `PATCH /projects/{project_id}`."},"share_url":{"type":"string","description":"Participant-facing interview link. Absent until the first PUT /questionnaire/items call publishes the questionnaire. Share this URL with participants or embed in outreach flows.","example":"https://app.strella.io/onboard/abc123"},"score":{"type":["number","null"],"description":"Relevance score (see ProjectSearchResult.score)","example":3}},"required":["id","project_name","participant_facing_name","slug","organization_id","created_at","updated_at","participants_count","survey_responses_count","has_screener","moderation_mode","anonymize_participant_names","questionnaire_items_count","has_section_blocks","has_unpublished_changes","unqualified_count","qualified_count","invited_count","in_review_count","accepted_count","rejected_count","draft_orders_count","screener_required","research_objectives","product_description","target_audience","default_language","participant_target_count","auto_invite","close_date","email_required","name_required","tts_provider","moderator_voice","moderator_name","greet_participant_by_name","voice_speed","general_moderator_prompt","default_follow_up_depth","video_sharing_requirement","screen_sharing_requirement","device_restriction","push_to_talk","blur_participant_camera","blur_participant_screen_share","allow_disabling_screen_recording","screen_share_display_surface","require_screen_share_verification","voice_modulation_enabled","default_captions_enabled","session_timeout","pronunciation_guide","allowed_languages","analysis_language","manually_closed","reject_when_quota_full","require_quota_match","custom_fields","score"]}},"meta":{"type":"object","properties":{"mode":{"type":"string","enum":["keyword","semantic"],"description":"The search mode that produced these results. For \"auto\" requests, this shows which mode was actually used.","example":"keyword"},"pagination":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["mode","pagination"]}},"required":["results","meta"]}]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"503":{"description":"Service unavailable","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/settings":{"get":{"operationId":"getProjectSettings","tags":["Projects"],"summary":"Get project settings options","description":"Returns valid configuration values for `POST /projects` and `PATCH /projects/{project_id}` fields. Call this endpoint first to populate dropdowns or validate input before creating or updating a project.\n\nIncludes: available moderator voices grouped by TTS provider, voice speed options, enum values for `video_sharing_requirement` / `screen_sharing_requirement` / `device_restriction`, and platform defaults applied when fields are omitted during project creation.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Project settings options","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"voices":{"type":"object","properties":{"openai":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Voice identifier (string ID or UUID depending on provider)","example":"sage"},"label":{"type":"string","description":"Human-readable voice name","example":"Sage"},"description":{"type":"string","description":"Voice characteristics (gender, age, tone)","example":"Neutral · Mature · Calm · Thoughtful"},"languages":{"type":"array","items":{"type":"string"},"description":"Language codes this voice supports. Empty array means multilingual (all languages).","example":["en"]},"gender":{"type":["string","null"],"enum":["masculine","feminine","neutral",null],"description":"Voice gender","example":"feminine"}},"required":["id","label","description","languages","gender"]},"description":"OpenAI gpt-4o-mini-tts voices"},"inworld":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Voice identifier (string ID or UUID depending on provider)","example":"sage"},"label":{"type":"string","description":"Human-readable voice name","example":"Sage"},"description":{"type":"string","description":"Voice characteristics (gender, age, tone)","example":"Neutral · Mature · Calm · Thoughtful"},"languages":{"type":"array","items":{"type":"string"},"description":"Language codes this voice supports. Empty array means multilingual (all languages).","example":["en"]},"gender":{"type":["string","null"],"enum":["masculine","feminine","neutral",null],"description":"Voice gender","example":"feminine"}},"required":["id","label","description","languages","gender"]},"description":"Inworld TTS voices"},"cartesia":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Voice identifier (string ID or UUID depending on provider)","example":"sage"},"label":{"type":"string","description":"Human-readable voice name","example":"Sage"},"description":{"type":"string","description":"Voice characteristics (gender, age, tone)","example":"Neutral · Mature · Calm · Thoughtful"},"languages":{"type":"array","items":{"type":"string"},"description":"Language codes this voice supports. Empty array means multilingual (all languages).","example":["en"]},"gender":{"type":["string","null"],"enum":["masculine","feminine","neutral",null],"description":"Voice gender","example":"feminine"}},"required":["id","label","description","languages","gender"]},"description":"Cartesia voices"}},"required":["openai","inworld","cartesia"],"description":"Available moderator voices grouped by TTS provider"},"speed_options":{"type":"object","properties":{"openai":{"type":"array","items":{"type":"number"},"description":"OpenAI speed options (0.5–2.0)"},"inworld":{"type":"array","items":{"type":"number"},"description":"Inworld speed options (0.5–1.5)"},"cartesia":{"type":"array","items":{"type":"number"},"description":"Cartesia speed options (0.5–2.0)"}},"required":["openai","inworld","cartesia"],"description":"Available voice speed options grouped by TTS provider"},"enums":{"type":"object","properties":{"video_sharing_requirement":{"type":"array","items":{"type":"string"},"description":"Valid video sharing requirement values"},"screen_sharing_requirement":{"type":"array","items":{"type":"string"},"description":"Valid screen sharing requirement values"},"device_restriction":{"type":"array","items":{"type":"string"},"description":"Valid device restriction values"},"screen_share_display_surface":{"type":"array","items":{"type":"string"},"description":"Valid screen share display surface values"}},"required":["video_sharing_requirement","screen_sharing_requirement","device_restriction","screen_share_display_surface"],"description":"Valid enum values for settings fields"},"defaults":{"type":"object","properties":{"tts_provider":{"type":"string","description":"Default TTS provider (resolved from Statsig config)","example":"openai"},"moderator_voice":{"type":"string","description":"Default moderator voice ID (resolved from Statsig config)","example":"coral"},"voice_gender":{"type":"string","enum":["masculine","feminine"],"description":"Default voice gender","example":"feminine"},"moderator_name":{"type":"string","description":"Default moderator display name","example":"Strella"},"greet_participant_by_name":{"type":"boolean","description":"Default for whether the moderator speaks the participant's name in its greeting"},"voice_speed":{"type":"number","description":"Default voice speed multiplier","example":1},"default_language":{"type":"string","description":"Default interview language code","example":"en"},"auto_invite":{"type":"boolean","description":"Whether participants are auto-invited after qualifying"},"email_required":{"type":"boolean","description":"Whether participant email is required"},"name_required":{"type":"boolean","description":"Whether participant name is required"},"video_sharing_requirement":{"type":"string","enum":["required_to_join","disabled"],"description":"Default camera recording: `required_to_join` (on) or `disabled` (off)","example":"required_to_join"},"screen_sharing_requirement":{"type":"string","enum":["enabled","disabled"],"description":"Default screen sharing requirement","example":"enabled"},"device_restriction":{"type":"string","enum":["desktop_only","mobile_only","no_restriction"],"description":"Default device restriction","example":"desktop_only"},"default_follow_up_depth":{"type":"integer","description":"Default follow-up question depth (0 = off, 1–3 = low to high)","example":0},"push_to_talk":{"type":"boolean","description":"Default push-to-talk setting"},"blur_participant_camera":{"type":"boolean","description":"Default camera blur setting"},"blur_participant_screen_share":{"type":"boolean","description":"Default screen share blur setting"},"allow_disabling_screen_recording":{"type":"boolean","description":"Default allow disabling screen recording setting"},"screen_share_display_surface":{"type":"string","enum":["window","monitor"],"description":"Default screen share display surface","example":"window"},"require_screen_share_verification":{"type":"boolean","description":"Default screen share verification setting"},"voice_modulation_enabled":{"type":"boolean","description":"Default voice modulation setting"},"default_captions_enabled":{"type":"boolean","description":"Default captions enabled setting"},"session_timeout":{"anyOf":[{"type":"number","enum":[30]},{"type":"number","enum":[60]},{"type":"number","enum":[120]},{"type":"number","enum":[180]},{"type":"number","enum":[300]},{"type":"number","enum":[600]},{"type":"number","enum":[900]}],"description":"Default session inactivity timeout in seconds","example":300},"anonymize_participant_names":{"type":"boolean","description":"Default participant name anonymization setting"}},"required":["tts_provider","moderator_voice","voice_gender","moderator_name","greet_participant_by_name","voice_speed","default_language","auto_invite","email_required","name_required","video_sharing_requirement","screen_sharing_requirement","device_restriction","default_follow_up_depth","push_to_talk","blur_participant_camera","blur_participant_screen_share","allow_disabling_screen_recording","screen_share_display_surface","require_screen_share_verification","voice_modulation_enabled","default_captions_enabled","session_timeout","anonymize_participant_names"],"description":"Default values for new projects (moderator voice resolved from platform config)"}},"required":["voices","speed_options","enums","defaults"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects":{"get":{"operationId":"listProjects","tags":["Projects"],"summary":"List projects","description":"Paginated list of research projects for the authenticated organization. Pass `detail=full` to include all detail fields (`description`, `screener_required`, `questionnaire_id`) in each list item — same fields as the single-project endpoint. See the `ProjectDetailListResponse` schema.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"required":false,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"required":false,"description":"Opaque cursor for the next page","name":"cursor","in":"query"},{"schema":{"type":"string","description":"Case-insensitive contains match on project name and participant-facing name","example":"onboarding"},"required":false,"description":"Case-insensitive contains match on project name and participant-facing name","name":"name","in":"query"},{"schema":{"type":"string","description":"Exact slug match. Mutually exclusive with slug_contains — providing both returns 422.","example":"my-project-6b864d"},"required":false,"description":"Exact slug match. Mutually exclusive with slug_contains — providing both returns 422.","name":"slug","in":"query"},{"schema":{"type":"string","description":"Case-insensitive partial match on project slug","example":"my-project"},"required":false,"description":"Case-insensitive partial match on project slug","name":"slug_contains","in":"query"},{"schema":{"type":"string","description":"Filter by workspace ID"},"required":false,"description":"Filter by workspace ID","name":"workspace_id","in":"query"},{"schema":{"type":"string","enum":["full"],"description":"When set to \"full\", each list item includes all fields from the single-resource endpoint (e.g., description, screener_required for projects; embedded sessions for interviews; profile for participants). Omit for lighter responses when only summary fields are needed. Pagination behavior (limit, cursor, has_more) is unchanged.","example":"full"},"required":false,"description":"When set to \"full\", each list item includes all fields from the single-resource endpoint (e.g., description, screener_required for projects; embedded sessions for interviews; profile for participants). Omit for lighter responses when only summary fields are needed. Pagination behavior (limit, cursor, has_more) is unchanged.","name":"detail","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Minimum number of completed interviews (inclusive). 1 participant = 1 interview.","example":"1"},"required":false,"description":"Minimum number of completed interviews (inclusive). 1 participant = 1 interview.","name":"min_interviews_count","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Filter by whether the project has published screener questions","example":"true"},"required":false,"description":"Filter by whether the project has published screener questions","name":"has_screener","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Filter by whether the project has at least one session with a playable recording, in any layout","example":"true"},"required":false,"description":"Filter by whether the project has at least one session with a playable recording, in any layout","name":"has_recordings","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Minimum number of questionnaire items (inclusive)","example":"1"},"required":false,"description":"Minimum number of questionnaire items (inclusive)","name":"min_questionnaire_items_count","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Filter by whether the questionnaire contains section blocks","example":"true"},"required":false,"description":"Filter by whether the questionnaire contains section blocks","name":"has_section_blocks","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Filter by whether the questionnaire has unpublished draft changes","example":"false"},"required":false,"description":"Filter by whether the questionnaire has unpublished draft changes","name":"has_unpublished_changes","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Minimum number of participants in in_review status (inclusive)","example":"1"},"required":false,"description":"Minimum number of participants in in_review status (inclusive)","name":"min_in_review_count","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Minimum number of accepted participants (inclusive)","example":"1"},"required":false,"description":"Minimum number of accepted participants (inclusive)","name":"min_accepted_count","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Minimum number of survey responses (inclusive)","example":"1"},"required":false,"description":"Minimum number of survey responses (inclusive)","name":"min_survey_responses_count","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Maximum number of completed interviews (inclusive)","example":"20"},"required":false,"description":"Maximum number of completed interviews (inclusive)","name":"max_interviews_count","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"description":"Maximum number of draft recruitment orders (inclusive). Use 0 to find projects with no pending drafts.","example":"0"},"required":false,"description":"Maximum number of draft recruitment orders (inclusive). Use 0 to find projects with no pending drafts.","name":"max_draft_orders_count","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Filter by share link availability. true = questionnaire published at least once.","example":"true"},"required":false,"description":"Filter by share link availability. true = questionnaire published at least once.","name":"has_share_link","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results created after this ISO 8601 datetime","name":"created_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results created before this ISO 8601 datetime","name":"created_before","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results updated after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results updated after this ISO 8601 datetime","name":"updated_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results updated before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results updated before this ISO 8601 datetime","name":"updated_before","in":"query"},{"schema":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"},"required":false,"description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","name":"sort","in":"query"}],"responses":{"200":{"description":"Paginated project list. Items are `ProjectListItem` by default, or `ProjectDetail` when `detail=full`.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique project identifier","example":"cm7a9qk2x0001jv04hn3w8m5p"},"project_name":{"type":"string","description":"Internal project name (falls back to participant-facing name)","example":"Q4 Onboarding Research"},"participant_facing_name":{"type":"string","description":"Name shown to participants","example":"Product Experience Interview"},"slug":{"type":"string","description":"URL-safe project slug","example":"vk7d0kv4VXPkbNjP8sTj8QrYF"},"organization_id":{"type":"string","description":"Organization that owns this project"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-10-15T14:30:00.000Z"},"updated_at":{"type":"string","format":"date-time","description":"ISO 8601 last-modified timestamp. Use ?sort=updated_at:desc for incremental sync — compare against your stored checkpoint and stop when results are older than your last run.","example":"2025-11-20T09:15:00.000Z"},"participants_count":{"type":"integer","minimum":0,"description":"Number of unique participants with completed recording and analysis","example":15},"survey_responses_count":{"type":"integer","minimum":0,"description":"Number of structured survey responses collected during sessions","example":42},"has_screener":{"type":"boolean","description":"Whether the project has published screener questions","example":true},"moderation_mode":{"type":"string","enum":["ai","human"],"description":"Who runs each interview: `ai` (the AI moderator) or `human` (a researcher on a scheduled video call). Set at creation and cannot be changed.","example":"ai"},"anonymize_participant_names":{"type":["boolean","null"],"description":"Whether participant anonymization is enabled for this project. When true, Strella never returns participant `email` anywhere — API, MCP, or exports — regardless of the caller's `participants:read:email` grant, and participant names are anonymized aliases. `null` when the project has no questionnaire yet.","example":false},"questionnaire_items_count":{"type":"integer","minimum":0,"description":"Number of items in the active questionnaire","example":8},"has_section_blocks":{"type":"boolean","description":"Whether the questionnaire contains section blocks","example":true},"has_unpublished_changes":{"type":"boolean","description":"Whether the questionnaire has unpublished draft changes (draftItemsV2 differs from itemsV2)","example":false},"unqualified_count":{"type":"integer","minimum":0,"description":"Participants in screener funnel (started or incomplete)","example":10},"qualified_count":{"type":"integer","minimum":0,"description":"Participants who passed screener and are waiting to be invited","example":4},"invited_count":{"type":"integer","minimum":0,"description":"Participants approved to join interview","example":3},"in_review_count":{"type":"integer","minimum":0,"description":"Participants who completed an interview and are awaiting researcher quality review","example":2},"accepted_count":{"type":"integer","minimum":0,"description":"Participants accepted by researcher (paid and finalized)","example":5},"rejected_count":{"type":"integer","minimum":0,"description":"Participants who failed screener, quality review, or did not attend","example":1},"draft_orders_count":{"type":"integer","minimum":0,"description":"Number of draft (unpublished) recruitment orders","example":0},"workspace_id":{"type":"string","description":"Workspace ID, omitted when project has no workspace"},"description":{"type":"string","description":"Project description. Omitted when not set (use PATCH with null to clear, or omit to leave unchanged).","example":"Exploring onboarding friction for new users"}},"required":["id","project_name","participant_facing_name","slug","organization_id","created_at","updated_at","participants_count","survey_responses_count","has_screener","moderation_mode","anonymize_participant_names","questionnaire_items_count","has_section_blocks","has_unpublished_changes","unqualified_count","qualified_count","invited_count","in_review_count","accepted_count","rejected_count","draft_orders_count"]},"description":"Projects sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["projects","meta"]},{"type":"object","properties":{"projects":{"type":"array","items":{"$ref":"#/components/schemas/ProjectDetail"},"description":"Projects sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["projects","meta"]}]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"503":{"description":"Service unavailable","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createProject","tags":["Projects"],"summary":"Create project","description":"Create a new research project. Only `project_name` is required; all other fields use platform defaults (see `GET /projects/settings`).\n\nThe project is created live with an empty questionnaire. Typical setup workflow:\n\n1. (Optional) Configure settings with `PATCH /projects/{project_id}`\n2. Add at least one question or task with `PUT /projects/{project_id}/questionnaire/items`\n3. (Optional) Add screener questions with `PUT /projects/{project_id}/screener`\n\nAll changes take effect immediately. The `share_url` field is populated on the first `PUT /questionnaire/items` call.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectBody"}}}},"responses":{"201":{"description":"Project created","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectDetail"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Workspace not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}":{"get":{"operationId":"getProject","tags":["Projects"],"summary":"Get project","description":"Retrieve a single research project by ID or slug. Pass `?project_context=true` to include the secure research context field.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","enum":["true"],"description":"Pass `true` to include the `project_context` field in the response. Omitted by default to reduce payload size.","example":"true"},"required":false,"description":"Pass `true` to include the `project_context` field in the response. Omitted by default to reduce payload size.","name":"project_context","in":"query"}],"responses":{"200":{"description":"Project detail","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectDetail"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteProject","tags":["Projects"],"summary":"Delete project","description":"Soft-delete a research project and all related data (participants, sessions, transcripts, questionnaire, screener). Returns 204 No Content on success. The project is no longer accessible via the API after deletion.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"}],"responses":{"204":{"description":"Project deleted"},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"updateProject","tags":["Projects"],"summary":"Update project","description":"Update project settings using RFC 7396 merge-patch semantics. Only provided fields are updated; omitted fields are unchanged. Set nullable fields to `null` to clear them.\n\nUse this endpoint to configure the project between creation and publishing — for example, setting `moderator_voice`, `default_follow_up_depth`, or `target_audience`. Call `GET /projects/settings` to discover valid values for enum and voice fields.\n\nTo close a project, set `manually_closed` to `true`. To reopen, set it to `false`. Closing prevents new participants from joining but does not delete data.\n\n**`auto_invite` is a project-level setting.** Writing it updates the project atomically. Per-order auto-invite is not exposed on the API — recruitment orders created via the API are always submitted to the recruitment panel provider with auto-invite off, so qualified participants are always routed through manual review.\n\n**`custom_fields` replaces the whole list.** Read the project, change its `custom_fields`, and send the list back: an entry with an `id` changes that field, and one without adds a field. The API deletes a field left out of the list along with every participant's value in it. A project read can be up to 5 minutes old, so read the project right before you send the list.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectBody"}}}},"responses":{"200":{"description":"Project updated","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectDetail"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project or workspace not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Two custom fields would share a name, or two active participants share a value in the field the request makes the participant identifier","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/questionnaire/items/{item_id}":{"get":{"operationId":"getQuestionnaireItem","tags":["Questionnaires"],"summary":"Get questionnaire item","description":"Retrieve a single questionnaire item by ID. Returns the item with all its properties. For container types (section_block, media_block), children are included.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Questionnaire item identifier"},"required":true,"description":"Questionnaire item identifier","name":"item_id","in":"path"}],"responses":{"200":{"description":"Questionnaire item","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnaireItem"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project, questionnaire, or item not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"put":{"operationId":"replaceQuestionnaireItem","tags":["Questionnaires"],"summary":"Replace questionnaire item","description":"Full replacement of a questionnaire item's properties. `item_type` is required. Children are preserved — use child-level CRUD to modify them. The replace keeps the stored internal-only fields (the id of an imported Figma prototype, startMediaId). It also keeps the stored blur_screen_recording, verbatim and participant_controlled when the body omits them and the item type still offers them, and on a task the stored figma_task_type, figma_start_screen and figma_goal_screen when the body omits them and they still apply.\n\n**Immediate effect:** The questionnaire is published atomically after the replacement.\n\n**Concurrent editing:** If a researcher is editing in the browser while API writes occur, the last write wins. No data is lost, but unsaved browser changes may be overwritten.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Questionnaire item identifier"},"required":true,"description":"Questionnaire item identifier","name":"item_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplaceItemBody"}}}},"responses":{"200":{"description":"Item replaced","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnaireItem"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project, questionnaire, or item not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteQuestionnaireItem","tags":["Questionnaires"],"summary":"Delete questionnaire item","description":"Remove an item from the questionnaire tree. Container items (section_block, media_block) are deleted with all their children. Conditional logic (`onlyAskIf`) referencing the deleted item is automatically cleaned up. Returns 204 No Content on success.\n\n**Live projects:** Deleting an item on a project with completed interviews does not remove existing survey responses or analysis summaries collected for that item. Those records become orphaned and will no longer appear in per-question charts.\n\n**Immediate effect:** The questionnaire is published atomically after the deletion.\n\n**Concurrent editing:** If a researcher is editing in the browser while API writes occur, the last write wins. No data is lost, but unsaved browser changes may be overwritten.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Questionnaire item identifier"},"required":true,"description":"Questionnaire item identifier","name":"item_id","in":"path"}],"responses":{"204":{"description":"Item deleted"},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project, questionnaire, or item not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"patchQuestionnaireItem","tags":["Questionnaires"],"summary":"Partial update questionnaire item","description":"Partial update of a questionnaire item using merge-patch semantics. Only provided fields are updated; omitted fields are preserved. Pass `null` to clear a nullable field. `item_type` and `children` cannot be changed via PATCH.\n\n**Immediate effect:** The questionnaire is published atomically after the update.\n\n**Concurrent editing:** If a researcher is editing in the browser while API writes occur, the last write wins. No data is lost, but unsaved browser changes may be overwritten.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Questionnaire item identifier"},"required":true,"description":"Questionnaire item identifier","name":"item_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchItemBody"}}}},"responses":{"200":{"description":"Item updated","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnaireItem"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project, questionnaire, or item not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/questionnaire/items":{"put":{"operationId":"replaceQuestionnaireItems","tags":["Questionnaires"],"summary":"Replace questionnaire items","description":"Bulk-replace all questionnaire items (the interview script) for a project. This is a full replacement — items not included in the request are removed.\n\n**Immediate effect:** The AI moderator will use the new items for all subsequent interviews.\n\n**ID preservation:** Provide `id` on items to preserve them across edits (round-trip safe). Omit `id` for new items — a UUID will be generated. For projects with existing interviews, always preserve IDs via a GET-modify-PUT workflow. New IDs orphan per-question analysis data (survey responses, interview progress, highlights).\n\n**Destructive empty PUT:** Sending an empty `items` array on a live project with existing interviews orphans all downstream data (survey responses, analysis summaries, interview progress). This cannot be undone.\n\n**Side effects:** Creates share link and anonymous call token on first write. Detects the content language and sets it as the moderator interview language. Marks project for insight refresh.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutQuestionnaireItemsBody"}}}},"responses":{"200":{"description":"Questionnaire with replaced items","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnaireDetail"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project or questionnaire not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"503":{"description":"Service unavailable","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createQuestionnaireItem","tags":["Questionnaires"],"summary":"Add questionnaire item","description":"Add a new item to the questionnaire tree. Specify `parent_id` to insert inside a container (section_block, media_block), or `null` to insert at the root level. Provide `position` (0-based) to insert at a specific index; omit to append at the end.\n\n**Nesting rules:** Leaf items (question, task, multiple_choice, etc.) cannot contain children. Nested containers beyond one level are not permitted. Returns 422 with a nesting rule violation detail when the insertion would violate tree constraints.\n\n**Maximum items:** A questionnaire may contain at most 500 items. Exceeds this limit → 422.\n\n**Immediate effect:** The questionnaire is published atomically after the item is inserted.\n\n**Concurrent editing:** If a researcher is editing in the browser while API writes occur, the last write wins. No data is lost, but unsaved browser changes may be overwritten.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateItemBody"}}}},"responses":{"201":{"description":"Item created. Location header contains the item URL.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnaireItem"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project, questionnaire, or parent item not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/questionnaire":{"get":{"operationId":"getQuestionnaire","tags":["Questionnaires"],"summary":"Get questionnaire","description":"Retrieve the questionnaire (interview script) for a project. Returns the `items` the AI moderator follows during interviews. `PUT /questionnaire/items` changes take effect immediately, so `items` always reflects the latest saved state.\n\nItems form a tree: container types (`section_block`, `media_block`) hold `children`, leaf types (`question`, `task`, `multiple_choice`, etc.) do not.\n\nPass `?include_draft=true` to include unpublished draft items (from web dashboard edits) in the `draft_items` field.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","enum":["true"],"description":"Pass `true` to include the `draft_items` field when unpublished changes exist."},"required":false,"description":"Pass `true` to include the `draft_items` field when unpublished changes exist.","name":"include_draft","in":"query"}],"responses":{"200":{"description":"Questionnaire detail","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionnaireDetail"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Questionnaire not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/quotas":{"get":{"operationId":"listQuotas","tags":["Quotas"],"summary":"List quotas","description":"Returns all quotas for a project with assignment and completion counts. No pagination — max 100 quotas per project. Optionally filter by computed status (open, filled).","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Filter by computed status. Values: open, filled. Comma-separated for multiple.","example":"open"},"required":false,"description":"Filter by computed status. Values: open, filled. Comma-separated for multiple.","name":"status","in":"query"}],"responses":{"200":{"description":"All quotas for the project","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"quotas":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique quota identifier","example":"cm7a9qk2x0001jv04quota01"},"name":{"type":"string","description":"Display name","example":"Senior PMs (US)"},"limit":{"type":"integer","exclusiveMinimum":0,"description":"Target participant count","example":10},"behavior":{"type":"string","enum":["close_when_full","allow_overflow"],"description":"How the quota behaves at capacity. `close_when_full` blocks new participants; `allow_overflow` continues accepting.","example":"close_when_full"},"status":{"type":"string","enum":["open","filled"],"description":"Computed status. `open` when under limit or behavior is allow_overflow. `filled` when close_when_full and completed_count >= limit.","example":"open"},"completed_count":{"type":"integer","minimum":0,"description":"Participants who completed their interview and were approved toward this quota. Counts toward the `limit`.","example":7},"qualified_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have passed the screener and are awaiting researcher invite. Approximates the in-app \"Qualified\" column; for panel participants, may transiently include screener-failures still pending vendor reconciliation (these appear under \"Disqualified\" in-app). Eventually consistent with vendor status — see the recruitment-order `counts.as_of` field for the freshness contract (Respondent webhook-driven; User Interviews cron only refreshes when new sign-ups arrive).","example":3},"invited_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have been invited but have not yet completed an interview. Same freshness contract as `qualified_count`.","example":2},"criteria":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"type":"object","properties":{"screener_question":{"type":"string","description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"]},"minItems":1,"description":"Filters AND-ed within this group"}},"required":["filters"]},"description":"Filter groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form)"}},"required":["filter_groups"]},"group":{"type":["object","null"],"properties":{"id":{"type":"string","description":"Quota group identifier","example":"cm7a9qk2x0001jv04qgrp001"},"name":{"type":"string","description":"Quota group name","example":"Gender"}},"required":["id","name"],"description":"Quota group this quota belongs to, or `null` when ungrouped. Quotas in the same group are mutually exclusive buckets of one dimension (for example every gender option), and a participant is counted in one quota per group. Read-only here: place a quota with `group_id` on create or update, and read the groups on the project from `quota_groups` on the quota list."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-02-01T10:00:00.000Z"}},"required":["id","name","limit","behavior","status","completed_count","qualified_count","invited_count","criteria","group","created_at"]},"description":"All quotas for the project (max 100, no pagination)"},"quota_groups":{"type":"array","items":{"$ref":"#/components/schemas/QuotaGroup"},"description":"Every live quota group on the project, oldest first, empty groups included. Each quota's `group` points at one of these by id. Empty when the project has no groups."},"meta":{"type":"object","properties":{"total":{"type":"integer","minimum":0,"description":"Total number of quotas matching the filter","example":3}},"required":["total"]}},"required":["quotas","quota_groups","meta"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createQuota","tags":["Quotas"],"summary":"Create quota","description":"Create a new quota for a project. Criteria reference screener questions by text — the project must have a published screener. Maximum 100 quotas per project.\n\n**Important:** Criteria reference screener question text and answer option text at write time. If screener answer options are later changed, existing quota criteria may stop matching participants. The API validates question text on create but does not block screener answer changes.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuotaBody"}}}},"responses":{"201":{"description":"Created quota","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Unique quota identifier","example":"cm7a9qk2x0001jv04quota01"},"name":{"type":"string","description":"Display name","example":"Senior PMs (US)"},"limit":{"type":"integer","exclusiveMinimum":0,"description":"Target participant count","example":10},"behavior":{"type":"string","enum":["close_when_full","allow_overflow"],"description":"How the quota behaves at capacity. `close_when_full` blocks new participants; `allow_overflow` continues accepting.","example":"close_when_full"},"status":{"type":"string","enum":["open","filled"],"description":"Computed status. `open` when under limit or behavior is allow_overflow. `filled` when close_when_full and completed_count >= limit.","example":"open"},"completed_count":{"type":"integer","minimum":0,"description":"Participants who completed their interview and were approved toward this quota. Counts toward the `limit`.","example":7},"qualified_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have passed the screener and are awaiting researcher invite. Approximates the in-app \"Qualified\" column; for panel participants, may transiently include screener-failures still pending vendor reconciliation (these appear under \"Disqualified\" in-app). Eventually consistent with vendor status — see the recruitment-order `counts.as_of` field for the freshness contract (Respondent webhook-driven; User Interviews cron only refreshes when new sign-ups arrive).","example":3},"invited_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have been invited but have not yet completed an interview. Same freshness contract as `qualified_count`.","example":2},"criteria":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"type":"object","properties":{"screener_question":{"type":"string","description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"]},"minItems":1,"description":"Filters AND-ed within this group"}},"required":["filters"]},"description":"Filter groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form)"}},"required":["filter_groups"]},"group":{"type":["object","null"],"properties":{"id":{"type":"string","description":"Quota group identifier","example":"cm7a9qk2x0001jv04qgrp001"},"name":{"type":"string","description":"Quota group name","example":"Gender"}},"required":["id","name"],"description":"Quota group this quota belongs to, or `null` when ungrouped. Quotas in the same group are mutually exclusive buckets of one dimension (for example every gender option), and a participant is counted in one quota per group. Read-only here: place a quota with `group_id` on create or update, and read the groups on the project from `quota_groups` on the quota list."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-02-01T10:00:00.000Z"}},"required":["id","name","limit","behavior","status","completed_count","qualified_count","invited_count","criteria","group","created_at"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project or quota group not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Criteria overlap a quota already in the target quota group","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/quotas/{quota_id}":{"get":{"operationId":"getQuota","tags":["Quotas"],"summary":"Get quota","description":"Returns a single quota with assignment and completion counts.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Quota identifier"},"required":true,"description":"Quota identifier","name":"quota_id","in":"path"}],"responses":{"200":{"description":"Quota detail","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Unique quota identifier","example":"cm7a9qk2x0001jv04quota01"},"name":{"type":"string","description":"Display name","example":"Senior PMs (US)"},"limit":{"type":"integer","exclusiveMinimum":0,"description":"Target participant count","example":10},"behavior":{"type":"string","enum":["close_when_full","allow_overflow"],"description":"How the quota behaves at capacity. `close_when_full` blocks new participants; `allow_overflow` continues accepting.","example":"close_when_full"},"status":{"type":"string","enum":["open","filled"],"description":"Computed status. `open` when under limit or behavior is allow_overflow. `filled` when close_when_full and completed_count >= limit.","example":"open"},"completed_count":{"type":"integer","minimum":0,"description":"Participants who completed their interview and were approved toward this quota. Counts toward the `limit`.","example":7},"qualified_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have passed the screener and are awaiting researcher invite. Approximates the in-app \"Qualified\" column; for panel participants, may transiently include screener-failures still pending vendor reconciliation (these appear under \"Disqualified\" in-app). Eventually consistent with vendor status — see the recruitment-order `counts.as_of` field for the freshness contract (Respondent webhook-driven; User Interviews cron only refreshes when new sign-ups arrive).","example":3},"invited_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have been invited but have not yet completed an interview. Same freshness contract as `qualified_count`.","example":2},"criteria":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"type":"object","properties":{"screener_question":{"type":"string","description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"]},"minItems":1,"description":"Filters AND-ed within this group"}},"required":["filters"]},"description":"Filter groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form)"}},"required":["filter_groups"]},"group":{"type":["object","null"],"properties":{"id":{"type":"string","description":"Quota group identifier","example":"cm7a9qk2x0001jv04qgrp001"},"name":{"type":"string","description":"Quota group name","example":"Gender"}},"required":["id","name"],"description":"Quota group this quota belongs to, or `null` when ungrouped. Quotas in the same group are mutually exclusive buckets of one dimension (for example every gender option), and a participant is counted in one quota per group. Read-only here: place a quota with `group_id` on create or update, and read the groups on the project from `quota_groups` on the quota list."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-02-01T10:00:00.000Z"}},"required":["id","name","limit","behavior","status","completed_count","qualified_count","invited_count","criteria","group","created_at"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Quota not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteQuota","tags":["Quotas"],"summary":"Delete quota","description":"Soft-delete a quota. Unassigns all in-progress participants. Does not clear completedQuotaId — historical records are preserved.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Quota identifier"},"required":true,"description":"Quota identifier","name":"quota_id","in":"path"}],"responses":{"204":{"description":"Quota deleted"},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Quota not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"updateQuota","tags":["Quotas"],"summary":"Update quota","description":"Update a quota using JSON Merge Patch (RFC 7396). All fields are optional — omitted fields are unchanged. Criteria is full replacement when provided.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Quota identifier"},"required":true,"description":"Quota identifier","name":"quota_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateQuotaBody"}}}},"responses":{"200":{"description":"Updated quota","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Unique quota identifier","example":"cm7a9qk2x0001jv04quota01"},"name":{"type":"string","description":"Display name","example":"Senior PMs (US)"},"limit":{"type":"integer","exclusiveMinimum":0,"description":"Target participant count","example":10},"behavior":{"type":"string","enum":["close_when_full","allow_overflow"],"description":"How the quota behaves at capacity. `close_when_full` blocks new participants; `allow_overflow` continues accepting.","example":"close_when_full"},"status":{"type":"string","enum":["open","filled"],"description":"Computed status. `open` when under limit or behavior is allow_overflow. `filled` when close_when_full and completed_count >= limit.","example":"open"},"completed_count":{"type":"integer","minimum":0,"description":"Participants who completed their interview and were approved toward this quota. Counts toward the `limit`.","example":7},"qualified_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have passed the screener and are awaiting researcher invite. Approximates the in-app \"Qualified\" column; for panel participants, may transiently include screener-failures still pending vendor reconciliation (these appear under \"Disqualified\" in-app). Eventually consistent with vendor status — see the recruitment-order `counts.as_of` field for the freshness contract (Respondent webhook-driven; User Interviews cron only refreshes when new sign-ups arrive).","example":3},"invited_count":{"type":"integer","minimum":0,"description":"Participants currently assigned to this quota who have been invited but have not yet completed an interview. Same freshness contract as `qualified_count`.","example":2},"criteria":{"type":"object","properties":{"filter_groups":{"type":"array","items":{"type":"object","properties":{"filters":{"type":"array","items":{"type":"object","properties":{"screener_question":{"type":"string","description":"Screener question text to filter by. Must match verbatim (case-insensitive) a question from GET /projects/{id}/screener. Mismatches silently match zero participants.","example":"What is your primary role?"},"match_rule":{"type":"string","enum":["is_one_of","is_none_of"],"description":"Match rule","example":"is_one_of"},"answers":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Answer option texts to match","example":["Product Manager","Senior PM"]}},"required":["screener_question","match_rule","answers"]},"minItems":1,"description":"Filters AND-ed within this group"}},"required":["filters"]},"description":"Filter groups OR-ed together. Filters within each group are AND-ed. (Disjunctive Normal Form)"}},"required":["filter_groups"]},"group":{"type":["object","null"],"properties":{"id":{"type":"string","description":"Quota group identifier","example":"cm7a9qk2x0001jv04qgrp001"},"name":{"type":"string","description":"Quota group name","example":"Gender"}},"required":["id","name"],"description":"Quota group this quota belongs to, or `null` when ungrouped. Quotas in the same group are mutually exclusive buckets of one dimension (for example every gender option), and a participant is counted in one quota per group. Read-only here: place a quota with `group_id` on create or update, and read the groups on the project from `quota_groups` on the quota list."},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-02-01T10:00:00.000Z"}},"required":["id","name","limit","behavior","status","completed_count","qualified_count","invited_count","criteria","group","created_at"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Quota or quota group not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Criteria overlap a quota in the same quota group, or the quota still holds grouped participants","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/recruitment":{"get":{"operationId":"listRecruitmentOrders","tags":["Recruitment"],"summary":"List recruitment orders","description":"List all recruitment orders for a project, merged across both panels (Respondent and User Interviews). Returns paginated summary DTOs sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\").\n\nUse `?panel=` to filter by recruitment panel provider, `?status=` to filter by lifecycle state, `?detail=full` to include targeting data (auto-caps limit at 25). Detail-mode items follow the same discriminated-union shape as `GET /projects/{project_id}/recruitment/{order_id}`.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"required":false,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"required":false,"description":"Opaque cursor for the next page","name":"cursor","in":"query"},{"schema":{"type":"string","enum":["respondent","user_interviews"],"description":"Filter by recruitment vendor panel.","example":"respondent"},"required":false,"description":"Filter by recruitment vendor panel.","name":"panel","in":"query"},{"schema":{"type":"string","enum":["pending","recruiting","filled","closed","paused"],"description":"Filter by order lifecycle status.","example":"recruiting"},"required":false,"description":"Filter by order lifecycle status.","name":"status","in":"query"},{"schema":{"type":"string","enum":["full"],"description":"When set to \"full\", each list item includes all fields from the single-resource endpoint (e.g., description, screener_required for projects; embedded sessions for interviews; profile for participants). Omit for lighter responses when only summary fields are needed. Pagination behavior (limit, cursor, has_more) is unchanged.","example":"full"},"required":false,"description":"When set to \"full\", each list item includes all fields from the single-resource endpoint (e.g., description, screener_required for projects; embedded sessions for interviews; profile for participants). Omit for lighter responses when only summary fields are needed. Pagination behavior (limit, cursor, has_more) is unchanged.","name":"detail","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results created after this ISO 8601 datetime","name":"created_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results created before this ISO 8601 datetime","name":"created_before","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results updated after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results updated after this ISO 8601 datetime","name":"updated_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results updated before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results updated before this ISO 8601 datetime","name":"updated_before","in":"query"},{"schema":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"},"required":false,"description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","name":"sort","in":"query"}],"responses":{"200":{"description":"Paginated list of recruitment orders. Items are summary DTOs by default; with `?detail=full` they include the full targeting block.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"recruitment_orders":{"type":"array","items":{"$ref":"#/components/schemas/RecruitmentOrder"}},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["recruitment_orders","meta"]},{"type":"object","properties":{"recruitment_orders":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/RecruitmentOrderRespondentDetail"},{"$ref":"#/components/schemas/RecruitmentOrderUserInterviewsDetail"}],"discriminator":{"propertyName":"panel","mapping":{"respondent":"#/components/schemas/RecruitmentOrderRespondentDetail","user_interviews":"#/components/schemas/RecruitmentOrderUserInterviewsDetail"}}}},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["recruitment_orders","meta"]}]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/recruitment/{order_id}":{"get":{"operationId":"getRecruitmentOrderDetail","tags":["Recruitment"],"summary":"Get recruitment order detail","description":"Returns a single recruitment order with full targeting data. The response shape is discriminated on the `panel` field — Respondent orders include named targeting fields, User Interviews orders include operator-based criteria.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Recruitment order identifier"},"required":true,"description":"Recruitment order identifier","name":"order_id","in":"path"}],"responses":{"200":{"description":"Recruitment order with targeting.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/RecruitmentOrderRespondentDetail"},{"$ref":"#/components/schemas/RecruitmentOrderUserInterviewsDetail"}],"discriminator":{"propertyName":"panel","mapping":{"respondent":"#/components/schemas/RecruitmentOrderRespondentDetail","user_interviews":"#/components/schemas/RecruitmentOrderUserInterviewsDetail"}}}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project or order not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteRecruitmentOrder","tags":["Recruitment"],"summary":"Delete recruitment order","description":"Delete a pending (draft) recruitment order. Only draft orders can be deleted. Returns 204 No Content.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Recruitment order identifier"},"required":true,"description":"Recruitment order identifier","name":"order_id","in":"path"}],"responses":{"204":{"description":"Recruitment order deleted"},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project or order not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Order is not in a deletable state","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"patchRecruitmentOrder","tags":["Recruitment"],"summary":"Update a recruitment order","description":"Single PATCH endpoint that drives both lifecycle transitions and metadata edits.\n\n**Lifecycle transitions (`{ status }`):**\n- `recruiting` — submit a draft order (charges credits, calls the panel) or resume a paused User Interviews order\n- `paused` — pause an active order; the panel stops sourcing participants (Respondent pauses are terminal)\n- `closed` — close the order and auto-refund unused credits (User Interviews only in v1)\n\n**Metadata edits (panel-shaped body):**\nUpdate a pending (draft) order's targeting/text fields. Body shape depends on the order's panel.\n\nReturns 422 for invalid state transitions, mixed lifecycle+metadata bodies, or metadata edits on non-draft orders. Returns 409 if a concurrent state change occurred.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Recruitment order identifier"},"required":true,"description":"Recruitment order identifier","name":"order_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchRecruitmentOrderBody"}}}},"responses":{"200":{"description":"Updated recruitment order. Returns the full recruitment order detail.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/RecruitmentOrderRespondentDetail"},{"$ref":"#/components/schemas/RecruitmentOrderUserInterviewsDetail"}],"discriminator":{"propertyName":"panel","mapping":{"respondent":"#/components/schemas/RecruitmentOrderRespondentDetail","user_interviews":"#/components/schemas/RecruitmentOrderUserInterviewsDetail"}}}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project or order not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Order state changed concurrently.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"503":{"description":"Service unavailable","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/recruitment/respondent/options":{"get":{"operationId":"getRespondentRecruitmentOptions","tags":["Recruitment"],"summary":"Get Respondent targeting options","description":"Returns all available targeting options for Respondent.io recruitment: age groups, genders, countries, education levels, company sizes, seniority levels, household incomes, ethnicities, industries, job functions, and B2C research topics.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Available targeting options for Respondent recruitment.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RespondentOptions"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/recruitment/respondent/job-titles":{"get":{"operationId":"searchJobTitles","tags":["Recruitment"],"summary":"Search job titles","description":"Search available job titles for B2B Respondent targeting. Returns matching titles (max 20).","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":2,"maxLength":100,"description":"Search query (case-insensitive contains match, min 2 characters).","example":"product"},"required":true,"description":"Search query (case-insensitive contains match, min 2 characters).","name":"q","in":"query"}],"responses":{"200":{"description":"Matching job titles.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobTitleSearchResponse"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/recruitment/respondent":{"post":{"operationId":"createRespondentRecruitmentOrder","tags":["Recruitment"],"summary":"Create Respondent recruitment order","description":"Create a pending Respondent.io recruitment order for a project. The order contains targeting criteria and must be submitted via PATCH /projects/{project_id}/recruitment/{order_id} with body `{ \"status\": \"recruiting\" }` before recruitment begins.\n\nMultiple pending orders per project per panel are allowed; each call creates a new draft.\n\nPer-order `auto_invite` toggles Strella's internal AI auto-approval (screener passers rated 4+ are auto-invited without manual review); omit it to inherit the project's auto-recruit setting. It is NOT the vendor panel's auto-invite: Respondent orders always send `automations.autoinvite: false`, so the panel never auto-admits. This is separate from project-level `auto_invite` on PATCH /projects/{project_id}.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"participant_count":{"type":"integer","minimum":1,"maximum":150,"description":"Number of participants to recruit (1-150).","example":10},"incentive_per_participant":{"type":"integer","minimum":5,"maximum":200,"description":"Per-participant incentive in whole dollars USD ($5-$200).","example":25},"targeting":{"oneOf":[{"type":"object","properties":{"countries":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Target countries. Required. Use codes from GET /recruitment/respondent/options."},"age_groups":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target age ranges. Omit for all ages."},"genders":{"type":["object","null"],"properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"],"description":"Target gender. Null or omit for no gender filter."},"household_incomes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target household income brackets."},"education_levels":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target education levels."},"ethnicities":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target ethnicities."},"market_type":{"type":"string","enum":["b2b"]},"job_titles":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Required for B2B. Job title IDs from GET /recruitment/respondent/job-titles."},"job_functions":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Required for B2B. Job function codes from GET /recruitment/respondent/options."},"industries":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Required for B2B. Industry IDs from GET /recruitment/respondent/options."},"seniority_levels":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Seniority levels. Omit for all levels."},"company_sizes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Company size filter. Omit for all sizes."}},"required":["countries","market_type","job_titles","job_functions","industries"]},{"type":"object","properties":{"countries":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"description":"Target countries. Required. Use codes from GET /recruitment/respondent/options."},"age_groups":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target age ranges. Omit for all ages."},"genders":{"type":["object","null"],"properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"],"description":"Target gender. Null or omit for no gender filter."},"household_incomes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target household income brackets."},"education_levels":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target education levels."},"ethnicities":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"description":"Target ethnicities."},"market_type":{"type":"string","enum":["b2c"]},"research_topics":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"description":"Human-readable label.","example":"United States"},"value":{"type":"string","minLength":1,"description":"Machine-readable code.","example":"US"}},"required":["name","value"]},"minItems":1,"maxItems":2,"description":"Required for B2C. Research topic codes (1-2) from GET /recruitment/respondent/options."}},"required":["countries","market_type","research_topics"]}],"description":"Targeting criteria. The `market_type` field determines which targeting fields are required."},"title":{"type":"string","minLength":1,"maxLength":100,"description":"Public study title shown to panelists (max 100 chars). Defaults to the project name. Duplicates are auto-suffixed (\" 2\", \" 3\", …) server-side; the resolved name is returned in the response `name` field.","example":"Acme Onboarding Study"},"auto_invite":{"type":"boolean","description":"Strella's internal AI auto-approval for this order. When `true`, screener passers rated 4+ by Strella's automated evaluation are auto-invited to interview without manual review. This is NOT the vendor panel's auto-invite: the Respondent.io panel never auto-admits participants (orders always send `automations.autoinvite: false`). Defaults to the project's auto-recruit setting when omitted.","example":false},"sandbox":{"type":"boolean","description":"Sandbox mode. When true, the order is created with `sandbox: true`, the Respondent.io integration is skipped, and a synthetic self-recruited participant + session is generated for end-to-end testing. Equivalent to sending the `X-Sandbox: true` request header.","example":false}},"required":["participant_count","incentive_per_participant","targeting"],"additionalProperties":false}}}},"responses":{"201":{"description":"Pending recruitment order created with full targeting detail.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecruitmentOrderRespondentDetail"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/screener":{"get":{"operationId":"getScreener","tags":["Screeners"],"summary":"Get screener questions","description":"Returns all screener questions for a project. The screener is an optional qualifying quiz shown to participants BEFORE the interview — distinct from the questionnaire (interview script shown DURING the interview).\n\nA matrix question carries its rows, columns and per-cell qualification in `matrix`; every other type returns `matrix: null`.\n\nPass `?include_draft=true` to include `is_draft` status on each question.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","enum":["true"],"description":"Pass `true` to include `is_draft` status on each question, showing which are unpublished."},"required":false,"description":"Pass `true` to include `is_draft` status on each question, showing which are unpublished.","name":"include_draft","in":"query"}],"responses":{"200":{"description":"Screener questions","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener question identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"text":{"type":"string","description":"Question text","example":"Which products have you purchased recently?"},"question_type":{"type":"string","enum":["radio","checkbox","slider_scale","single_line_text","multi_line_text","matrix"],"description":"Type of screener question"},"is_required":{"type":"boolean","description":"Whether the question must be answered"},"position":{"type":"integer","description":"Display position (0-based)","example":0},"answers":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text displayed to participants","example":"Yes, within the last month"},"answer_value":{"type":"string","description":"Radio: \"1\" qualify, \"2\" disqualify. Checkbox: \"1\" may select, \"2\" must select, \"3\" disqualify.","example":"1"}},"required":["text","answer_value"]},"description":"Answer options"},"include_other_option":{"type":"boolean","description":"Whether an \"Other\" free-text option is included. Always `false` on question types other than radio and checkbox."},"other_option_answer_value":{"type":"string","description":"Qualifying value for the \"Other\" option. Always `\"1\"` on question types other than radio and checkbox."},"slider_config":{"type":["object","null"],"properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1}},"required":["min_value","max_value","step"],"description":"Slider configuration (null for non-slider questions)"},"matrix":{"type":["object","null"],"properties":{"selection_mode":{"type":"string","enum":["single","multiple"],"description":"`single`: participants pick one column per row. `multiple`: participants pick any number of columns per row."},"qualify_when":{"type":"string","enum":["all_rows","any_row"],"description":"`all_rows`: the question qualifies only when every row passes. `any_row`: it qualifies when any one row passes."},"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","example":"Daily"}},"required":["id","text"]}},"rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","example":"Slack"},"cells":{"type":"object","additionalProperties":{"type":"string","enum":["1","2","3"],"description":"How picking this cell counts. Single select: \"1\" qualify, \"2\" disqualify. Multiple selection: \"1\" may select, \"2\" must select, \"3\" disqualify."},"description":"Map of column text to how picking that cell counts, for every column."}},"required":["id","text","cells"]}}},"required":["selection_mode","qualify_when","columns","rows"],"description":"Rows, columns and per-cell qualification (null for non-matrix questions)"},"skip_logic_id":{"type":["string","null"],"description":"Stable identifier for skip logic references. Other questions reference this ID in their skip logic destinations.","example":"sl_a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"skip_logic":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["answer_routing"],"description":"Radio question: each answer routes to a different question"},"destinations":{"type":"object","additionalProperties":{"type":"object","properties":{"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["destination_question_id"]},"description":"Map of answer text to destination. Keys match the answer text values."}},"required":["type","destinations"]},{"type":"object","properties":{"type":{"type":"string","enum":["condition"],"description":"Checkbox question: conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"answer_text":{"type":"string","description":"Answer text to match against"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether the answer should be selected or not"}},"required":["answer_text","match"]},"minItems":1,"description":"Conditions to evaluate (primary + additional)"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["slider_threshold"],"description":"Slider question: numeric threshold conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"operator":{"type":"string","enum":["lt","lte","eq","gte","gt"],"description":"Numeric comparison: lt = less than, lte = less than or equal, eq = equal, gte = greater than or equal, gt = greater than."},"threshold":{"type":"number","description":"Numeric threshold value compared against the slider answer."}},"required":["operator","threshold"]},"minItems":1,"description":"One or more numeric threshold conditions (primary + additional)."},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple threshold conditions."},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of a later question, null for end of screener, or an empty string for the next question"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["matrix_condition"],"description":"Matrix question: conditions on cells evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"row_text":{"type":"string","description":"Text of the row to match"},"column_text":{"type":"string","description":"Text of the column to match"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether that cell should be selected or not"}},"required":["row_text","column_text","match"]},"minItems":1,"description":"Cell conditions to evaluate"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"null"}],"description":"Skip logic configuration. Determines which question to show next based on the answer. Null when no skip logic is configured."},"is_draft":{"type":"boolean","description":"Whether this question is a draft (unpublished). Only present when `include_draft=true` is passed."}},"required":["id","text","question_type","is_required","position","answers","include_other_option","other_option_answer_value","slider_config","matrix","skip_logic_id","skip_logic"]},"description":"Screener questions sorted by position (ascending). Changes via PUT are immediately live."}},"required":["questions"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"put":{"operationId":"replaceScreener","tags":["Screeners"],"summary":"Replace screener questions","description":"Replace all screener questions for a project. Changes take effect immediately. The screener is an optional qualifying quiz shown to participants BEFORE the interview — distinct from the questionnaire (interview script shown DURING the interview).\n\nRequires at least one question. Use `DELETE /projects/{project_id}/screener` to remove all questions.\n\nA matrix question sends its grid in `matrix`. Echo each row and column `id` from a read to keep it; rows and columns without a known `id` get new ones. Cells are keyed by column text.\n\nReturns **409 Conflict** if removing or renaming a question would orphan quota criteria that reference it. Delete or update the affected quotas first.\n\n**Note:** Changing answer options on a screener question is allowed even when quotas reference it. Quota criteria that reference removed or renamed answers will stop matching participants but will not cause errors. Review affected quotas after changing answer options.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing question ID. When provided and matches an existing question, the question is updated in place — preserving its ID so downstream references stay linked. When omitted, a new ID is generated.","example":"cm7g3nq8r0007jm04wv5t2k6s"},"text":{"type":"string","minLength":1,"maxLength":500,"description":"Question text","example":"Which products have you purchased recently?"},"question_type":{"type":"string","enum":["radio","checkbox","slider_scale","single_line_text","multi_line_text","matrix"],"description":"Type of screener question","example":"radio"},"is_required":{"type":"boolean","default":true,"description":"Whether the participant must answer before continuing. Defaults to `true`, the same as a question added in the app. Send `false` to let participants skip it.","example":true},"answers":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":300,"description":"Answer text displayed to participants","example":"Yes, within the last month"},"answer_value":{"type":"string","enum":["1","2","3"],"description":"Qualifying value — see ScreenerAnswerValue for per-type semantics","example":"1"}},"required":["text","answer_value"]},"description":"Answer options (required for radio and checkbox, ignored for other types)"},"include_other_option":{"type":"boolean","description":"Include a free-text \"Other\" option (radio and checkbox only). Refused on matrix and not stored on the other types.","example":false},"other_option_answer_value":{"type":"string","enum":["1","2","3"],"description":"Qualifying value for the \"Other\" option (radio and checkbox only, not stored on other types)","example":"1"},"slider_config":{"type":"object","properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1}},"required":["min_value","max_value","step"],"description":"Slider configuration (required for slider_scale). Refused on matrix and not stored on the other types."},"matrix":{"type":"object","properties":{"selection_mode":{"type":"string","enum":["single","multiple"],"default":"single","description":"`single`: participants pick one column per row. `multiple`: participants pick any number of columns per row."},"qualify_when":{"type":"string","enum":["all_rows","any_row"],"default":"all_rows","description":"`all_rows`: the question qualifies only when every row passes. `any_row`: it qualifies when any one row passes."},"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing column ID, kept when the question is updated in place and already has this column. Any other value is replaced with a new ID."},"text":{"type":"string","minLength":1,"maxLength":300,"example":"Daily"}},"required":["text"]},"maxItems":7,"description":"Answer columns shared by every row (2-7)."},"rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1,"description":"Existing row ID, kept when the question is updated in place and already has this row. Any other value is replaced with a new ID."},"text":{"type":"string","minLength":1,"maxLength":300,"example":"Slack"},"cells":{"type":"object","additionalProperties":{"type":"string","enum":["1","2","3"],"description":"How picking this cell counts. Single select: \"1\" qualify, \"2\" disqualify. Multiple selection: \"1\" may select, \"2\" must select, \"3\" disqualify."},"description":"Map of column text to how picking that cell counts. Every column the map leaves out counts as \"1\"."}},"required":["text"]},"maxItems":10,"description":"Rows, each answered on the shared columns (2-10)."}},"required":["columns","rows"],"description":"Rows, columns and per-cell qualification (required for matrix, not allowed on other types)"},"skip_logic_id":{"type":"string","minLength":1,"description":"Stable identifier referenced by other questions' `skip_logic.destinations[].destination_question_id` / `destination_question_id`. Provide on update to preserve referencing rules; omitted on create (server generates).","example":"sl_a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"skip_logic":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["answer_routing"],"description":"Radio question: each answer routes to a different question"},"destinations":{"type":"object","additionalProperties":{"type":"object","properties":{"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["destination_question_id"]},"description":"Map of answer text to destination. Keys match the answer text values."}},"required":["type","destinations"]},{"type":"object","properties":{"type":{"type":"string","enum":["condition"],"description":"Checkbox question: conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"answer_text":{"type":"string","description":"Answer text to match against"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether the answer should be selected or not"}},"required":["answer_text","match"]},"minItems":1,"description":"Conditions to evaluate (primary + additional)"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["slider_threshold"],"description":"Slider question: numeric threshold conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"operator":{"type":"string","enum":["lt","lte","eq","gte","gt"],"description":"Numeric comparison: lt = less than, lte = less than or equal, eq = equal, gte = greater than or equal, gt = greater than."},"threshold":{"type":"number","description":"Numeric threshold value compared against the slider answer."}},"required":["operator","threshold"]},"minItems":1,"description":"One or more numeric threshold conditions (primary + additional)."},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple threshold conditions."},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of a later question, null for end of screener, or an empty string for the next question"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["matrix_condition"],"description":"Matrix question: conditions on cells evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"row_text":{"type":"string","description":"Text of the row to match"},"column_text":{"type":"string","description":"Text of the column to match"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether that cell should be selected or not"}},"required":["row_text","column_text","match"]},"minItems":1,"description":"Cell conditions to evaluate"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"null"}],"description":"Skip logic configuration. Discriminated by `type`: `answer_routing` (radio), `condition` (checkbox), `slider_threshold` (slider_scale), `matrix_condition` (matrix). Pass `null` to clear an existing rule. Omit to leave the existing rule untouched on update."}},"required":["text","question_type"]},"minItems":1,"maxItems":40,"description":"Ordered list of screener questions (1-40). Replaces all existing questions. Use DELETE to remove all questions."}},"required":["questions"]}}}},"responses":{"200":{"description":"Updated screener state","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener question identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"text":{"type":"string","description":"Question text","example":"Which products have you purchased recently?"},"question_type":{"type":"string","enum":["radio","checkbox","slider_scale","single_line_text","multi_line_text","matrix"],"description":"Type of screener question"},"is_required":{"type":"boolean","description":"Whether the question must be answered"},"position":{"type":"integer","description":"Display position (0-based)","example":0},"answers":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text displayed to participants","example":"Yes, within the last month"},"answer_value":{"type":"string","description":"Radio: \"1\" qualify, \"2\" disqualify. Checkbox: \"1\" may select, \"2\" must select, \"3\" disqualify.","example":"1"}},"required":["text","answer_value"]},"description":"Answer options"},"include_other_option":{"type":"boolean","description":"Whether an \"Other\" free-text option is included. Always `false` on question types other than radio and checkbox."},"other_option_answer_value":{"type":"string","description":"Qualifying value for the \"Other\" option. Always `\"1\"` on question types other than radio and checkbox."},"slider_config":{"type":["object","null"],"properties":{"min_value":{"type":"integer","description":"Minimum slider value","example":0},"max_value":{"type":"integer","description":"Maximum slider value","example":10},"step":{"type":"integer","exclusiveMinimum":0,"description":"Slider step increment","example":1}},"required":["min_value","max_value","step"],"description":"Slider configuration (null for non-slider questions)"},"matrix":{"type":["object","null"],"properties":{"selection_mode":{"type":"string","enum":["single","multiple"],"description":"`single`: participants pick one column per row. `multiple`: participants pick any number of columns per row."},"qualify_when":{"type":"string","enum":["all_rows","any_row"],"description":"`all_rows`: the question qualifies only when every row passes. `any_row`: it qualifies when any one row passes."},"columns":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","example":"Daily"}},"required":["id","text"]}},"rows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","example":"Slack"},"cells":{"type":"object","additionalProperties":{"type":"string","enum":["1","2","3"],"description":"How picking this cell counts. Single select: \"1\" qualify, \"2\" disqualify. Multiple selection: \"1\" may select, \"2\" must select, \"3\" disqualify."},"description":"Map of column text to how picking that cell counts, for every column."}},"required":["id","text","cells"]}}},"required":["selection_mode","qualify_when","columns","rows"],"description":"Rows, columns and per-cell qualification (null for non-matrix questions)"},"skip_logic_id":{"type":["string","null"],"description":"Stable identifier for skip logic references. Other questions reference this ID in their skip logic destinations.","example":"sl_a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"skip_logic":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["answer_routing"],"description":"Radio question: each answer routes to a different question"},"destinations":{"type":"object","additionalProperties":{"type":"object","properties":{"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["destination_question_id"]},"description":"Map of answer text to destination. Keys match the answer text values."}},"required":["type","destinations"]},{"type":"object","properties":{"type":{"type":"string","enum":["condition"],"description":"Checkbox question: conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"answer_text":{"type":"string","description":"Answer text to match against"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether the answer should be selected or not"}},"required":["answer_text","match"]},"minItems":1,"description":"Conditions to evaluate (primary + additional)"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["slider_threshold"],"description":"Slider question: numeric threshold conditions evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"operator":{"type":"string","enum":["lt","lte","eq","gte","gt"],"description":"Numeric comparison: lt = less than, lte = less than or equal, eq = equal, gte = greater than or equal, gt = greater than."},"threshold":{"type":"number","description":"Numeric threshold value compared against the slider answer."}},"required":["operator","threshold"]},"minItems":1,"description":"One or more numeric threshold conditions (primary + additional)."},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple threshold conditions."},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of a later question, null for end of screener, or an empty string for the next question"}},"required":["type","conditions","join","destination_question_id"]},{"type":"object","properties":{"type":{"type":"string","enum":["matrix_condition"],"description":"Matrix question: conditions on cells evaluate to a single destination"},"conditions":{"type":"array","items":{"type":"object","properties":{"row_text":{"type":"string","description":"Text of the row to match"},"column_text":{"type":"string","description":"Text of the column to match"},"match":{"type":"string","enum":["selected","not_selected"],"description":"Whether that cell should be selected or not"}},"required":["row_text","column_text","match"]},"minItems":1,"description":"Cell conditions to evaluate"},"join":{"type":"string","enum":["and","or"],"description":"How to combine multiple conditions"},"destination_question_id":{"type":["string","null"],"description":"skip_logic_id of the destination question, or null for end of screener"}},"required":["type","conditions","join","destination_question_id"]},{"type":"null"}],"description":"Skip logic configuration. Determines which question to show next based on the answer. Null when no skip logic is configured."},"is_draft":{"type":"boolean","description":"Whether this question is a draft (unpublished). Only present when `include_draft=true` is passed."}},"required":["id","text","question_type","is_required","position","answers","include_other_option","other_option_answer_value","slider_config","matrix","skip_logic_id","skip_logic"]},"description":"Screener questions sorted by position (ascending). Changes via PUT are immediately live."}},"required":["questions"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Active quotas reference screener questions that would be removed. Delete or update the quotas first.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteScreener","tags":["Screeners"],"summary":"Delete all screener questions","description":"Removes all screener questions for a project. All quotas for the project are also deleted, since quota criteria reference screener questions.\n\nIdempotent — returns 204 even if no screener questions exist.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"}],"responses":{"204":{"description":"Screener deleted"},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/screener-responses":{"get":{"operationId":"listProjectScreenerResponses","tags":["Responses"],"summary":"List project screener responses","description":"Paginated list of screener question responses across all participants in a project. Optionally filter by participant IDs.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"required":false,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"required":false,"description":"Opaque cursor for the next page","name":"cursor","in":"query"},{"schema":{"type":"string","description":"Filter by participant IDs (comma-separated). Returns screener responses for these participants.","example":"abc123,def456"},"required":false,"description":"Filter by participant IDs (comma-separated). Returns screener responses for these participants.","name":"participant_ids","in":"query"}],"responses":{"200":{"description":"Paginated project screener response list","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"screener_responses":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Unique screener response identifier","example":"cm7g3nq8r0007jm04wv5t2k6s"},"question_text":{"type":"string","description":"The screener question text","example":"Which of the following products have you purchased in the last 3 months?"},"question_type":{"type":"string","description":"Question type (radio, checkbox, sliderScale, singleLineTextbox, multiLineTextbox, matrix)","example":"checkbox"},"response":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"Answer text. For a matrix question, the picked cell as \"Row: Column\"."},"value":{"anyOf":[{"type":"string"},{"type":"number"}]},"is_selected":{"type":"boolean"},"row":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the row of this cell"},"column":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string","description":"The label the participant saw"}},"required":["id","text"],"description":"Matrix questions only: the column of this cell"}},"required":["value"]},"description":"Answer options for this question"},"created_at":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2025-06-01T12:00:00.000Z"},"participant_id":{"type":"string","description":"Participant who submitted this screener response","example":"cm7c4hp5w0003jm04kx8v2n9r"}},"required":["id","question_text","question_type","response","created_at","participant_id"]},"description":"Screener responses sorted by question position (ascending), then by ID (ascending)"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["screener_responses","meta"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/share-links":{"get":{"operationId":"listShareLinks","tags":["Share Links"],"summary":"List share links","description":"Returns a project's share links with fully-resolved settings. Every setting reflects the per-link override falling back to the project value when the link inherits, so the response is always the effective configuration. Includes the participant-facing URL, status, redirects, resolved participant limit, and per-link participant count. Cursor-paginated (default 50 per page); sort with the `?sort` query param.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"required":false,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"required":false,"description":"Opaque cursor for the next page","name":"cursor","in":"query"},{"schema":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"},"required":false,"description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","name":"sort","in":"query"}],"responses":{"200":{"description":"All share links for the project.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"share_links":{"type":"array","items":{"$ref":"#/components/schemas/ShareLinkListItem"},"description":"Share links for the project, each with every setting fully resolved (per-link override falling back to the project value). Sorted per the ?sort query param (default \"created_at:desc\")."},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["share_links","meta"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createShareLink","tags":["Share Links"],"summary":"Create share link","description":"Create a new share link for a project. The name must be unique within the project. A new link inherits every setting from the project until per-link overrides are set via PATCH.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareLinkBody"}}}},"responses":{"201":{"description":"Created share link with resolved settings.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareLinkListItem"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"A share link with this name already exists","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/share-links/{share_link_id}":{"get":{"operationId":"getShareLink","tags":["Share Links"],"summary":"Get share link","description":"Returns a single share link with fully-resolved settings.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Share link identifier."},"required":true,"description":"Share link identifier.","name":"share_link_id","in":"path"}],"responses":{"200":{"description":"Share link detail.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareLinkListItem"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Share link not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteShareLink","tags":["Share Links"],"summary":"Delete share link","description":"Soft-delete a share link. A project must keep at least one live share link, so deleting the last one returns 409.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Share link identifier."},"required":true,"description":"Share link identifier.","name":"share_link_id","in":"path"}],"responses":{"204":{"description":"Share link deleted."},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Share link not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"Cannot delete the last share link","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"updateShareLink","tags":["Share Links"],"summary":"Update share link","description":"Update a share link using JSON Merge Patch (RFC 7396). Omitted fields are unchanged. For per-link settings, a `null` value clears the override so the link inherits the project value; a concrete value sets the override. `first_name_only` has no project value, so it takes `true` or `false`, never `null`, and `true` returns 422 unless the link collects names after the patch. Changing `url_slug` regenerates the participant token, so the previous share URL stops resolving.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Share link identifier."},"required":true,"description":"Share link identifier.","name":"share_link_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateShareLinkBody"}}}},"responses":{"200":{"description":"Updated share link with resolved settings.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareLinkListItem"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Share link not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"A share link with this name already exists","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/survey-responses":{"get":{"operationId":"listSurveyResponses","tags":["Responses"],"summary":"List survey responses","description":"Paginated list of in-call survey responses for a project.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"required":false,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"required":false,"description":"Opaque cursor for the next page","name":"cursor","in":"query"},{"schema":{"type":"string","description":"Filter by participant IDs (comma-separated). Returns survey responses for these participants.","example":"abc123,def456"},"required":false,"description":"Filter by participant IDs (comma-separated). Returns survey responses for these participants.","name":"participant_ids","in":"query"},{"schema":{"type":"string","description":"Filter by questionnaire item ID"},"required":false,"description":"Filter by questionnaire item ID","name":"questionnaire_item_id","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results created after this ISO 8601 datetime","name":"created_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results created before this ISO 8601 datetime","name":"created_before","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results updated after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results updated after this ISO 8601 datetime","name":"updated_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results updated before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results updated before this ISO 8601 datetime","name":"updated_before","in":"query"},{"schema":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"},"required":false,"description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","name":"sort","in":"query"}],"responses":{"200":{"description":"Paginated survey response list","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"survey_responses":{"type":"array","items":{"$ref":"#/components/schemas/SurveyResponseListItem"},"description":"Survey responses sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["survey_responses","meta"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/participants/{participant_id}/transcripts":{"get":{"operationId":"listParticipantTranscripts","tags":["Transcripts"],"summary":"List participant transcripts","description":"Paginated transcript utterances spanning all recordings for a participant. Uses a compound cursor that paginates across recording boundaries. Pass `?full=true` to return all utterances without pagination. Filter by `?speaker_role=participant|moderator` to isolate speakers. Each utterance carries the guide question it was said under (`item_id`), with question windows in `meta.questions`, and whether it belongs to the main question or a follow-up (`turn_kind`, `follow_up_number`); question attribution covers AI-moderated interviews only. Pass `?exclude=questions` to leave it out.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"},{"schema":{"type":"string","description":"Participant identifier"},"required":true,"description":"Participant identifier","name":"participant_id","in":"path"},{"schema":{"type":"integer","exclusiveMinimum":0,"default":500,"description":"Maximum utterances to return (default 500, max 2000)","example":500},"required":false,"description":"Maximum utterances to return (default 500, max 2000)","name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque pagination cursor from a previous response. Encodes position across multiple recordings."},"required":false,"description":"Opaque pagination cursor from a previous response. Encodes position across multiple recordings.","name":"cursor","in":"query"},{"schema":{"type":["boolean","null"],"default":false,"description":"Return all utterances across all recordings with no pagination. Overrides limit and cursor."},"required":false,"description":"Return all utterances across all recordings with no pagination. Overrides limit and cursor.","name":"full","in":"query"},{"schema":{"type":"string","enum":["participant","moderator"],"description":"Filter to only moderator or participant utterances","example":"participant"},"required":false,"description":"Filter to only moderator or participant utterances","name":"speaker_role","in":"query"},{"schema":{"type":"string","description":"Comma-separated sections to leave out. Valid values: questions. `questions` drops `item_id`, `turn_kind`, `follow_up_number`, `has_question_data` and `meta.questions`, and skips the question timing lookup.","example":"questions"},"required":false,"description":"Comma-separated sections to leave out. Valid values: questions. `questions` drops `item_id`, `turn_kind`, `follow_up_number`, `has_question_data` and `meta.questions`, and skips the question timing lookup.","name":"exclude","in":"query"}],"responses":{"200":{"description":"Paginated transcript utterances with recording metadata","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TranscriptListResponse"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Participant not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/projects/{project_id}/recruitment/user-interviews":{"post":{"operationId":"createUserInterviewsRecruitmentOrder","tags":["Recruitment"],"summary":"Create User Interviews recruitment order","description":"Create a pending User Interviews recruitment order for a project. The order contains operator-based targeting criteria and study details.\n\nCall GET /recruitment/user-interviews/options to discover valid characteristic slugs and operators.\n\nMultiple pending orders per project per panel are allowed; each call creates a new draft. Only credits payment is supported.\n\nPer-order `auto_invite` toggles Strella's internal AI auto-approval (screener passers rated 4+ are auto-invited without manual review); omit it to inherit the project's auto-recruit setting. It is NOT the vendor panel's auto-invite: `require_approval` is not accepted and User Interviews orders always send `requireApproval: true`, so participants routed through this endpoint are always held for the panel's manual review before being invited.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Project identifier"},"required":true,"description":"Project identifier","name":"project_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"target_market":{"type":"string","enum":["b2c","b2b"],"description":"Market type: \"b2c\" or \"b2b\".","example":"b2c"},"participant_count":{"type":"integer","minimum":1,"maximum":100,"description":"Number of participants to recruit (1-100).","example":10},"incentive_per_participant":{"type":"integer","minimum":1,"maximum":200,"description":"Per-participant incentive in whole dollars USD ($1-$200).","example":30},"targeting":{"type":"array","items":{"type":"object","additionalProperties":{"anyOf":[{"type":"object","properties":{"$gte":{"type":"integer","minimum":18},"$lte":{"type":"integer","maximum":99}},"required":["$gte","$lte"]},{"type":"object","properties":{"$in":{"type":"array","items":{"type":"string","minLength":1},"minItems":1}},"required":["$in"]},{"type":"object","properties":{"$eq":{"type":"string","minLength":1}},"required":["$eq"]}]}},"default":[],"description":"Targeting criteria. Each element is an object with one key (characteristic slug) and one value (operator object). Call GET /recruitment/user-interviews/options for valid slugs."},"title":{"type":"string","minLength":1,"maxLength":200,"description":"Study title shown to panelists. Used as the base for auto-disambiguation: if the title (or project name when omitted) already exists on another order in this project across either panel, \" 2\", \" 3\", … is appended so the resolved name is unique. The final name is returned in the response `name` field."},"description":{"type":"string","maxLength":2000,"description":"Study description shown to panelists. Defaults to project description."},"preparation_instructions":{"type":"string","maxLength":2000,"description":"Preparation instructions for participants. Defaults to \"This is an AI-led interview.\""},"device_requirements":{"type":"array","items":{"type":"string"},"description":"Required device types (e.g., \"desktop\", \"mobile\")."},"auto_invite":{"type":"boolean","description":"Strella's internal AI auto-approval for this order. When `true`, screener passers rated 4+ by Strella's automated evaluation are auto-invited to interview without manual review. This is NOT the vendor panel's auto-invite: User Interviews orders always send `requireApproval: true` (participants are always held for panel-side manual review). Defaults to the project's auto-recruit setting when omitted.","example":false},"sandbox":{"type":"boolean","description":"Sandbox mode. When true, the order is created with `sandbox: true`, the User Interviews integration is skipped, and a synthetic self-recruited participant + session is generated for end-to-end testing. Equivalent to sending the `X-Sandbox: true` request header.","example":false}},"required":["target_market","participant_count","incentive_per_participant"],"additionalProperties":false}}}},"responses":{"201":{"description":"Pending recruitment order created with full targeting detail.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecruitmentOrderUserInterviewsDetail"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Project not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"422":{"description":"Unprocessable entity","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/recruitment/user-interviews/options":{"get":{"operationId":"getUserInterviewsRecruitmentOptions","tags":["Recruitment"],"summary":"Get User Interviews targeting options","description":"Returns all targeting characteristics for User Interviews recruitment. Closed-set characteristics include their option values inlined under `options[]`; searchable, location, and age-range characteristics omit `options` — use the per-slug search endpoint to look up their values. Also returns per-participant recruitment costs by market type (B2C/B2B).","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Available targeting characteristics and costs for User Interviews recruitment.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserInterviewsOptions"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/recruitment/user-interviews/characteristics/{slug}/options":{"get":{"operationId":"searchUserInterviewsCharacteristicOptions","tags":["Recruitment"],"summary":"Search User Interviews characteristic options","description":"Search vendor option values for a User Interviews characteristic whose values are not inlined on the bulk catalog endpoint. Supported slugs: `multi_select--skill`, `single_select--occupation`, `single_select--country`, `single_select--region`, `single_select--locality`. Inlinable slugs return 404 here — their values already live on `GET /recruitment/user-interviews/options`.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","enum":["multi_select--skill","single_select--occupation","single_select--country","single_select--region","single_select--locality"],"description":"Characteristic slug. Only the slugs whose values are not inlined on `/options` are accepted; inlinable slugs return 404 because their values are already on the bulk catalog.","example":"multi_select--skill"},"required":true,"description":"Characteristic slug. Only the slugs whose values are not inlined on `/options` are accepted; inlinable slugs return 404 because their values are already on the bulk catalog.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1,"maxLength":100,"description":"Search query forwarded to the User Interviews vendor catalog. Trimmed before forwarding; whitespace-only queries are rejected. For searchable slugs (`multi_select--skill`, `single_select--occupation`), the per-slug `minSearchLength` from `/options` applies. For closed-list slugs paginated by this endpoint (country, region, locality), any non-empty query is accepted.","example":"figma"},"required":true,"description":"Search query forwarded to the User Interviews vendor catalog. Trimmed before forwarding; whitespace-only queries are rejected. For searchable slugs (`multi_select--skill`, `single_select--occupation`), the per-slug `minSearchLength` from `/options` applies. For closed-list slugs paginated by this endpoint (country, region, locality), any non-empty query is accepted.","name":"q","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":20,"default":10,"description":"Maximum results to return (1–20, default 10).","example":10},"required":false,"description":"Maximum results to return (1–20, default 10).","name":"limit","in":"query"}],"responses":{"200":{"description":"Matching option values for the requested slug.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserInterviewsCharacteristicSearchResponse"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/workspaces":{"get":{"operationId":"listWorkspaces","tags":["Workspaces"],"summary":"List workspaces","description":"Paginated list of workspaces for the authenticated organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"integer","exclusiveMinimum":0,"maximum":100,"default":50,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","example":50},"required":false,"description":"Page size (1–100, default 50). Values above 100 are capped at 100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Opaque cursor for the next page","example":"dGVzdF9jdXJzb3I"},"required":false,"description":"Opaque cursor for the next page","name":"cursor","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results created after this ISO 8601 datetime","name":"created_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results created before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results created before this ISO 8601 datetime","name":"created_before","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results updated after this ISO 8601 datetime","example":"2025-01-01T00:00:00Z"},"required":false,"description":"Filter results updated after this ISO 8601 datetime","name":"updated_after","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Filter results updated before this ISO 8601 datetime","example":"2025-12-31T23:59:59Z"},"required":false,"description":"Filter results updated before this ISO 8601 datetime","name":"updated_before","in":"query"},{"schema":{"type":"string","default":"created_at:desc","description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","example":"updated_at:desc"},"required":false,"description":"Sort order as \"field:direction\". Supported fields: \"created_at\" (row creation time), \"updated_at\" (last modification time). Direction is \"asc\" or \"desc\". Defaults to newest first (created_at:desc) when omitted.","name":"sort","in":"query"}],"responses":{"200":{"description":"Paginated workspace list","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"type":"object","properties":{"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceListItem"},"description":"Workspaces sorted per the ?sort query param (default \"created_at:desc\"; supports \"created_at\" or \"updated_at\" with \":asc\" or \":desc\")"},"meta":{"type":"object","properties":{"has_more":{"type":"boolean","description":"Whether more items exist beyond this page","example":true},"next_cursor":{"type":["string","null"],"description":"Cursor to fetch the next page, or null if no more pages","example":"dGVzdF9jdXJzb3I"},"total":{"type":"integer","minimum":0,"description":"Total count of items matching the query (across all pages). Omitted when not available (e.g. search endpoints).","example":42}},"required":["has_more","next_cursor"]}},"required":["workspaces","meta"]}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"operationId":"createWorkspace","tags":["Workspaces"],"summary":"Create workspace","description":"Create a new workspace. A URL-safe slug is generated automatically from the name.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceBody"}}}},"responses":{"201":{"description":"Created workspace","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceListItem"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/public/v1/workspaces/{workspace_id}":{"get":{"operationId":"getWorkspace","tags":["Workspaces"],"summary":"Get workspace","description":"Retrieve a single workspace by ID.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Workspace identifier"},"required":true,"description":"Workspace identifier","name":"workspace_id","in":"path"}],"responses":{"200":{"description":"Workspace detail","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceListItem"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Workspace not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"operationId":"deleteWorkspace","tags":["Workspaces"],"summary":"Delete workspace","description":"Soft-delete a workspace. Projects assigned to this workspace are unassigned first (their workspace_id is set to null).","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Workspace identifier"},"required":true,"description":"Workspace identifier","name":"workspace_id","in":"path"}],"responses":{"204":{"description":"Workspace deleted"},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Workspace not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"operationId":"updateWorkspace","tags":["Workspaces"],"summary":"Update workspace","description":"Update a workspace using JSON Merge Patch (RFC 7396). Omitted fields are unchanged. Slug is not updated on rename.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","description":"Workspace identifier"},"required":true,"description":"Workspace identifier","name":"workspace_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceBody"}}}},"responses":{"200":{"description":"Updated workspace","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceListItem"}}}},"400":{"description":"Bad request","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"401":{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"Workspace not found","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"415":{"description":"Unsupported media type","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"429":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Maximum requests allowed in the current window"},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window"},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch (seconds) when the current window resets"},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"500":{"description":"Internal server error","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"504":{"description":"Gateway timeout","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}}},"webhooks":{},"x-tagGroups":[{"name":"Guides","tags":["Quickstart Guide","Setting Up Authentication","Building Data Sync Pipelines","Polling for Changes","Understanding the Data Model","Handling Errors","Setting Up a Project","Managing Recruitment Orders","Accepting and Rejecting Participants","Building Questionnaires","Building Screeners","Managing API Upgrades","Controlling Participant PII Access","Logging and Auditing API Access","Managing Share Links"]},{"name":"API Reference","tags":["Conventions","Projects","Participants","Transcripts","Questionnaires","Screeners","Responses","Recruitment","Quotas","Share Links","Credits","Workspaces"]}]}