Threads and messages
One conversation, its opaque external author, and the entries that make it up.
A thread is one conversation with one of your end users; a message is one entry in it. Everything else in Support — statuses, labels, agents, attachments — exists to describe a thread or to ride on a message.
Opening a thread
support_thread_create takes the conversation and nothing else:
{
"type": "ticket",
"title": "Cannot upload my avatar",
"authorExternalId": "user_8842",
"authorDisplay": "Alex Fern",
"metadata": { "plan": "team", "appVersion": "4.19.2" },
"labelIds": []
}type is the one classification Support itself understands. A ticket is an end user asking for something to be fixed; feedback is unsolicited input that nobody is waiting on an answer to. Nothing automates off the distinction — it exists so a queue can be split — and it cannot be changed once the thread exists.
title is a short subject, one to five hundred characters. It is what a specialist reads in the queue and what a notification quotes, so it is required even for feedback.
metadata is a free-form JSON object that belongs entirely to you: plan, app version, device, the id of the order the complaint is about. Support stores it and gives it back, and reads nothing in it. Threads filed by a feedback widget fill it by a convention of four keys — see The four conventional keys below.
rating is a field of the thread rather than a key inside metadata, which is what makes it filterable: an optional integer from 0 to 10, null when unset. See The rating field.
labelIds puts tags on the thread as it opens. Every id must name a label of this product; one that does not is reported as not found and the thread is not created. At most fifty ids.
Two things you cannot pass. There is no status argument — a thread always opens on the product’s default open status, the non-archived open-category status with the lowest display order. And there is no priority argument: a new thread is normal, and moving it to low, high or urgent is an edit.
The external author
authorExternalId is the whole of what Support knows about the person who wrote in. It is an opaque identifier from your own user base — a row id, a customer number, whatever you already use — and it is stored byte for byte as it arrived. Support does not resolve it, does not normalise it, does not check that it looks like anything, and calls nobody to enrich it. There is no end-user directory here and no login for an end user to have.
authorDisplay is the optional name you already know that person by, stored the same way. It is what a specialist sees at the top of the thread and what a new-thread notification says “From …” with. There is no field for an email address, a phone number or any other contact detail, and that is deliberate: Support never contacts your end users, so it has no use for a way to reach them. Reaching them is your product’s job — see Notifications.
Because the author is an identifier you supply, a thread is opened by your backend, never by an end user’s browser: there is no unauthenticated route to open one on. Filtering the queue by authorExternalId matches verbatim, which is what makes “every thread this customer ever opened” a single call.
Editing a thread
support_thread_update covers the thread’s own fields — title, priority, labelIds and metadata — and at least one of them must be present.
labelIds and metadata both replace rather than merge. Passing three label ids leaves the thread carrying exactly those three; passing [] strips every label. Passing a metadata object replaces the whole object. That makes the call idempotent and leaves no window for two callers doing read-modify-write over the same thread to lose each other’s work.
Two things this tool deliberately cannot do. Moving the thread to another status is support_thread_status_set, and putting an agent on it is support_thread_assign — each carries rules of its own (an archived status is refused, a deactivated agent is refused), and a second way in would be a second place to forget them.
Reading threads back
support_thread_get returns one thread with its status joined in. A thread that belongs to another product is reported as not found, never as forbidden.
support_thread_list is the queue, and every filter lives on it: type, statusId, status category, assignedAgentId, authorExternalId, the createdFrom / createdTo window, the ratingMin / ratingMax bounds, and labelIds. Filters combine by narrowing, labelIds included — asking for two labels returns the threads carrying both, not either.
Pages are newest first, up to 100 threads at a time and 25 by default, walked with offset. sort chooses the column: created is newest by creation time, updated is most recently modified first, which is the order a live queue wants.
Every thread comes back with its status object, its labels by name, its assignedAgentId or null, its rating or null, your metadata, and lastReplyAt — the time of the last public message, or null while there are none. Internal notes do not move it, on purpose: the field answers “when did this person last hear something”, so a note to a colleague must not make a neglected thread look answered.
Messages
support_message_create posts one entry:
{
"threadId": "b50f26b4…",
"direction": "outbound",
"authorAgentId": "13c893aa…",
"body": "Thanks for the report. Which browser are you on?",
"visibility": "public"
}A message has three independent axes and one hard rule.
Direction is where it came from: inbound from the end user, outbound from support.
Visibility is who may see it. public is part of the conversation the end user sees; internal is a note between specialists that never leaves the support contour. It defaults to public when omitted, so an internal note is always a deliberate act. Note that “the end user sees it” describes your own product’s rendering: Support shows nobody anything, it only tells you which messages are showable.
Authorship is the hard rule. Exactly one of authorExternalId and authorAgentId is set — never both, never neither — and the two are different universes of identifier: the first is your opaque external id, the second is a support agent of this product. A message naming an agent of another product is reported as not found. The rule is enforced both at the input and as a constraint in the database.
The three axes do not derive from one another. An internal note may be inbound as well as outbound — “the customer called and said X” is a real thing to record — and every combination goes in exactly as given.
body is stored verbatim, one to fifty thousand characters, with no trimming and no normalisation.
Posting a public message stamps the thread’s lastReplyAt, and a public inbound message can also move the thread’s status on its own — see Statuses. attachmentIds names files the message carries; see Labels and attachments.
Reading a conversation
support_message_list reads forward — oldest first, because that is how a conversation is read, and unlike the thread queue, which is newest first.
Naming a threadId gives you that conversation. visibility: "public" returns what your end user is allowed to see, which is the filter your own product’s support screen should send; omitting visibility returns public messages and internal notes together, which is what a specialist wants. direction narrows the same way.
Each message comes back with its two author fields — one filled, one null — its direction, its visibility, its creation timestamp, and its attachments, each with a freshly signed download URL. Attachments whose upload was never confirmed are never listed.
Catching up across the whole product
Omit threadId and the same tool answers a different question: every message of this product, in one stream. It is the read a client runs after its own consumer was down and it needs to find out what it missed.
Pass since with the createdAt of the last message you already have — strictly after, so that message does not come back a second time — and then follow nextCursor:
{ "since": "2026-08-19T17:11:32.344Z", "limit": 100 }The response carries nextCursor, an opaque position. Pass it back as cursor to get the next page, and repeat until it comes back null, which means you have caught up. The position names both the timestamp and the message, so a walk neither skips nor repeats a message even when several were posted in the same instant. Never construct a cursor yourself.
offset still works and is still the older, weaker option: rows arriving mid-walk shift every later page, so an offset walk can skip a message. The two paginations cannot be combined — sending cursor and offset together is refused rather than silently resolved.
The product-wide form is the thread-scoped form with one filter removed. It reads exactly the same rows under exactly the same product boundary, so it can never surface a message the thread-scoped read would have hidden.
Feedback threads
A feedback thread is the same thread object as a ticket. There is no second type of record, no separate table and no separate route: what follows is the vocabulary for filling the thread you already know, so that a rating widget in your frontend and a dashboard reading the queue three months later agree on what a thread means.
Two of those things are different in kind. The context keys are a convention inside the free-form metadata object — Support stores them and never looks at them. The rating is a field of the thread, which Support validates and can filter on.
title is required for a feedback thread exactly as it is for a ticket. A widget that collects only a score still has to compose one; “Rated the checkout step 8/10” is a better title than the score alone. Everything else behaves as above: a feedback thread opens on the product’s default open status, at normal priority, unassigned, and it can be labelled, assigned and answered like any other.
The four conventional keys
| Key | What it holds |
|---|---|
pageUrl | The address the end user was on when they wrote. The whole URL, as the browser had it. |
elementRef | Your own stable name for the thing they were rating — a screen, a step, a control. Not a CSS selector or a DOM path, both of which change when the markup does. |
device | Whatever you already know about the client: model, operating system, browser. One human-readable string. |
locale | The language the end user was reading in, as a BCP 47 tag — en-GB, pt-BR. |
This is a convention, not a schema. Support does not validate these keys. It does not reject a thread that omits all four, does not reject one that adds ten keys of its own, does not check that pageUrl is a URL or that locale is a real tag, and does not normalise, trim or lowercase any value. A pageUrl of "not a url" is stored as the string "not a url" and comes back as that string.
So the convention buys you one thing, and it is worth having: every consumer that reads your queue — your own dashboard, an export, a colleague writing a one-off query — finds the same information under the same names. Nothing enforces it, which means it holds only as long as the code that writes it agrees with the code that reads it.
Note the consequence of metadata being opaque. You cannot filter the thread queue by pageUrl, or ask for every thread from one locale; there is no query parameter for a metadata key and there is no index behind one. Metadata is carried, not searched. Anything you need to slice the queue by has to be a field of the thread, which is precisely why the score is one.
The rating field
rating is an optional integer from 0 to 10 inclusive. It is null when unset, and null is what a thread that was never scored reads back as.
It is accepted on thread create on both planes — support_thread_create on the authenticated side, and public POST /threads — and it reads back on every route that returns a thread.
It is create-only. support_thread_update does not accept rating, and there is no other route that changes one: a score is what the end user said at the moment they said it, and a thread’s rating is fixed when the thread opens. Collect the score before you open the thread rather than opening one and scoring it afterwards.
The range is closed and the type is exact. 11, -1, 7.5 and "8" are each a 400 of the validation shape: a map from field name to what is wrong with that field, with no message at the top level. A string that happens to contain a number is not coerced — send 8, not "8".
Zero is a score, not an absence. 0 is the lowest rating an end user can give and it is stored and filtered as such; the way to say “no score” is to omit the field, which leaves it null. Code that treats a falsy rating as missing will silently discard every worst-possible review it is given, which is the one class of feedback nobody can afford to lose.
Support attaches no meaning to the number. It does not decide that 9 and 10 are promoters, it does not compute an average, and it does not move a thread’s status or priority because the score was low. The scale is yours: a five-emoji widget mapping to 0, 2, 5, 8, 10 and an eleven-point NPS question both fit in the same field, and Support cannot tell them apart. Whichever you choose, keep it stable — a thread stores the number, never the scale it came from, so re-mapping your widget silently rewrites the meaning of every thread already collected.
Filtering by rating
The thread list takes two bounds, on both planes:
| Parameter | Rules |
|---|---|
ratingMin | Integer 0–10. Only threads whose rating is at or above it. |
ratingMax | Integer 0–10. Only threads whose rating is at or below it. |
Both bounds are inclusive. Either works alone, and together they are a window: ratingMin=0&ratingMax=6 is every thread scored six or worse. They narrow the queue like every other filter and combine with the rest of them, so “unassigned feedback rated under seven this week” is one call.
An unrated thread matches neither bound. A thread whose rating is null is not above 0 and not below 10 — it is outside the comparison altogether. So ratingMin=0 does not mean “everything”; it means “everything that was scored”, and it silently excludes every ticket anyone opened without a score. To count your unrated threads, ask for the queue without either bound and subtract — there is no filter that selects for the absence of a rating.
A frontend files an emoji rating
The end user taps an emoji on your checkout page. What reaches Support is a feedback thread carrying the score as rating and the circumstances as metadata.
The browser sends the tap to your own backend, and only to your own backend:
await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
score: 8,
step: 'checkout.payment.submit',
pageUrl: window.location.href,
locale: navigator.language,
}),
})Your backend is what holds the product public key, and it is what calls the public edge:
curl -X POST "https://public.lessly.com/{productId}/support/threads" \
-H "Authorization: Bearer lpk_…" \
-H "Content-Type: application/json" \
-d '{
"type": "feedback",
"title": "Rated the checkout step 8/10",
"authorExternalId": "user_8842",
"authorDisplay": "Alex Fern",
"rating": 8,
"metadata": {
"pageUrl": "https://shop.example.com/checkout/payment",
"elementRef": "checkout.payment.submit",
"device": "iPhone 15 Pro / iOS 18.2 / Safari",
"locale": "en-GB"
}
}'The key never goes to the browser, and the two-step shape above is not ceremony. A product public key read out of a page’s network tab opens threads, reads your whole queue and reads your specialists’ internal notes, and the public edge accepts browser requests only from Lessly’s own origins — your application’s origin is deliberately not on that list, so the direct call would fail even if you tried it. See CORS.
authorExternalId is your identifier for the person, taken from the session your backend already authenticated. It is opaque to Support, stored byte for byte, and never resolved or validated — which is the other reason this call belongs on your side, since a browser could claim to be anyone.
metadata comes back exactly as it was sent — same keys, same order, same values, nothing added and nothing dropped. That is the whole contract for it. The thread is now in the queue like any other: a specialist can label it, assign it and answer it, and nothing about having a rating keeps it out of the inbox.
Where to go next
- Statuses: the statuses a thread moves through.
- Agents: the agents who write outbound messages.
- Surveys: asking for feedback rather than waiting for it.
- Tools, SDK and errors: every tool named here, and how it is called.