Skip to content

Surveys and delivery rules

Ask for feedback rather than waiting for it, and say where the question is shown.

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 and the one place the two meet.

Lifecycle

A survey is in exactly one of three states:

StateEditableAnswerable
draftYesNo
publishedNoYes
archivedNoNo

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 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 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, and a create that skipped it would be a second way in.

The survey object

{
  "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 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 drafts one; support_survey_list and 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.

TypeWhat it collectsAnswer shape
reactionA free string your client owns — an emoji, typically.String
textFree prose.String
radioOne option.An option id
checkboxAny number of options.An array of option ids
ratingAn integer on a declared scale.Integer
npsThe recommendation question, fixed to 0–10.Integer
statementNothing. 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:

  • optionsradio and checkbox only. Each option is an id and a label, both client-owned.
  • scalerating 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.
  • skipradio only. See 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.

{
  "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 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.

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:

{
  "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 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 reads one.

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, 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.

{
  "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"
  }
}
FieldRules
authorExternalIdRequired. 1–255 characters. Your own identifier for the respondent, stored verbatim and never resolved, normalised or enriched.
answersRequired. Keyed by question id, each value fitting that question’s type. A skipped or blank question is absent.
completedRequired. true when the respondent reached the end, false for a partial response. Required questions are enforced only when it is true.
metadataOptional. 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 rule object

{
  "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"
}
FieldRules
idThe rule’s own id.
surveyIdThe survey this rule delivers. Required at creation, must belong to your product, and not editable afterwards.
enabledWhether the rule is live. A rule is created enabled unless you say otherwise.
urlMatchRequired. Which pages the rule applies to.
deviceany, desktop or mobile. Defaults to any.
triggerRequired. When to show the survey once the page matches.
frequencyRequired. How often the same person may see it.
attributeConditionsConditions on the visitor, all of which must hold. Defaults to an empty list, which applies to everyone.
createdAt, updatedAtISO-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.

kindMatches when
exactThe URL is the value, character for character. No case folding and no trailing-slash forgiveness.
prefixThe URL starts with the value.
containsThe value appears anywhere in the URL.
regexA 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.

kindFieldMeans
immediateShow it as soon as the page matches.
delayseconds, an integer from 0 to 86 400Wait this long after the page matched, at most a day.
eventname, 1–128 charactersWait 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.

kindMeans
onceShow it a single time, ever.
until_answeredKeep showing it until the person responds.
alwaysShow 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.

opHolds whenvalue
eqThe attribute equals valueRequired
neqThe attribute does not equal valueRequired
containsvalue appears in the attributeRequired
existsThe attribute is present at allRefused

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

ToolRESTWhat it does
support_rule_createPOST /support/rulesWrite a rule against one survey
support_rule_getGET /support/rules/{id}One rule, with its whole configuration
support_rule_listGET /support/rulesThis product’s rules, newest first
support_rule_updatePATCH /support/rules/{id}Edit a rule, including switching it off
support_rule_deleteDELETE /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:

FilterEffect
surveyIdOnly rules delivering this survey
enabledtrue for live rules, false for switched-off ones
urlOnly rules whose URL match applies to this page URL
devicedesktop or mobile; without it only any rules match
limit, offsetPage 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:

AuthenticatedPublic edge
urlOptionalRequired
enabledFilter of your choosingForced to true; the filter is not honoured
Survey statusAnypublished 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. What is specific to rules:

CodeWhen
400The 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
404The 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 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

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect