# Public HTTP API

Twelve HTTP routes your backend can call directly, without a platform login and without the generated SDK. They cover the client-facing half of support: opening a thread on behalf of one of your end users, reading the queue, posting and reading messages, reading the two dictionaries — statuses and labels — that give those threads their vocabulary, reading the agent profiles a reply is signed with, from Support 0.8.0 reading a published survey and submitting a response to it, and from Support 0.9.0 asking which delivery rules apply to a page.

Everything a support specialist does is not on this perimeter. Assignment, moving a thread to a status, creating and editing agent profiles, attachments, and the webhook registry are managed through the `support_*` tools, the `support` namespace of `@lessly/sdk-app`, or the Workspace app — see [Tools, SDK and errors](/ship/support/tools-and-errors). On the public base path those routes are not published at all, so a call to one is a plain `404` and never a `403`. A `404` at the edge means the route is not declared, which is indistinguishable from a mistyped path — so check the path first when a call you expected to work comes back not found.

Which is the thing to hold on to about this perimeter: it is not discoverable, it is documented. Nothing here lists its own routes, and a `404` tells you nothing about whether a route could exist. The twelve below are the whole of it, and this page is where that list lives.

The key these routes need is platform surface: creating, scoping and revoking one is [Give out a product public key](/products/public-access).

## Base path

```text
https://public.lessly.com/{productId}/support
```

`{productId}` is the id of your product on the platform. Every path below is relative to that base, so opening a thread is `POST https://public.lessly.com/{productId}/support/threads`.

Staging is the same shape on `public.lessly.dev`. Keys are per-environment: one created for `public.lessly.com` is unknown on `public.lessly.dev` and reads there as `public_key_invalid`, with nothing in the response naming the environment that answered.

## The two contours

The end user whose request a thread is about belongs to you, not to Lessly. They arrive as `authorExternalId` — an opaque identifier out of your own user base — and are stored byte for byte as you sent them. Nothing resolves, normalises, validates or enriches them, and they are not a Lessly identity. What you read back is what you wrote.

The other contour — the specialists who answer — is all but out of reach here. Nothing on this perimeter assigns a thread, changes its status or creates an agent, and nobody bound to a Lessly identity writes through it. The one crossing is a message authored by an *unbound* agent profile — your bot or your own backend, which has no platform login and therefore no other way in. See [POST /messages](#post-messages).

## Authentication

Send your product public key as a bearer credential:

```text
Authorization: Bearer lpk_…
```

A key is created, scoped and revoked on the platform — see [Give out a product public key](/products/public-access) — and shown in plaintext exactly once. It is `lpk_` followed by 32 hexadecimal characters.

`X-Public-Key` and the legacy `X-Api-Key` are accepted with the same value, so an integration written against either header keeps working. The platform edge reads the first of the three that is present, `Authorization` first, and a wrong key in a higher-priority header is a rejection rather than a reason to look at the next one. `Authorization` is what new code should send.

The key is checked at the platform edge before the request reaches Support:

| Status | `code` | When |
|---|---|---|
| `401` | `public_key_required` | No key was presented |
| `401` | `public_key_invalid` | The key matches no active key of the product in the path — unknown, mistyped, revoked (once the revocation has propagated), or belonging to another product |
| `403` | `public_key_scope` | The key is active but its scope does not cover this route |
| `503` | `public_config_unavailable` | The edge has no key snapshot loaded and fails closed. Retry with backoff |

`404`, `413` and `429` keep their usual public-edge meanings — unknown route, body over the cap, rate limit — and have nothing to do with keys.

The body is `{"error": {"code": …, "message": …}}`. A key outcome carries `"step": "public_key_check"` beside the code and a `404` does not, which is the only machine-readable way to tell "the key did not pass" from "there is no such route":

```json
{ "error": { "code": "public_key_required", "message": "Unauthorized", "step": "public_key_check" } }
```

```json
{ "error": { "code": "not_found", "message": "Not Found" } }
```

Every rejected key is the same `public_key_invalid`, so the surface cannot be used to probe for other products.

That envelope is the edge's. There are three error shapes on this perimeter, and which one you get says where the request stopped and on what:

| Answered by | When | Body |
|---|---|---|
| The edge | The request never reached Support — key, scope, snapshot, unknown route, body cap, rate limit | `{"error": {"code": …, "message": …}}`, with `"step"` on key outcomes |
| Support, validating | Your body or query does not fit the schema — a `400` | `{"formErrors": […], "fieldErrors": {"<field>": ["…"]}}` |
| Support, refusing | The request was well-formed and refused on its merits — `403`, `404`, and the rest | `{"message": …, "error": …, "statusCode": …}`, where `error` is the status name — `"Forbidden"`, `"Not Found"` — and never an object |

The second and third are both Support's, and neither carries a `code`. Note how far apart they are: a validation `400` has no `message`, no `error` and no `statusCode` at all — it is a map from field name to the things wrong with that field. Code that reads `message` on every failure gets `undefined` exactly when the answer is most useful.

The second shape is Support's whole validation layer and not a property of any one route, so every `400` below reads this way. The third is every other refusal Support produces — a `404` for a thread of another product, the `403` under [POST /messages](#post-messages).

So `err.error.code` reads a value only when the edge answered. Branch on the shape rather than on the status: `403` arrives from two of the three.

The four key outcomes are produced by the edge, not by Support. Once the key checks out, the edge deletes the `Authorization`, `X-Public-Key` and `X-Api-Key` headers before passing the request on, so Support never sees a key and never validates one — which is where to look first when a call is rejected without Support having been reached at all.

Key changes reach the edge through a snapshot refreshed roughly every 30 seconds, so a create, a scope change or a revoke lands within about that window rather than at once. Wait out the window before testing a fresh key, rather than retrying it in a tight loop.

This key is a backend credential. Never ship it to a browser. Your product's support screen talks to your backend, and your backend talks to Support.

## CORS

Browser requests are accepted only from `https://*.lessly.com` and `https://*.lessly.dev`. Your own application origins are not on that list, which is deliberate: these routes are for your backend, and the key they need must never reach a browser.

## Body size and rate limits

| Limit | Value |
|---|---|
| Request body, most routes | 64 KiB (65 536 bytes) |
| Request body, `POST /messages` and `POST /surveys/{surveyId}/responses` | 256 KiB (262 144 bytes) |
| Requests | 100 per minute, per calling IP address |

## POST /threads

Opens a thread on behalf of one of your end users.

```json
{
  "type": "ticket",
  "title": "Cannot upload my avatar",
  "authorExternalId": "user_8842",
  "authorDisplay": "Alex Fern"
}
```

| Field | Rules |
|---|---|
| `type` | Required. `ticket` — the end user wants something fixed — or `feedback`, unsolicited input. |
| `title` | Required. 1–500 characters. The subject of the thread. |
| `authorExternalId` | Required. 1–255 characters. Your own identifier for the end user, stored verbatim. |
| `authorDisplay` | Optional. Up to 255 characters. A display name as you already know it. |
| `rating` | Optional. Integer 0–10 inclusive. A score the end user gave, `null` when unset. See [the rating field](/ship/support/threads-and-messages#the-rating-field). |
| `metadata` | Optional. Free-form JSON object of your own, and the four conventional keys a feedback widget fills it with are documented in [Threads and messages](/ship/support/threads-and-messages#the-four-conventional-keys). |
| `labelIds` | Optional. Up to 50 label ids of this product. |

There is no status argument and no priority argument: the thread opens on the product's default open status, at the default priority.

There is no author-agent argument either. On this perimeter a thread is always opened by an end user of yours: `authorExternalId` is required and there is no field naming an agent profile. Only `POST /messages` takes an agent author, and only for some profiles.

Response — `201`:

```json
{
  "id": "b8d662ea-26ef-410f-8912-de406f4498cd",
  "type": "ticket",
  "title": "Cannot upload my avatar",
  "statusId": "22cb75ad-dca9-427e-a079-380822009205",
  "status": {
    "id": "22cb75ad-dca9-427e-a079-380822009205",
    "name": "open",
    "color": "#2563eb",
    "order": 0,
    "category": "open",
    "archived": false
  },
  "priority": "normal",
  "rating": null,
  "authorExternalId": "user_8842",
  "authorDisplay": "Alex Fern",
  "assignedAgentId": null,
  "metadata": {},
  "labels": [],
  "createdAt": "2026-08-19T20:13:00.654Z",
  "updatedAt": "2026-08-19T20:13:00.654Z",
  "lastReplyAt": null
}
```

`metadata`, `rating` and `labelIds` were not sent here, which is why they come back as `{}`, `null` and `[]`.

The status is joined in at read time, so a thread always carries the whole status object — current name, colour, order and category — and not only its id. `assignedAgentId` is `null` while the thread is unassigned, and this perimeter never sets it. `lastReplyAt` is `null` until the thread has a reply. `priority` is one of `low`, `normal`, `high`, `urgent`.

A label id belonging to another product is reported as not found — see [Not found, never forbidden](#not-found-never-forbidden).

## GET /threads

Reads a page of the product's threads, newest first.

| Parameter | Rules |
|---|---|
| `type` | `ticket` or `feedback`. |
| `statusId` | Only threads sitting on this exact status. |
| `category` | `open`, `pending`, `resolved` or `closed`. Resolved through the status dictionary — the category is never stored on the thread. |
| `assignedAgentId` | Only threads assigned to this agent. |
| `authorExternalId` | Only threads opened by this external author, matched verbatim. |
| `ratingMin`, `ratingMax` | Integers 0–10. Only threads whose `rating` is at or above, and at or below. Both bounds are inclusive, and an unrated thread matches neither — see [filtering by rating](/ship/support/threads-and-messages#filtering-by-rating). |
| `createdFrom`, `createdTo` | ISO-8601 timestamps. Threads created at or after, and at or before. |
| `labelIds` | One id, or the parameter repeated. Only threads carrying **all** of them. |
| `sort` | `created` (default) — newest first by creation time — or `updated`, most recently modified first. Both are descending. |
| `limit` | Integer 1–100. Defaults to 25. |
| `offset` | Integer, 0 or more. Threads to skip. |

Every filter narrows the answer; none widens it.

```text
GET /threads?category=open&sort=updated&limit=25
```

The response is `{ "threads": [...], "limit": …, "offset": … }`, each thread the object shown above. `limit` and `offset` echo what was actually applied.

## GET `/threads/{id}`

Reads one thread. The response is the single thread object of `POST /threads`, with its status joined in the same way.

```text
GET /threads/b8d662ea-26ef-410f-8912-de406f4498cd
```

A thread belonging to another product is a plain `404`.

## POST /messages

Posts a message into a thread.

```json
{
  "threadId": "b8d662ea-26ef-410f-8912-de406f4498cd",
  "body": "The upload button does nothing when I pick a PNG.",
  "direction": "inbound",
  "authorExternalId": "user_8842"
}
```

| Field | Rules |
|---|---|
| `threadId` | Required. A thread of this product. |
| `body` | Required. 1–50 000 characters, stored verbatim. |
| `direction` | Required. `inbound` — written by the end user — or `outbound`, going out to them. |
| `authorExternalId` | 1–255 characters. Your own identifier for the end user, stored verbatim. Exactly one author — see below. |
| `authorAgentId` | An agent profile of this product — and here only a bot or backend profile, never one of your colleagues. Exactly one author — see below. |
| `visibility` | Optional. `public` — part of the conversation the end user sees — or `internal`, a note that never leaves the support contour. Defaults to `public`. |
| `attachmentIds` | Optional. Up to 10 ids of files already uploaded and confirmed on the same thread, none of them carried by another message. |

Every message has exactly one author. Set `authorExternalId` or `authorAgentId` — exactly one of the two. A body that sets both, or neither, is a `400` of the validation shape, and both field names appear under `fieldErrors`, each carrying the same sentence. The sentence is what separates the two mistakes: setting both ends `…, not both`, while setting neither reads `set either …`. The status alone does not tell them apart.

`authorExternalId` is the ordinary case on this perimeter: your end user wrote this, and the identifier is the same opaque string the thread was opened with.

`authorAgentId` names an agent profile of your product, and your product has two kinds. A profile can be **tied to one of your colleagues in Lessly** — a person who signs in and answers from the Workspace app. Or it can stand for **a bot or your own backend**, a profile with nobody in Lessly behind it, which Support already treats as an agent that replies like any other.

Only the second kind may author a message here. That profile has no other door: the Workspace app and the `support_*` tools both need someone signed in, and your backend is not signed in as anyone, so the public perimeter is where it writes.

A profile tied to a colleague is refused with a `403`, naming the agent you asked for:

```json
{
  "message": "Agent \"d2548149-398f-4d35-9880-4e078a78876b\" is bound to a Lessly identity and cannot author messages on the public edge; use an agent with no Lessly binding, or authorExternalId",
  "error": "Forbidden",
  "statusCode": 403
}
```

There is no `code` and no `step` here: this refusal comes from Support, not from the edge, so it is the third shape above. The other `403` on this perimeter, `public_key_scope`, is the edge refusing your key before Support is reached — same status, different answerer, and the `step` field is what tells them apart.

Behind a colleague's profile stands a real sign-in, and a key sitting in your backend is not a substitute for it — their replies go out from the Workspace app or the `support_*` tools, under their own name. The refusal belongs to this perimeter, not to the message: the same body, naming the same colleague's profile, is accepted through the Workspace app and the `support_*` tools, so a call that works there is not evidence it works from here.

Direction, authorship and visibility are independent of one another: an internal note may be inbound or outbound, and either author may write either.

Reserving and uploading a file is not on this perimeter, so `attachmentIds` can only name files created and confirmed through the authenticated plane — see [Labels and attachments](/ship/support/labels-and-attachments).

Response — `201`:

```json
{
  "id": "456ae172-3305-4866-9e8c-0d5477fc2b0a",
  "threadId": "b8d662ea-26ef-410f-8912-de406f4498cd",
  "body": "The upload button does nothing when I pick a PNG.",
  "direction": "inbound",
  "authorExternalId": "user_8842",
  "authorAgentId": null,
  "visibility": "public",
  "createdAt": "2026-08-19T20:13:16.381Z",
  "attachments": []
}
```

Posting a message moves the parent thread's `lastReplyAt`. A public inbound message on a thread parked in the `pending` category also pulls it back to the product's default open status — the one automatic transition Support makes, and it reads the category, never the name.

## GET /messages

Reads messages oldest first — a conversation reads forward.

| Parameter | Rules |
|---|---|
| `threadId` | A thread of this product. Omit it to read the messages of every thread — the catch-up read. |
| `direction` | `inbound` or `outbound`. |
| `visibility` | `public` or `internal`. Omit it to get both. |
| `since` | ISO-8601 timestamp with an offset. Only messages created strictly after it. |
| `cursor` | An opaque position from a previous `nextCursor`. Never construct one, and never combine it with `offset`. |
| `limit` | Integer 1–100. Defaults to 50. |
| `offset` | Integer, 0 or more. The older pagination; prefer `cursor`, which cannot drift as messages arrive. |

Passing `cursor` and `offset` together is a `400` of the same validation shape, with both names under `fieldErrors`.

The response is `{ "messages": [...], "limit": …, "offset": …, "nextCursor": … }`. To catch up after your own endpoint was down: pass `since` with the `createdAt` of the last message you already hold, then follow `nextCursor` until it comes back `null`. Paging is stable across messages that share a timestamp, so a walk neither skips a message nor repeats one.

`authorAgentId` names the specialist when one wrote the message, and is `null` when your end user did. `attachments` is empty unless the message carries files; a file whose upload was never confirmed is never listed, and each one that is carries a freshly signed download URL.

This route filters nothing by visibility of its own accord. Omit `visibility` and you get internal notes alongside the public conversation — the caller is your own backend, so it sees your whole product. It also means agent-to-agent notes are readable by whoever holds the key, which is one more reason the key never goes to a browser and never reaches your end users' screens unfiltered.

## GET /statuses

Returns the product's status vocabulary, in display order.

| Parameter | Rules |
|---|---|
| `includeArchived` | Boolean. Also return archived statuses. Defaults to `false`. |

```json
{
  "statuses": [
    {
      "id": "22cb75ad-dca9-427e-a079-380822009205",
      "name": "open",
      "color": "#2563eb",
      "order": 0,
      "category": "open",
      "archived": false
    }
  ]
}
```

Four statuses — `open`, `pending`, `resolved`, `closed` — are what a product starts with. They are seeded on first read, so this route answers with them even for a product whose settings nobody has opened yet.

`name`, `color` and `order` are the customer's to choose; `category` is one of `open`, `pending`, `resolved` and `closed`, and is what automation and reporting read. Build your own screen against the category, not against the name — the name is display text and can be changed at any time. An archived status stays readable so old threads still render, but is not offered as a destination.

The dictionary is configured through the management plane, not here.

## GET /labels

Returns the product's label dictionary, by name.

```json
{
  "labels": [
    {
      "id": "3f1c9b64-2f0a-4d8e-9a71-2c0d5f6b8e10",
      "name": "billing",
      "color": "#f59e0b",
      "createdAt": "2026-08-01T09:12:44.000Z"
    }
  ]
}
```

`color` is a six-digit hex string, or `null` for an uncoloured label. These are the ids `POST /threads` accepts in `labelIds`. Creating a label is management plane, not this perimeter.

## GET /agents

Returns the product's agent profiles, ordered by display name. A message read back from [GET /messages](#get-messages) carries `authorAgentId` and nothing else about its author, and this is the route that turns that id into a name, a job title and a face.

| Parameter | Rules |
|---|---|
| `includeInactive` | Boolean. Also return deactivated profiles. Defaults to `false`. |

```json
{
  "agents": [
    {
      "id": "e8e7f95a-628b-4551-b3b5-db9f95c80b7b",
      "displayName": "Maria",
      "title": "Support engineer",
      "avatarUrl": null,
      "isActive": true,
      "createdAt": "2026-08-18T09:50:56.893Z",
      "updatedAt": "2026-08-18T09:50:56.893Z"
    }
  ]
}
```

`title` and `avatarUrl` are `null` when unset. A deactivated profile is out of the default listing but keeps every message it wrote, which is why it is still readable by id.

## GET `/agents/{id}`

Reads one profile, deactivated ones included — a screen rendering an old conversation has to be able to resolve the author of every message in it. A profile of another product is a plain `404`.

### `lesslyUserId` is absent here, not null

An agent profile has one more field than you see above. On the authenticated plane it carries `lesslyUserId` — the Lessly identity bound to it, or `null` for an unbound profile, a bot or your own backend. Through the public edge that key is **not in the object at all**.

Absent rather than nulled, and the difference is the point. On this shape `null` already means something: an agent nobody in Lessly stands behind — exactly the kind of profile that may [author a message](#post-messages) here. Answering `null` for one of your colleagues would not redact the binding, it would report them as a bot. So the field is omitted, and a bound profile and an unbound one come back the same way.

The binding never leaves the authenticated plane. Reading the same profiles through the `support_*` tools or the generated SDK returns `lesslyUserId` as it always did. If you are comparing the two answers field by field, that one difference is expected — see [Agents](/ship/support/agents).

### Writing profiles is not published

Only the two reads above are on this perimeter. `POST /agents` and `PATCH /agents/{id}` are not declared at the edge and answer `404` there, like any undeclared path. Profiles are created, edited and deactivated by a specialist in the Workspace app or through the `support_*` tools; a public key reads the roster and never edits it.

## GET `/surveys/{surveyId}`

Returns a **published** survey with its whole question set, for a client to render. Available from Support 0.8.0.

```text
GET /surveys/9f3c1b02-77ad-4e51-b0c9-5a8e2d4f6017
```

A draft, an archived survey, an unknown id and a survey of another product are all the same plain `404`. The question set, the seven question types and the skip rules are documented in [Surveys and delivery rules](/ship/support/surveys).

## POST `/surveys/{surveyId}/responses`

Records one response to a published survey. Available from Support 0.8.0, and the only survey write on this perimeter — everything else about surveys stays behind the login.

```json
{
  "authorExternalId": "user_8842",
  "completed": true,
  "answers": { "q_channel": "docs" },
  "metadata": { "locale": "en-GB" }
}
```

`answers` is keyed by question id and each value must fit that question's type. An unpublished survey is a `404` here rather than a conflict. This route carries the raised 256 KiB body cap. Full field rules are in [Surveys and delivery rules](/ship/support/surveys#reading-and-answering).

## GET /rules

Returns the [delivery rules](/ship/support/surveys#delivery-rules) that apply to one page — where, when and how often a published survey should be shown there. Available from Support 0.9.0.

```text
GET /rules?url=https%3A%2F%2Fshop.example.com%2Fcheckout&device=mobile
```

| Query | Rules |
|---|---|
| `url` | **Required here.** The page asking. Only rules whose URL match applies to it are returned. |
| `device` | Optional, `desktop` or `mobile`. Without it only rules targeting `any` device match — absence is not a wildcard. |
| `surveyId` | Optional. Only rules delivering that survey. |
| `limit`, `offset` | Optional. Page size 1–100 (default 25) and how many to skip, applied after matching. |

The response is `{ "rules": [...], "limit": …, "offset": … }`, each rule the whole configuration object.

Only **enabled** rules on **published** surveys are visible through this leg, and `enabled` is not a filter you may choose here — a switched-off rule is not deliverable, so asking for one is not a question this route answers. Omitting `url` is a `400`: a page asks which rules apply to itself, never for the product's whole targeting configuration.

Nothing here executes. The URL and device matching is all Support does; whether the visitor's attributes hold, when to fire the trigger and how often the same person may see the survey are decided by your own runtime — see [the client-execution contract](/ship/support/surveys#the-client-execution-contract).

Writing rules is not on this perimeter. `POST /rules`, `PATCH /rules/{id}` and `DELETE /rules/{id}` answer `404` at the edge, like any undeclared path.

## Not found, never forbidden

Anything belonging to another product is reported as **not found**, and the message never says that it exists somewhere else or who owns it. A thread, a status, a label, an agent — all the same answer, at every depth: a thread created with another product's label is a plain not-found, not a `403`.

"Forbidden" would tell the caller the id is real, which is itself information about another customer's data. Between "there is no such thread" and "there is a thread and it is not yours", Support always says the first, so probing for ids learns nothing.

## Where to go next

- [Lessly Support](/ship/support): the model behind these routes.
- [Threads and messages](/ship/support/threads-and-messages): threads, authorship, visibility and the `rating` field in depth.
- [Surveys and delivery rules](/ship/support/surveys): the survey routes and where a survey is shown.
- [Tools, SDK and errors](/ship/support/tools-and-errors): the full operation surface, including everything not published here.
