# Screening Questions (https://terac.com/docs/developers/guides/screening-questions)

Define screening questions to qualify, disqualify, or collect freeform responses from participants during application.

Screening questions are asked to participants during application. Most are multi-option questions that qualify or disqualify based on the chosen answer (design these so the qualifying answer is not obvious). You can also ask open-ended freeform questions (`pick: "text"`) when you want a written response to review rather than an automatic qualify/reject.

## Example [#example]

```json
[
  {
    "key": "role_type",
    "text": "Which best describes your current role?",
    "pick": "one",
    "answers": [
      { "text": "Individual contributor", "qualify_logic": "may" },
      { "text": "People manager", "qualify_logic": "may" },
      { "text": "Executive / C-suite", "qualify_logic": "may" },
      { "text": "Not currently employed", "qualify_logic": "reject" }
    ]
  },
  {
    "key": "facility_size",
    "text": "What is the approximate total square footage of facilities you currently oversee?",
    "pick": "one",
    "answers": [
      { "text": "I don't manage any facilities", "qualify_logic": "reject" },
      { "text": "Under 10,000 sqft", "qualify_logic": "reject" },
      { "text": "10,000 - 50,000 sqft", "qualify_logic": "may" },
      { "text": "50,001 - 100,000 sqft", "qualify_logic": "may" },
      { "text": "100,001 - 500,000 sqft", "qualify_logic": "must_one_of" },
      { "text": "Over 500,000 sqft", "qualify_logic": "must_one_of" }
    ]
  },
  {
    "key": "team_size",
    "text": "How many direct reports do you have?",
    "pick": "one",
    "answers": [
      { "text": "1-5", "qualify_logic": "may" },
      { "text": "6-15", "qualify_logic": "may" },
      { "text": "16-50", "qualify_logic": "may" },
      { "text": "Over 50", "qualify_logic": "may" }
    ]
  }
]
```

The `key` field is a client-defined identifier for the question. It is required when the question is referenced by a quota.

Whether or not you send one, every question reads back with a `key`, and that same value is returned as `screening_answers[].key` on `GET /submissions/{submissionId}`. That is how you join an answer to the question that produced it. Treat the value as opaque: when you omit `key` we assign one, and the format is not part of the contract.

## Question Fields [#question-fields]

| Field                       | Type    | Required    | Description                                                                                                      |
| --------------------------- | ------- | ----------- | ---------------------------------------------------------------------------------------------------------------- |
| `key`                       | string  | no          | Client-defined identifier. Required when referenced by quotas or display conditions.                             |
| `text`                      | string  | yes         | The question text shown to participants (plaintext).                                                             |
| `question_rich_text`        | string  | no          | Optional Markdown (GFM) display copy. See Rich Text Prompts.                                                     |
| `pick`                      | string  | yes         | Question type: `one`, `any`, `boolean`, `text`, or `grid`.                                                       |
| `answers`                   | array   | conditional | Two or more answer options. Required for choice questions (`one`, `any`, `boolean`); omit for `text` and `grid`. |
| `answers[].text`            | string  | yes         | The answer text shown to participants.                                                                           |
| `answers[].qualify_logic`   | string  | yes         | Qualification behavior: `may`, `must`, `must_one_of`, `reject`, or `review`.                                     |
| `answers[].allow_free_text` | boolean | no          | Reveals an "Other, please specify" write-in box when this answer is selected. Applies to `pick` `one` / `any`.   |
| `min_qualifying`            | integer | no          | Multi-select (`any`) only. Require at least this many `must_one_of` answers (see Qualify Logic).                 |
| `grid`                      | object  | conditional | Required for `pick: "grid"`. Rows, columns, and per-cell qualify logic (see Grid Questions).                     |
| `display_condition`         | object  | no          | Show this question only when earlier answers match (see Ask Only If).                                            |
| `conditional_rules`         | array   | no          | Cross-question consistency / anti-falsification checks that reject or flag for review (see Consistency Checks).  |
| `skip_rules`                | array   | no          | Branching: jump to a later question (or end the screener) when this question's answers match (see Branching).    |
| `allow_paste`               | boolean | no          | Open-ended (`text`) only. Whether participants may paste into the field. Pasting is blocked by default.          |

## Question Types [#question-types]

| `pick`    | Description                                                      |
| --------- | ---------------------------------------------------------------- |
| `one`     | Single select -- participant picks exactly one                   |
| `any`     | Multi select -- participant picks one or more                    |
| `boolean` | Yes / No                                                         |
| `text`    | Open-ended -- participant types a freeform response (no options) |
| `grid`    | Matrix -- one shared set of columns chosen per row               |

## Open-Ended Questions [#open-ended-questions]

Set `pick: "text"` for a freeform response. Open-ended questions have no `answers` and no qualifying logic, so they do not auto-screen participants. Submissions are routed to manual review for you to read the response and approve or reject. Quotas cannot target open-ended questions.

```json
[
  {
    "key": "biggest_challenge",
    "text": "What is the biggest challenge you face managing your facilities today?",
    "pick": "text"
  }
]
```

## Qualify Logic [#qualify-logic]

| Value         | Meaning                                                                                                                     |
| ------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `may`         | Does not affect qualification                                                                                               |
| `must`        | Participant must select this exact answer (valid with `one` or `any`)                                                       |
| `must_one_of` | At least one answer in this any-of group must be selected                                                                   |
| `reject`      | Selecting this disqualifies the participant                                                                                 |
| `review`      | Selecting this routes an otherwise-qualified applicant to manual review instead of auto-inviting (auto-invite studies only) |

> **Note:** On multi-select (`pick: "any"`), `must` means *this exact answer is required*, while `must_one_of` means *at least one of a group*. Use `must` for each individually-required option (e.g. "must have run Google Ads") and `must_one_of` for an any-of group (e.g. "Meta OR TikTok").

> **Single-select collapse:** on `pick: "one"` and `pick: "boolean"` only one answer can be picked, so `may` / `must` / `must_one_of` all persist as the same qualifying disposition (and read back as `must`). Only `reject` and `review` change the outcome on a single-select — use `pick: "any"` when you need a distinct disposition per answer.

On multi-select, `min_qualifying` raises the "at least one of" bar for the `must_one_of` group: with three `must_one_of` answers and `min_qualifying: 2`, a participant must select at least two of the three to qualify. Omitted or `1` keeps the default "at least one of" behavior. It is ignored for other `pick` types.

## Grid Questions [#grid-questions]

Set `pick: "grid"` for a matrix question that asks the same set of choices about several items. Omit `answers` and provide `grid` instead. Per-cell `qualify_logic` uses the same values as answers.

Use `grid.pick` to control how many columns a participant may choose per row: `"one"` (default) is a single column per row, and `"any"` lets them select any number of columns per row (a "select all that apply per row" matrix).

| Field                          | Type   | Required | Description                                                         |
| ------------------------------ | ------ | -------- | ------------------------------------------------------------------- |
| `grid.pick`                    | string | no       | `one` (default, single column per row) or `any` (multiple per row). |
| `grid.rows[].text`             | string | yes      | Row label (e.g. a platform name).                                   |
| `grid.rows[].key`              | string | no       | Stable row key; defaults to `r0`, `r1`, ... Used by `cell_actions`. |
| `grid.columns[].text`          | string | yes      | Column label shared across every row.                               |
| `grid.columns[].value`         | string | no       | Stable column value; defaults to `c0`, `c1`, ...                    |
| `grid.cell_actions[]`          | array  | no       | Per-cell qualify logic. Omit a cell for neutral (`may`).            |
| `cell_actions[].row`           | string | yes      | A row key from `grid.rows`.                                         |
| `cell_actions[].column`        | string | yes      | A column value from `grid.columns`.                                 |
| `cell_actions[].qualify_logic` | string | yes      | `may`, `must`, `must_one_of`, or `reject`.                          |

```json
[
  {
    "key": "platforms",
    "text": "How active are you on each platform?",
    "pick": "grid",
    "grid": {
      "rows": [
        { "key": "youtube", "text": "YouTube" },
        { "key": "tiktok", "text": "TikTok" }
      ],
      "columns": [
        { "value": "active", "text": "Active" },
        { "value": "never", "text": "Never" }
      ],
      "cell_actions": [
        {
          "row": "youtube",
          "column": "active",
          "qualify_logic": "must_one_of"
        },
        { "row": "tiktok", "column": "active", "qualify_logic": "must_one_of" }
      ]
    }
  }
]
```

## Ask Only If (Display Conditions) [#ask-only-if-display-conditions]

Use `display_condition` to show a question only when earlier answers match -- for example, ask a Meta-specific follow-up only if the participant said they use Meta. Each condition references an **earlier** question by its `key` and one of its answers (for a grid source, use `"Row: Column"`). Conditions are combined with `join` (`and` / `or`). A condition that references a later question returns a `BAD_REQUEST` error.

| Field                             | Type   | Required | Description                                                        |
| --------------------------------- | ------ | -------- | ------------------------------------------------------------------ |
| `display_condition.conditions[]`  | array  | yes      | One or more conditions.                                            |
| `conditions[].screening_question` | string | yes      | The `key` of an earlier question.                                  |
| `conditions[].answer`             | string | yes      | An answer of that question. For a grid source use `"Row: Column"`. |
| `conditions[].operator`           | string | no       | `eq` (answered that value, default) or `ne` (did not).             |
| `display_condition.join`          | string | no       | `and` (default) or `or`.                                           |

```json
[
  {
    "key": "platforms",
    "text": "Which platforms do you use?",
    "pick": "any",
    "answers": [
      { "text": "Meta", "qualify_logic": "may" },
      { "text": "TikTok", "qualify_logic": "may" }
    ]
  },
  {
    "key": "meta_spend",
    "text": "What is your monthly Meta ad spend?",
    "pick": "one",
    "answers": [
      { "text": "$10k+", "qualify_logic": "must" },
      { "text": "Under $10k", "qualify_logic": "reject" }
    ],
    "display_condition": {
      "conditions": [
        {
          "screening_question": "platforms",
          "answer": "Meta",
          "operator": "eq"
        }
      ],
      "join": "and"
    }
  }
]
```

## Rich Text Prompts [#rich-text-prompts]

Set `question_rich_text` to render the prompt with Markdown (GitHub-flavored: bold, bullet lists, links). `text` stays required and remains the plaintext source of truth used by the voice agent and search, so keep the two in sync — `question_rich_text` only changes how the prompt is *displayed* to participants.

```json
[
  {
    "key": "compliance",
    "text": "Which compliance frameworks has your team completed in the last 12 months?",
    "question_rich_text": "Which compliance frameworks has your team **completed** in the last 12 months?\n\n- Count only audits that closed\n- Exclude in-progress engagements",
    "pick": "any",
    "answers": [
      { "text": "SOC 2", "qualify_logic": "must_one_of" },
      { "text": "ISO 27001", "qualify_logic": "must_one_of" },
      { "text": "None of these", "qualify_logic": "reject" }
    ]
  }
]
```

## Consistency Checks (Conditional Rules) [#consistency-checks-conditional-rules]

`conditional_rules` catch inconsistent or low-quality answers that per-answer qualify logic can't express on its own — for example, a participant who claims a senior role but selects none of the tasks that role would actually do. When a rule's conditions match, it either **rejects** the participant or **flags the submission for human review**.

Unlike a `display_condition` (which references an *earlier* question to gate visibility), a consistency-check condition may reference any question by `key`, or omit `screening_question` to test an answer on the question the rule lives on (a self-check). Write one all-`and` rule per pattern you want to catch.

| Field                              | Type   | Required | Description                                                                                      |
| ---------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------ |
| `conditional_rules[].conditions[]` | array  | yes      | One or more conditions (up to 26).                                                               |
| `conditions[].screening_question`  | string | no       | The `key` of the question to read. Omit to test an answer on THIS question (a self-check).       |
| `conditions[].answer`              | string | yes      | An answer by its text. For a grid source use `"Row: Column"`.                                    |
| `conditions[].operator`            | string | no       | `eq` (selected that answer, default) or `ne` (did not).                                          |
| `conditional_rules[].join`         | string | no       | `and` (default) or `or`.                                                                         |
| `conditional_rules[].outcome`      | string | yes      | `reject` screens the participant out; `review` flags the submission for a human when it matches. |

```json
[
  {
    "key": "role",
    "text": "What is your role?",
    "pick": "one",
    "answers": [
      { "text": "Store Manager", "qualify_logic": "may" },
      { "text": "Associate", "qualify_logic": "may" }
    ]
  },
  {
    "key": "tasks",
    "text": "Which of these do you do day to day?",
    "pick": "any",
    "answers": [
      { "text": "Approve schedules", "qualify_logic": "may" },
      { "text": "Manage the P&L", "qualify_logic": "may" },
      { "text": "None of these", "qualify_logic": "may" }
    ],
    "conditional_rules": [
      {
        "conditions": [
          { "screening_question": "role", "answer": "Store Manager" },
          { "answer": "None of these", "operator": "eq" }
        ],
        "join": "and",
        "outcome": "review"
      }
    ]
  }
]
```

The second condition omits `screening_question`, so it tests an answer on the `tasks` question the rule lives on: a "Store Manager" who also picks "None of these" is flagged for review.

## Branching (Skip Rules) [#branching-skip-rules]

`skip_rules` let a question jump the participant forward — to a later question, or straight to the end of the screener — based on how they answered it. A rule tests the answers of the question it lives on, and the target must be a *later* question (the screener only walks forward). Not available for `pick: "text"` or `"grid"`.

| Field                             | Type           | Required | Description                                                                      |
| --------------------------------- | -------------- | -------- | -------------------------------------------------------------------------------- |
| `skip_rules[].conditions[]`       | array          | yes      | One or more conditions (up to 26) testing THIS question's answers.               |
| `conditions[].screening_question` | string         | yes      | The `key` of the question this rule lives on.                                    |
| `conditions[].answer`             | string         | yes      | One of that question's answers (its text; `"Yes"`/`"No"` for `pick: "boolean"`). |
| `conditions[].operator`           | string         | yes      | `eq` (answered that value) or `ne` (did not).                                    |
| `skip_rules[].join`               | string         | yes      | `and` or `or`.                                                                   |
| `skip_rules[].target_question`    | string \| null | yes      | `key` of a later question to jump to; `null` ends the screener.                  |

```json
[
  {
    "key": "uses_product",
    "text": "Do you currently use the product?",
    "pick": "boolean",
    "answers": [
      { "text": "Yes", "qualify_logic": "may" },
      { "text": "No", "qualify_logic": "may" }
    ],
    "skip_rules": [
      {
        "conditions": [
          {
            "screening_question": "uses_product",
            "answer": "No",
            "operator": "eq"
          }
        ],
        "join": "and",
        "target_question": null
      }
    ]
  },
  {
    "key": "plan_tier",
    "text": "Which plan are you on?",
    "pick": "one",
    "answers": [
      { "text": "Free", "qualify_logic": "may" },
      { "text": "Pro", "qualify_logic": "may" }
    ]
  }
]
```

A participant who answers "No" skips straight to the end; everyone else continues to the plan-tier question.

## Reading Screen-Out Rates [#reading-screen-out-rates]

`GET /opportunities/{opportunityId}` returns `screening_stats` on a live opportunity: one entry per question, keyed by the same `key` you authored.

```json
{
  "screening_question": "years_experience",
  "graded": 412,
  "rejected": 321,
  "rejection_rate": 0.78,
  "answers": [
    { "text": "0-10", "selected": 91, "share": 0.22 },
    { "text": "Over 10 years", "selected": 321, "share": 0.78 }
  ]
}
```

`graded` counts the finished screeners that were **graded on this question**, which is not the same as how many people answered it. A screener with `skip_rules` or a `display_condition` stops grading at the first failure, so an answer the evaluator never reached carries no verdict and is excluded here.

That distinction is what makes `rejection_rate` useful: it is the share of the population that *could* have been rejected on this question, not the share of everyone who applied. A question late in a branching screener can show a high rate on a small `graded` count, which means it is strict among the few who got that far, not that it is your main bottleneck. Sort by `rejected` when you want the question costing you the most applicants, and by `rejection_rate` when you want the strictest one.

`answers[].share` is out of the same `graded` count, so a lopsided split is the signal that a question is narrower than intended. In the example above, widening the accepted range on `years_experience` is worth more than touching any other question.

## Launching Without a Screener [#launching-without-a-screener]

A screener is recommended, because it decides who reaches the paid task. Some studies need none, for example when the task screens participants itself. For those, your organization can launch opportunities with no `screening_questions` once Terac has enabled it. Ask your Terac contact to turn it on.

With no screener, everyone who matches your filters goes straight to the task, so the launch asks for three things:

1. Every task that delivers the work, such as an interview, activity or file upload, has `review_type: "manual_review"`. Nobody is paid until you approve their submission with `POST /submissions/{submissionId}/approve`, and a `completed` redirect moves a submission into review instead of paying it. A task that only confirms a step, such as signing a document, can stay as it is, as long as one task waits for your review.
2. `customer_screening_review` is `auto_invite`. Holding applicants for review needs screening answers to review them on.
3. The launch says so explicitly:

```json
POST /opportunities/{opportunityId}/launch
{ "without_screener": true }
```

Without `without_screener`, a draft with no screener is refused with `412` and a message naming what to do next. The flag is ignored on an opportunity that has screening questions, and it never launches a screener that nobody can pass.

If Terac runs its AI screening interview on the opportunity, applicants are still screened and reviewed before they reach the task, so only the third item applies.

The dashboard offers the same launch. Its builder asks you to confirm it, sets each **Needs approval** task to `manual_review` as it launches, and keeps applicants on automatic invites while there is no screener. See [Launching Without a Screener](https://terac.com/docs/researchers/opportunities/screening#launching-without-a-screener) in the researcher docs.

When your task screens participants itself, send the `screened_out` redirect for anyone it turns away (see [External Surveys & Redirect URLs](https://terac.com/docs/researchers/integrations/external-surveys)). They are rejected without pay, and their place opens for the next participant.