# Surveys and delivery rules

A thread waits for an end user to write in. A survey is the other direction: a questionnaire your product owns and puts in front of someone, answered by the same opaque external identifiers that open threads.

A response is not a thread. It has no status, carries no messages, cannot be assigned and notifies nobody — until you deliberately turn one into a thread, which is [escalation](#escalate-a-response) and the one place the two meet.

> **NOTE**
> The survey model and the `support_survey_*` and `support_response_*` tools are available from Support 0.7.0. The two public survey routes are declared from 0.8.0; delivery rules, the five `support_rule_*` tools and the public `GET /rules` route from 0.9.0. Earlier versions answer `404` at the edge, as they do for any undeclared path, so a not-found from an older deployment is not evidence about a survey.

## Lifecycle

A survey is in exactly one of three states:

| State | Editable | Answerable |
|---|---|---|
| `draft` | Yes | No |
| `published` | No | Yes |
| `archived` | No | No |

[`support_survey_publish`](/reference/mcp-tools/support_survey_publish) moves a draft to published, which is what makes it answerable and what **freezes its structure**. Editing is a draft-only operation, because answers are keyed by question id: rewriting the questions underneath collected answers would change what those answers were answering.

Publishing is not one-way. [`support_survey_unpublish`](/reference/mcp-tools/support_survey_unpublish) returns a published survey to draft so it can be edited; it stops accepting responses immediately, and the responses already collected are untouched. A survey that is not published is refused.

[`support_survey_archive`](/reference/mcp-tools/support_survey_archive) switches a survey off for good, from either draft or published. **Archiving is the only terminal state** — there is no way back — and it deletes nothing: every response already collected stays readable.

There is no way to create a survey already published. Publishing runs the structure validator described under [What publication checks](#what-publication-checks), and a create that skipped it would be a second way in.

## The survey object

```json
{
  "id": "9f3c1b02-77ad-4e51-b0c9-5a8e2d4f6017",
  "name": "After your first week",
  "status": "published",
  "questions": [],
  "createdAt": "2026-08-20T09:14:02.331Z",
  "updatedAt": "2026-08-20T09:20:47.882Z"
}
```

`name` is one to 255 characters. It is not only a label for your own list: it becomes the title of the feedback thread if a response is ever escalated, so write it as something a support specialist can read in a queue.

`questions` is the ordered question set, at most 100, returned exactly as stored. Editing it through [`support_survey_update`](/reference/mcp-tools/support_survey_update) **replaces the whole set** rather than patching into it, which keeps the call idempotent and stops two editors interleaving a read-modify-write over an ordered list. Pass `[]` to empty a draft.

[`support_survey_create`](/reference/mcp-tools/support_survey_create) drafts one; [`support_survey_list`](/reference/mcp-tools/support_survey_list) and [`support_survey_get`](/reference/mcp-tools/support_survey_get) read them back, any status included.

## Questions

Each question carries a client-owned `id`, unique within the survey and stable across edits, since answers are keyed by it. `label` is what the respondent reads.

| Type | What it collects | Answer shape |
|---|---|---|
| `reaction` | A free string your client owns — an emoji, typically. | String |
| `text` | Free prose. | String |
| `radio` | One option. | An option `id` |
| `checkbox` | Any number of options. | An array of option `id`s |
| `rating` | An integer on a declared scale. | Integer |
| `nps` | The recommendation question, fixed to 0–10. | Integer |
| `statement` | Nothing. It is displayed, never answered. | None — never send one |

Three properties are type-specific, and offering one where it does not belong is a `400` naming the field:

- **`options`** — `radio` and `checkbox` only. Each option is an `id` and a `label`, both client-owned.
- **`scale`** — `rating` only. `min` is `0` or `1`, the two conventions anyone uses, and `max` is at most `10`. A `rating` question that declares no scale means `{ "min": 1, "max": 5 }`. `nps` is fixed to 0–10 and takes no scale.
- **`skip`** — `radio` only. See [Skip logic](#skip-logic).

`required` marks a question that must be answered, and it is enforced **only when a response is `completed`** — a partial response is kept whatever is missing, because where people stop is evidence. A `statement` may not be required; nothing can answer it.

```json
{
  "id": "q_channel",
  "type": "radio",
  "label": "How did you get started?",
  "required": true,
  "options": [
    { "id": "docs", "label": "Reading the documentation" },
    { "id": "sales", "label": "A call with our team" },
    { "id": "self", "label": "I worked it out myself" }
  ],
  "skip": { "self": "q_blockers" }
}
```

## Skip logic

`skip` maps an **option id** to where answering with that option sends the respondent: another question's `id`, or the literal `"end"` to finish the survey there. It belongs to `radio` questions alone — the branch is per-option, so a type without options has nothing to branch on.

Targets are **forward-only**: a target names a question later in the list, or `end`. A survey can therefore always be walked from the top in one pass, and no answer can send a respondent backwards or into a loop. The rule is not merely "no cycles" — a survey you can walk backwards through is one you can walk forever, and position ordering settles the question outright.

A skipped question is simply absent from `answers`. Absence is not an error and not a distinct "skipped" value: a question never shown and a question left blank both arrive as no key at all. If you need to tell those apart, the survey has to ask.

## What publication checks

Shape is checked whenever a draft is saved; everything cross-question is checked at publication, because a half-written draft is a legitimate draft and refusing to save one would make an editor useless. `support_survey_publish` refuses a survey unless:

- it has at least one question;
- question ids are unique, and so are the option ids within a question;
- every `radio` and `checkbox` question offers options;
- no `statement` is marked required;
- every `skip` names an option the question actually offers, and targets a later question or `end`.

A published survey is therefore already known to terminate, and a client can walk it without defending against a loop.

## Answers

`answers` is an object keyed by question `id` — never by position — so reordering a draft cannot remap an answer. Every key must be a question of this survey and every value must fit that question's type; a value that does not, or an answer naming a `statement`, is a `400`.

## Escalate a response

[`support_response_escalate`](/reference/mcp-tools/support_response_escalate) turns one response into a `feedback` thread so a person can follow up. The thread opens on the product's default open status, titled with the survey's `name`, authored by the same external respondent, and carrying the response metadata plus a `surveyResponseId` key. Its `rating` is set from the response `score` when that fits 0–10 — which is the one place a survey answer becomes the thread field documented in [the rating field](/ship/support/threads-and-messages#the-rating-field).

No message is written. The answers stay on the response, and neither your end user nor an agent typed anything that belongs in a conversation.

A response that was already escalated is refused with a conflict naming the thread it went to, so escalating twice cannot produce two threads.

Escalation is a management-plane operation. It is not on the public perimeter.

## Read responses back

A stored response carries more than it was sent:

```json
{
  "id": "3a7c5e91-2d48-4b06-9f13-8e5a0c7d2b64",
  "surveyId": "9f3c1b02-77ad-4e51-b0c9-5a8e2d4f6017",
  "authorExternalId": "user_8842",
  "answers": { "q_channel": "docs", "q_score": 9 },
  "score": 9,
  "completed": true,
  "metadata": { "locale": "en-GB" },
  "escalatedThreadId": null,
  "createdAt": "2026-08-20T11:31:55.204Z",
  "updatedAt": "2026-08-20T11:31:55.204Z"
}
```

`score` is the answer of the **first** `nps` or `rating` question, raw and never rescaled, or `null` when neither was answered. `null` is not zero — `0` is a real score at the bottom of an NPS scale, exactly as it is for a thread's `rating`.

Because the score is raw, a `rating` question on a 1–5 scale and an `nps` question on 0–10 land in the same field on different scales. If you mix them across surveys, read `score` together with the question it came from.

[`support_response_list`](/reference/mcp-tools/support_response_list) reads one survey at a time — `surveyId` is required, since a page mixing two surveys would carry answers keyed by two different question sets. It filters on `scoreMin` / `scoreMax` (inclusive, and a response with no score matches neither bound), a `from` / `to` creation window, and `completed`. [`support_response_get`](/reference/mcp-tools/support_response_get) reads one.

[`support_survey_stats`](/reference/mcp-tools/support_survey_stats) aggregates a survey over a time window: counts of responses and completions, then per question — option counts for `radio` and `checkbox`, mean and distribution for `rating` and `nps`, an answered count for the rest. For the first `nps` question it also returns promoters (9–10), passives (7–8), detractors (0–6) and the NPS score. **The window defaults to the last 30 days**; omitting `from` does not mean "since the beginning".

## The two public survey routes

Exactly two survey routes are published on the public perimeter, and they are the pair a client's own frontend needs: read the questions, submit the answers. Everything else — creating, editing, publishing, unpublishing, archiving, reading collected responses, statistics and escalation — stays behind the login and answers `404` at the edge like any undeclared path.

Both routes take the product public key and the base path documented in [Public HTTP API](/ship/support/public-api), and follow that page's error shapes, CORS rules and rate limit.

### Access: no tokens

The two routes need the product public key and the `surveyId`, and nothing else. There is no per-respondent token, no signed link, no expiry and no single-use guard.

A `surveyId` is therefore not a secret: it identifies a survey, it does not authorise anyone. And `authorExternalId` is **asserted, not proven** — whatever your backend sends is recorded as the respondent, byte for byte. Nothing checks that the identifier belongs to a real person, that they were invited, or that they have not answered already; Support neither deduplicates responses nor rate-limits them per respondent.

Which is the same shape as the rest of the perimeter. The key stays on your backend, and your backend is what decides who may answer and how often.

### Reading and answering

`GET /surveys/{surveyId}` returns the survey object above, with its whole question set, for a **published** survey only. A draft, an archived survey, an unknown id and a survey of another product are all the same plain `404`, and nothing in the response distinguishes them. Note what that costs you: a `404` here has four possible causes and the response names none, so check the survey's status in the management plane before suspecting the id.

`POST /surveys/{surveyId}/responses` records one response. It is the only survey **write** that reaches the edge — which is why it takes the survey id from the path rather than the body.

```json
{
  "authorExternalId": "user_8842",
  "completed": true,
  "answers": {
    "q_channel": "docs",
    "q_blockers": ["billing", "permissions"],
    "q_score": 9
  },
  "metadata": {
    "pageUrl": "https://shop.example.com/welcome",
    "locale": "en-GB"
  }
}
```

| Field | Rules |
|---|---|
| `authorExternalId` | Required. 1–255 characters. Your own identifier for the respondent, stored verbatim and never resolved, normalised or enriched. |
| `answers` | Required. Keyed by question `id`, each value fitting that question's type. A skipped or blank question is absent. |
| `completed` | Required. `true` when the respondent reached the end, `false` for a partial response. Required questions are enforced only when it is `true`. |
| `metadata` | Optional. Free-form JSON of your own, stored verbatim and never read. |

The request body cap here is **256 KiB**, the raised limit this route shares with `POST /messages`; every other public route is capped at 64 KiB. A long free-text answer is what the extra room is for.

`completed` is the field that keeps your numbers honest. A partial response is a real record — it tells you where people stop — but averaging over partials as though they were finished answers mixes two populations. Post `false` as a respondent moves through a long survey and `true` when they finish, then filter on it when you read the results.

Through this leg an unpublished survey is a `404`, not a conflict: the edge never confirms that a survey it will not serve exists. The response is the stored response object shown above, with `escalatedThreadId` still `null`.

Submitting a response is deliberately not a tool: a response is written by a person filling in a form, so `POST /surveys/{surveyId}/responses` is an HTTP route and nothing else.

## Delivery rules

A survey is a questionnaire. A delivery rule is the answer to *where, when and how often it is shown* — a URL match, a device, a trigger, a frequency and a set of conditions on the visitor, all attached to one survey.

**Nothing in a rule runs here.** Support has no scheduler, no impression log, no per-visitor state and no widget to embed, because there is no visitor on this side to keep state about: the end users of your product are opaque identifiers to Lessly and nothing resolves them. A rule is configuration. Your own runtime — the script on your page, your app — reads the rules that apply to the page it is rendering and decides. See [the client-execution contract](#the-client-execution-contract).

### The rule object

```json
{
  "id": "3f0a5c11-6b2d-4f8e-9a17-2c4e8d3b5a90",
  "surveyId": "9f3c1b02-77ad-4e51-b0c9-5a8e2d4f6017",
  "enabled": true,
  "urlMatch": { "kind": "prefix", "value": "https://shop.example.com/checkout" },
  "device": "mobile",
  "trigger": { "kind": "delay", "seconds": 15 },
  "frequency": { "kind": "until_answered", "cooldownDays": 7 },
  "attributeConditions": [
    { "key": "plan", "op": "eq", "value": "pro" },
    { "key": "trial_ends_at", "op": "exists" }
  ],
  "createdAt": "2026-08-20T09:14:02.118Z",
  "updatedAt": "2026-08-20T09:14:02.118Z"
}
```

| Field | Rules |
|---|---|
| `id` | The rule's own id. |
| `surveyId` | The survey this rule delivers. Required at creation, must belong to your product, and **not editable afterwards**. |
| `enabled` | Whether the rule is live. A rule is created enabled unless you say otherwise. |
| `urlMatch` | Required. Which pages the rule applies to. |
| `device` | `any`, `desktop` or `mobile`. Defaults to `any`. |
| `trigger` | Required. When to show the survey once the page matches. |
| `frequency` | Required. How often the same person may see it. |
| `attributeConditions` | Conditions on the visitor, all of which must hold. Defaults to an empty list, which applies to everyone. |
| `createdAt`, `updatedAt` | ISO-8601 timestamps. |

A rule may be written against a **draft** survey. Writing targeting is not publishing it: the rule simply waits, invisible at the public edge until its survey is published.

### URL matching

`urlMatch` is a `kind` and a `value`, and the value is at most **512 characters**.

| `kind` | Matches when |
|---|---|
| `exact` | The URL is the value, character for character. No case folding and no trailing-slash forgiveness. |
| `prefix` | The URL starts with the value. |
| `contains` | The value appears anywhere in the URL. |
| `regex` | A JavaScript regular expression, applied **unanchored**. |

A `regex` is tested rather than compared against the whole string, so `/checkout` matches any URL containing it. Write `^` and `$` yourself when you mean the ends — silently anchoring the pattern would break every pattern that deliberately does not.

The expression is compiled when the rule is written. A pattern that does not compile is a `400` you read at once, not a rule that quietly never fires. The three literal kinds accept any string at all, including one that looks like a broken pattern: `([` is a perfectly good substring to look for.

### Devices

A rule's `device` says which devices it applies to. `any` is the rule that does not care and is the default.

At the public edge a page reports its own device as `desktop` or `mobile` — never `any`, which is a rule's vocabulary and not a page's. **A page that reports no device gets only `any` rules.** Absence is not a wildcard: a rule naming a device does care, and answering that a mobile rule matches an unknown screen would be inventing a fact about the caller.

### Triggers

`trigger` is one of three shapes, and each carries only its own field. An unexpected field is a `400` rather than a harmless extra — `seconds` on an immediate trigger is a caller who thinks they configured a delay.

| `kind` | Field | Means |
|---|---|---|
| `immediate` | — | Show it as soon as the page matches. |
| `delay` | `seconds`, an integer from 0 to 86 400 | Wait this long after the page matched, at most a day. |
| `event` | `name`, 1–128 characters | Wait for an event in **your own** runtime. |

An `event` name — a checkout completing, a modal closing — belongs to your runtime. Support never sees it, validates it against nothing and matches it against nothing; it is a string handed back to you.

### Frequency

`frequency` is a `kind` and an optional `cooldownDays`, an integer from 1 to 365.

| `kind` | Means |
|---|---|
| `once` | Show it a single time, ever. |
| `until_answered` | Keep showing it until the person responds. |
| `always` | Show it every time the rule matches. |

`cooldownDays` on a `once` frequency is refused with a `400`: once is once ever, and a gap between showings that never come is a misunderstanding rather than a configuration.

Frequency is **enforced by your runtime**, not here. Support keeps no record of who was shown what, so what travels out is the policy; the counting is yours.

### Attribute conditions

`attributeConditions` is a list of at most 20 conditions, and **all** of them must hold — the semantics are AND, and there is no OR.

| `op` | Holds when | `value` |
|---|---|---|
| `eq` | The attribute equals `value` | Required |
| `neq` | The attribute does not equal `value` | Required |
| `contains` | `value` appears in the attribute | Required |
| `exists` | The attribute is present at all | Refused |

A `key` is at most 128 characters and a `value` at most 512. Both are **your own vocabulary**, stored verbatim: Support does not know what `plan` means or which values it takes, never resolves either against a person, and never validates one against anything. They come back exactly as you wrote them.

An empty list is a rule that applies to everyone. Passing `[]` on an update is how you clear a condition set. Like frequency, conditions are evaluated by your runtime — it is the side that holds the visitor.

### Managing rules

| Tool | REST | What it does |
|---|---|---|
| [`support_rule_create`](/reference/mcp-tools/support_rule_create) | `POST /support/rules` | Write a rule against one survey |
| [`support_rule_get`](/reference/mcp-tools/support_rule_get) | `GET /support/rules/{id}` | One rule, with its whole configuration |
| [`support_rule_list`](/reference/mcp-tools/support_rule_list) | `GET /support/rules` | This product's rules, newest first |
| [`support_rule_update`](/reference/mcp-tools/support_rule_update) | `PATCH /support/rules/{id}` | Edit a rule, including switching it off |
| [`support_rule_delete`](/reference/mcp-tools/support_rule_delete) | `DELETE /support/rules/{id}` | Remove a rule for good |

These are the management plane and require a platform login, like every other `support_*` tool. Only the read at the public edge answers a product public key.

Every value on an update **replaces** the old one whole rather than merging into it: a new `attributeConditions` is the complete new set, and a new `urlMatch` is the complete new match. At least one field must be present. `surveyId` is not editable — a rule is the targeting *of* one survey, and moving it to another would silently change what every matching page shows; delete the rule and write the one you meant. Setting `enabled` to `false` is the quiet, reversible way to stop delivering without touching the survey.

`support_rule_delete` is a real delete, deliberately not an archive. A survey is archived because responses were collected against it and would otherwise be left answering nothing; a rule has no such dependants, so the row is removed and cannot be recovered. The survey it targeted is untouched, and a second delete of the same rule is a plain `404`.

`support_rule_list` returns `{ "rules": [...], "limit": …, "offset": … }`, newest first. Its filters:

| Filter | Effect |
|---|---|
| `surveyId` | Only rules delivering this survey |
| `enabled` | `true` for live rules, `false` for switched-off ones |
| `url` | Only rules whose URL match applies to this page URL |
| `device` | `desktop` or `mobile`; without it only `any` rules match |
| `limit`, `offset` | Page size 1–100 (default 25) and how many to skip |

`url` and `device` are honoured on **both** planes rather than being a public-edge special case: an operator asking "which rules fire on this page?" is asking exactly what a page asks. Naming neither leaves every rule standing. `limit` and `offset` are applied **after** matching, so a page of ten is ten rules that apply rather than ten candidates of which two survived.

Nothing on the authenticated plane is hidden by the survey's status: an operator configuring delivery must see a rule before it goes live, so every rule is readable whatever state its survey is in.

### The rule read at the public edge

`GET /rules` is the one rule route on the public perimeter. What separates this leg from the authenticated one is the **survey's status**, not the caller's identity:

| | Authenticated | Public edge |
|---|---|---|
| `url` | Optional | **Required** |
| `enabled` | Filter of your choosing | Forced to `true`; the filter is not honoured |
| Survey status | Any | `published` only |

`url` is required because a page asks which rules apply to *itself*, never for the whole targeting configuration; without it the answer is a `400`. URL and device matching is applied server-side, so a page is answered with its own rules rather than with a map of every campaign the product is running.

A rule is invisible here unless it is `enabled` **and** its survey is `published`. That makes `support_survey_unpublish` the revocation lever for delivery as well as for the survey itself, and `enabled: false` the reversible one for a single rule.

The write half is not published. `POST /rules`, `PATCH /rules/{id}` and `DELETE /rules/{id}` are undeclared at the edge and answer `404` there: a public key may read the targeting, never edit it.

Nothing is stripped on the way out. Unlike an agent profile, which carries a Lessly binding, a rule is your own configuration coming back to you — there is no internal fact to redact.

### The client-execution contract

Support answers *which rules apply to this page*. Everything after that is yours, because everything after that needs the visitor.

**Support decides:**

- which rules belong to the product,
- whether the rule is `enabled` and its survey `published`,
- whether `urlMatch` applies to the `url` you sent,
- whether `device` admits the device you reported.

**Your runtime decides:**

- whether `attributeConditions` hold, against attributes only you have;
- when to show the survey, honouring `trigger` — now, after `seconds`, or on your own named event;
- whether this person may be shown it at all, honouring `frequency` and `cooldownDays` against the per-visitor history only you keep.

A workable loop on a page:

1. `GET /rules?url=…&device=…` with the current page URL and device.
2. Drop the rules whose `attributeConditions` do not hold for this visitor.
3. For each rule that survives, check your own record of what this person has already been shown against `frequency`.
4. Arm the `trigger` — render at once, set a timer for `seconds`, or wait for your event.
5. When it fires, fetch the survey with `GET /surveys/{surveyId}` and render it.
6. Post the answers to `POST /surveys/{surveyId}/responses`, and record locally that this person was shown it.

Fetch the rules for a page once, when it loads, rather than polling: a rule set changes when an operator edits it, which is not on a page's timescale.

### Rule errors

The shared error shapes are in [Tools, SDK and errors](/ship/support/tools-and-errors#errors). What is specific to rules:

| Code | When |
|---|---|
| `400` | The body does not fit — a `regex` that does not compile, a match value over 512 characters, `cooldownDays` on `once`, a `value` on `exists` or missing on `eq` / `neq` / `contains`, an update with no field in it, or `url` missing at the public edge |
| `404` | The rule does not exist, or the rule or the survey belongs to another product |

A survey of another product is **not found** rather than forbidden when you create a rule against it, exactly as everywhere else in Support: "forbidden" would confirm the id is real.

A stored pattern that no longer compiles — one written before its engine changed — matches nothing rather than failing the whole request, so one bad rule never takes down the answer for the rest of the page.

### No webhook fires for a rule

The [outbound webhook vocabulary](/ship/support/webhooks#the-five-events) stays at five events, and no rule change joins it. A rule is configuration your own contour just made through your own call, so a webhook about it would be telling you what you already know. A survey response is the opposite — it arrives through your frontend — and that is why it has an event.

## Where to go next

- [Public HTTP API](/ship/support/public-api): the key, the base path and the error shapes.
- [Threads and messages](/ship/support/threads-and-messages): unsolicited feedback and the `rating` field a response escalates into.
- [Tools, SDK and errors](/ship/support/tools-and-errors): every tool named here.
