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

# Survey definition

> The JSON format of a Gleap survey: blocks, answer keys, logic jumps and conditions, endings and hidden fields, with complete examples.

A survey definition is a JSON document. The dashboard editor, the [REST API](/documentation/surveys/rest-api), the [MCP server](/documentation/surveys/mcp) and Kai all read and write this format, and Gleap validates it the same way everywhere.

```json theme={null}
{
  "schemaVersion": 2,
  "format": "card",
  "defaultLanguage": "en",
  "welcome": { … },
  "blocks": [ … ],
  "endings": [ … ],
  "fields": [ … ],
  "showProgress": true,
  "allowSkipBack": true
}
```

## Top level

<ParamField body="schemaVersion" type="2">Always `2`.</ParamField>
<ParamField body="format" type="'card' | 'full'" required>`card` (compact card, bottom sheet on phones) or `full` (Typeform-style full screen). The legacy values `survey` and `survey_full` are deprecated aliases of `card` and `full`.</ParamField>
<ParamField body="defaultLanguage" type="string" required>Locale of the default texts, for example `en`.</ParamField>
<ParamField body="welcome" type="Welcome">Optional welcome screen before the first question — see [Welcome screen](#welcome-screen).</ParamField>
<ParamField body="blocks" type="Block[]" required>The questions in display order. 1–60 blocks.</ParamField>
<ParamField body="endings" type="Ending[]" required>Thank-you screens. 1–10 endings.</ParamField>
<ParamField body="fields" type="HiddenField[]">Hidden fields: values passed in by the SDK, URL parameters or personal links.</ParamField>
<ParamField body="showProgress" type="boolean">Show the progress bar.</ParamField>
<ParamField body="allowSkipBack" type="boolean">Full screen: allow going back to earlier questions.</ParamField>
<ParamField body="theme" type="Theme">The survey's design — see [Theme](#theme). Every field is optional; unset fields use your widget settings.</ParamField>
<ParamField body="sender" type="object">`{ userId? }` — the teammate shown in the card's sender row. Defaults to the project's default sender.</ParamField>

### Localized texts

Every text (`title`, `description`, choice `label`, …) is a map of locale to text:

```json theme={null}
{ "en": "How likely are you to recommend us?", "de": "Wie wahrscheinlich ist es, dass Sie uns weiterempfehlen?" }
```

Titles may contain tokens that are replaced for each respondent: `{{contact.first_name}}`, `{{field.<hidden field key>}}` and `{{answer.<earlier answer key>}}`.

## Theme

`theme` sets the survey's design. Every field is optional: a field you leave out uses your project's widget settings (widget colour, widget logo, widget light/dark setting), so a survey without `theme` looks like your widget.

<ParamField body="brandColor" type="hex">Buttons, selected choices and the progress bar. Default: the widget colour.</ParamField>
<ParamField body="buttonTextColor" type="hex">Text on brand-filled buttons and selected choices. Default: black or white, whichever contrasts better with `brandColor`.</ParamField>
<ParamField body="appearance" type="'auto' | 'light' | 'dark'">Light or dark surfaces. `auto` (default) follows the widget's light/dark setting.</ParamField>
<ParamField body="background" type="'tint' | 'plain' | 'color' | 'image'">The backdrop of the full-screen format and the survey page. `tint` (default) is a light tint of the brand colour, `plain` has no tint, `color` uses `backgroundColor`, `image` uses `backgroundImage`. The card and the bottom sheet always stay a solid light or dark surface.</ParamField>
<ParamField body="backgroundColor" type="hex">Backdrop colour for `background: "color"`.</ParamField>
<ParamField body="backgroundImage" type="string">Backdrop image for `background: "image"`. Must be an `https` URL without quotes, parentheses or whitespace.</ParamField>
<ParamField body="backgroundOverlay" type="number">0–0.8. Black darkening over a colour or image backdrop so text stays readable. Default `0.3` for images, `0` for colours.</ParamField>
<ParamField body="textColor" type="hex">Text on the full-screen and survey page backdrop. Default: picked by contrast — light text on dark colours and images.</ParamField>
<ParamField body="corners" type="'sharp' | 'rounded' | 'pill'">Corner radius of choices, buttons and inputs. Default `rounded`.</ParamField>
<ParamField body="logoUrl" type="string">`https` URL of the logo. Default: the widget logo.</ParamField>
<ParamField body="showLogo" type="boolean">Show the logo — top left in the full-screen format and in the survey page header. Default `true`.</ParamField>

Where each field applies:

| | Card / bottom sheet | Full screen | Survey page |
| - | - | - | - |
| `brandColor`, `buttonTextColor`, `corners`, `appearance` | ✓ | ✓ | ✓ |
| `background`, `backgroundColor`, `backgroundImage`, `backgroundOverlay`, `textColor` | — | ✓ | ✓ |
| `logoUrl`, `showLogo` | — | ✓ | ✓ |

A brand colour, a photo backdrop darkened for legibility and pill-shaped choices:

```json theme={null}
"theme": {
  "brandColor": "#0f766e",
  "background": "image",
  "backgroundImage": "https://cdn.example.com/surveys/forest.jpg",
  "backgroundOverlay": 0.4,
  "corners": "pill"
}
```

Colours are hex (`#0f766e`, `#fff`, or with alpha). An invalid `backgroundImage` or `backgroundColor` falls back to `tint`.

### Resolved theme

The SDK and the survey page load a survey through `GET /surveys/{id}` and `GET /surveys/page/{slug}`. Both return `theme` with the widget defaults already filled in:

```json theme={null}
"theme": {
  "brandColor": "#0f766e",
  "appearance": "auto",
  "autoAppearance": "system",
  "background": "image",
  "backgroundImage": "https://cdn.example.com/surveys/forest.jpg",
  "backgroundOverlay": 0.4,
  "corners": "pill",
  "showLogo": true,
  "logoUrl": "https://cdn.example.com/logo.png",
  "widgetBackgroundColor": "#ffffff",
  "buttonColor": "#0f766e",
  "defaults": { "brandColor": "#485bff", "appearance": "system", "logoUrl": "https://cdn.example.com/logo.png", "backgroundColor": "#ffffff" }
}
```

| Field | |
| - | - |
| `brandColor`, `background`, `backgroundOverlay`, `corners`, `showLogo`, `appearance` | Always present, defaults applied. |
| `autoAppearance` | What `auto` means for this project: `light`, `dark` or `system` (follows the visitor's device). |
| `buttonTextColor`, `textColor` | Only when the survey sets them; otherwise pick by contrast. |
| `backgroundColor`, `backgroundImage` | Only for a `color` / `image` background. |
| `logoUrl` | The survey logo, else the widget logo; absent when `showLogo` is `false` or there is no logo. |
| `widgetBackgroundColor`, `buttonColor` | The widget's own colours, when set. |
| `defaults` | The widget values unset fields fall back to: `brandColor`, `appearance`, `logoUrl?`, `backgroundColor?`. |

## Blocks

<ParamField body="id" type="string" required>Stable id, unique across blocks **and** endings (letters, digits, `_`, `-`; max 40). Jumps point to it.</ParamField>
<ParamField body="key" type="string" required>Stable answer key, unique, `^[a-z][a-z0-9_]{1,40}$` — for example `nps_score`. Webhooks, exports, the API and filters use it. Don't rename it on a live survey.</ParamField>
<ParamField body="type" type="string" required>One of the types below.</ParamField>
<ParamField body="title" type="Localized" required>The question.</ParamField>
<ParamField body="description" type="Localized">Help text under the question.</ParamField>
<ParamField body="required" type="boolean" required>Whether the question must be answered. `statement` blocks are never required; a required `consent` must be ticked.</ParamField>
<ParamField body="defaultValue" type="string | number | boolean | string[]">Preselected answer the question starts with: a choice id (`single`), choice ids (`multi`), a scale value, `true` / `false` (`yesno`) or text. The respondent still confirms it; nothing is saved before. Must be a valid answer of the block (`invalid_default` otherwise).</ParamField>
<ParamField body="jumps" type="Jump[]" required>Logic rules, checked top to bottom; the first match wins. Max 20. `[]` for none.</ParamField>
<ParamField body="next" type="string | null" required>"All other cases go to": a later block id or an ending id. `null` = the next block in order (after the last block, the first ending).</ParamField>

Type-specific options:

| Type | Answer value | Options |
| - | - | - |
| `nps` | number 0–10 | `scaleStyle` (`numbers` / `emoji` / `compact`), `lowLabel`, `highLabel`. `compact` shows six buttons 0–5 that answer 0, 2, 4, 6, 8, 10, so NPS groups and logic stay on 0–10 (the pre-2.0 "modern" NPS) |
| `csat` | number 1–5 | `scaleStyle` |
| `rating` | number `scaleMin`–`scaleMax` | `scaleMin`, `scaleMax`, `scaleStyle` (`stars`) |
| `scale` | number `scaleMin`–`scaleMax` | `scaleMin`, `scaleMax`, `scaleStyle`, `lowLabel`, `highLabel` |
| `single` | choice id, or `{ "other": "…" }` | `choices` (max 25), `allowOther`, `randomize` |
| `multi` | array of choice ids (plus `{ "other": "…" }`) | `choices`, `allowOther`, `randomize`, `minSelect`, `maxSelect` |
| `yesno` | `true` / `false` | — |
| `short`, `long` | string | `placeholder`, `maxLength` |
| `email` | string | `placeholder` |
| `number` | number | `placeholder` |
| `date` | `YYYY-MM-DD` | — |
| `upload` | array of file URLs | `accept` (file types like an `<input accept>`: `.png,.jpg,application/pdf`, `image/*`) |
| `statement` | — (no answer) | — |
| `consent` | `true` (ticked) | `linkUrl` (https, the policy), `linkLabel` (its text, default "privacy policy"). `title` is the checkbox text; leave it empty to show the widget's "I have read and accept the privacy policy." in the respondent's language. Logic only on `answered` / `skipped` |

Choices are `{ "id": "too_expensive", "label": { "en": "Too expensive" } }`. Choice ids are unique within the block; answers and conditions use the id, never the label. Any block can also carry `media: { type: 'image' | 'video', url }`.

## Logic: jumps and conditions

A jump is `{ "if": <condition>, "go": "<block id or ending id>" }`. `go` must point to a **later** block or to an ending — jumps never go backwards, so there are no loops.

Where a person goes after answering:

1. the first jump whose condition matches,
2. otherwise `next`,
3. otherwise the next block in order,
4. after the last block, the first ending.

### Conditions on the answer

| Condition | Use with |
| - | - |
| `{ "src": "answer", "op": "eq", "value": "too_expensive" }` | `single` (choice id), `yesno` (`true`/`false`), numbers |
| `{ "src": "answer", "op": "neq", "value": … }` | same as `eq` |
| `{ "src": "answer", "op": "between", "value": [0, 6] }` | `nps`, `csat`, `rating`, `scale`, `number` — inclusive |
| `{ "src": "answer", "op": "includes", "value": "slack" }` | `multi` (choice id) |
| `{ "src": "answer", "op": "answered" }` / `"skipped"` | any optional question |

### Conditions on the person

| Condition | Meaning |
| - | - |
| `{ "src": "contact", "key": "plan", "op": "eq", "value": "pro" }` | Contact property. `op`: `eq`, `neq`, `contains`, `exists`. |
| `{ "src": "field", "key": "source", "op": "eq", "value": "newsletter" }` | Hidden field (must be listed in `fields`). |
| `{ "src": "url", "op": "contains", "value": "/billing" }` | Page URL the survey is shown on. `op`: `contains`, `eq`. |

The editor lists person conditions before answer conditions; put them first when you write definitions yourself.

## Welcome screen

`welcome` adds an intro screen before the first question, like Typeform's. It is not a block: it has no answer key, no logic and does not count as a question. In full screen and on the survey page it shows centred, with the optional image, a large title, the description, the Start button ("press Enter ↵") and the time estimate. The card and the phone bottom sheet show a compact version (title, description, Start). People who come back to finish a response they started skip it.

<ParamField body="enabled" type="boolean" required>`true` shows the welcome screen. `false` keeps the texts without showing them.</ParamField>
<ParamField body="title" type="Localized" required>The heading. Required when `enabled` is `true`; may contain tokens like `{{contact.first_name}}`.</ParamField>
<ParamField body="description" type="Localized">One or two sentences under the title.</ParamField>
<ParamField body="buttonLabel" type="Localized">Start button text. Default "Start".</ParamField>
<ParamField body="media" type="object">`{ type: "image", url }` — an image above the title (full screen and survey page). `https` only.</ParamField>
<ParamField body="showTimeToComplete" type="boolean">Show "Takes about N minutes" (about 15 seconds per question). Default `true`.</ParamField>

```json theme={null}
"welcome": {
  "enabled": true,
  "title": { "en": "Help shape Acme" },
  "description": { "en": "Three quick questions. Your answers go straight to the product team." },
  "buttonLabel": { "en": "Let's go" },
  "media": { "type": "image", "url": "https://cdn.acme.com/survey-hero.png" }
}
```

Use the welcome screen rather than a leading `statement` block: question numbers, the step funnel and "starts" (first answer) stay the same, and the analytics report how many people pressed Start.

## Endings

<ParamField body="id" type="string" required>Unique across blocks and endings.</ParamField>
<ParamField body="title" type="Localized" required>Thank-you text.</ParamField>

<ParamField body="description" type="Localized" />

<ParamField body="button" type="object">`{ label: Localized, url }` — a call to action.</ParamField>
<ParamField body="redirectUrl" type="string">Survey page: redirect after this ending.</ParamField>
<ParamField body="autoCloseSeconds" type="number">In the app: close after N seconds.</ParamField>
<ParamField body="tagContact" type="string">Tag added to the contact who reaches this ending.</ParamField>

## Hidden fields

```json theme={null}
"fields": [{ "key": "plan", "label": "Plan" }, { "key": "source" }]
```

Values come from `showSurvey(id, { fields })`, URL parameters on the survey page (`?plan=pro`) and personal links. They are stored on the response under `fields`.

## Validation

Gleap rejects a definition at publish time when:

* ids or keys are missing, malformed or duplicated (ids are unique across blocks and endings; choice ids within a block),
* a `go` or `next` points to an unknown id or to an earlier block (loop),
* a block can't be reached from the first block, or a path doesn't end in an ending,
* a condition doesn't fit its block type (for example `includes` on a `single` block, `between: [0, 11]` on NPS, or a value condition on a `consent`),
* `scaleStyle: 'compact'` is set on anything but NPS, or a `defaultValue` is not a valid answer of its block,
* a limit is exceeded: 60 blocks, 10 endings, 25 choices per block, 20 jumps per block.

Saving a draft accepts invalid logic and returns the problems as warnings; publishing returns `422` with the same list. Use `POST /validate` to check a definition without saving. Errors look like:

```json theme={null}
{ "ok": false, "errors": [{ "path": "blocks.2.jumps.0.go", "code": "backward_jump", "message": "\"q_reason\" is not after this block" }] }
```

## Example: NPS with follow-ups

Detractors (0–6), passives (7–8) and promoters (9–10) each get their own follow-up question.

```json theme={null}
{
  "schemaVersion": 2,
  "format": "card",
  "defaultLanguage": "en",
  "blocks": [
    {
      "id": "q_nps", "key": "nps_score", "type": "nps", "required": true,
      "title": { "en": "How likely are you to recommend us to a friend?" },
      "lowLabel": { "en": "Not likely" }, "highLabel": { "en": "Very likely" },
      "jumps": [
        { "if": { "src": "answer", "op": "between", "value": [0, 6] }, "go": "q_detractor" },
        { "if": { "src": "answer", "op": "between", "value": [7, 8] }, "go": "q_passive" },
        { "if": { "src": "answer", "op": "between", "value": [9, 10] }, "go": "q_promoter" }
      ],
      "next": null
    },
    { "id": "q_detractor", "key": "detractor_reason", "type": "long", "required": false,
      "title": { "en": "Sorry to hear that. What disappointed you?" }, "jumps": [], "next": "end_thanks" },
    { "id": "q_passive", "key": "passive_improve", "type": "long", "required": false,
      "title": { "en": "What would make it a 10?" }, "jumps": [], "next": "end_thanks" },
    { "id": "q_promoter", "key": "promoter_love", "type": "long", "required": false,
      "title": { "en": "Great! What do you love most?" }, "jumps": [], "next": "end_thanks" }
  ],
  "endings": [{ "id": "end_thanks", "title": { "en": "Thanks for your feedback!" } }],
  "fields": [],
  "showProgress": true,
  "allowSkipBack": true
}
```

## Example: cancellation survey

People who pick "Too expensive" are asked what price would have worked; everyone else goes straight to "What could we have done better?". People who agree to be contacted are tagged.

```json theme={null}
{
  "schemaVersion": 2,
  "format": "full",
  "defaultLanguage": "en",
  "blocks": [
    {
      "id": "q_reason", "key": "cancel_reason", "type": "single", "required": true,
      "title": { "en": "What's the main reason you're leaving?" },
      "choices": [
        { "id": "too_expensive", "label": { "en": "Too expensive" } },
        { "id": "missing_features", "label": { "en": "Missing features" } },
        { "id": "switching", "label": { "en": "Switching to another tool" } },
        { "id": "not_using", "label": { "en": "Not using it enough" } }
      ],
      "allowOther": true,
      "jumps": [{ "if": { "src": "answer", "op": "eq", "value": "too_expensive" }, "go": "q_price" }],
      "next": "q_better"
    },
    { "id": "q_price", "key": "fair_price", "type": "number", "required": false,
      "title": { "en": "What monthly price would have worked for you?" }, "jumps": [], "next": "q_contact" },
    { "id": "q_better", "key": "do_better", "type": "long", "required": false,
      "title": { "en": "What could we have done better?" }, "jumps": [], "next": null },
    {
      "id": "q_contact", "key": "may_contact", "type": "yesno", "required": true,
      "title": { "en": "May we contact you about this, {{contact.first_name}}?" },
      "jumps": [{ "if": { "src": "answer", "op": "eq", "value": true }, "go": "end_contact" }],
      "next": "end_bye"
    }
  ],
  "endings": [
    { "id": "end_contact", "title": { "en": "Thanks — we'll be in touch." }, "tagContact": "churn-followup" },
    { "id": "end_bye", "title": { "en": "Thanks for being a customer." } }
  ],
  "fields": [],
  "showProgress": true,
  "allowSkipBack": true
}
```

## Responses

Answers are stored keyed by the block's `key`:

```json theme={null}
{
  "id": "rsp_4kq9X2mT7vB1nR8sLp3Z",
  "surveyId": "q7x2kd",
  "version": 3,
  "status": "completed",
  "answers": { "cancel_reason": "too_expensive", "fair_price": 49, "may_contact": true },
  "path": ["q_reason", "q_price", "q_contact", "end_contact"],
  "endingId": "end_contact",
  "channel": "web",
  "fields": { "plan": "pro" },
  "language": "en",
  "startedAt": "2026-10-06T09:12:03.000Z",
  "completedAt": "2026-10-06T09:12:41.000Z"
}
```

`channel` is one of `web`, `ios`, `android`, `page`, `email`, `embed`, `conversation`, `api`.


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