Public HTTP API
Look up Support public routes, credentials, request fields, and errors.
Your backend can call sixteen Support HTTP routes with a product public key. They cover threads, messages, status and label reads, agent profile reads, attachments and their deletion, published surveys and responses, and matching delivery rules. Keep the key on your backend; your customer-facing app calls that backend.
Assignment, status changes, profile creation and editing, message corrections and deletion, and webhook management require the authenticated Support tools, REST API, or SDK. Those routes return 404 on the public base path because they are not published there.
The public API has no route-discovery endpoint. Use the route list on this page and check the path when you get 404. Successful responses put the documented fields directly in the response body, without a { "success": true, "data": … } wrapper.
Examples follow Alex Fern’s avatar-upload ticket in your shop. Replace all customer values, ids, keys, file details, and example URLs with your own; response examples show relevant fields.
For key creation, scopes, and revocation, see Public access.
Base path
https://public.lepsto.com/{productId}/supportReplace {productId} with your platform product id. Paths below are relative to this base; for example, thread creation is POST https://public.lepsto.com/{productId}/support/threads.
Staging uses public.lepsto.dev with the same path. Keys are environment-specific: a production key returns public_key_invalid in staging. The error does not identify the environment, so check the host as well as the key.
The two contours
Supply your customer’s authorExternalId from your own user base. Support stores the string verbatim without resolving, normalizing, or enriching it, or treating it as a Lepsto identity.
Messages on the public API can be authored by an external customer or an unbound agent profile for a bot or backend. Profiles bound to Lepsto identities must use the authenticated API to reply. See POST /messages.
Authentication
Send your product public key as a bearer credential:
Authorization: Bearer lpk_…The platform creates and manages keys; see Public access. A key is shown in plaintext once and has the prefix lpk_ followed by 32 hexadecimal characters.
For new integrations, use Authorization. X-Public-Key and legacy X-Api-Key are also accepted, in that priority order after Authorization. The first present header is checked. An invalid higher-priority value rejects the request even if a later header contains a valid key.
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 |
At the public edge, 404 means an unknown route, 413 means the body exceeds its cap, and 429 means the IP rate limit was reached. These are separate from key errors.
Edge errors use {"error": {"code": …, "message": …}}. Key-check errors also include step: "public_key_check"; an unknown-route error does not:
{ "error": { "code": "public_key_required", "message": "Unauthorized", "step": "public_key_check" } }{ "error": { "code": "not_found", "message": "Not Found" } }Unknown, mistyped, revoked, and other-product keys share public_key_invalid. The response does not disclose which case applies.
Handle three error shapes:
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 |
Support validation errors contain formErrors and fieldErrors, with no top-level message, error, statusCode, or code. Other Support refusals contain message, a string error, and statusCode, but no code.
Use the body shape to select your error parser. Schema validation returns the field-error shape; an operation refusal uses the status-name shape, including attachment-rejection 400 responses. Do not assume every 400 has field errors.
err.error.code is available only for edge errors. A 403 can come from the edge or Support, so the HTTP status alone does not identify the source.
Key checks run at the edge. After authentication it removes Authorization, X-Public-Key, and X-Api-Key before forwarding the request; Support does not receive or validate the key. Investigate edge authentication first when a rejected call never reaches Support.
Key snapshots refresh roughly every 30 seconds. Creation, scope changes, and revocation can take about that long to apply. Wait for propagation before retesting a changed key.
Never include the key in browser code or requests. Route customer interactions through your backend.
CORS
CORS accepts browser requests only from https://*.lepsto.com and https://*.lepsto.dev. Customer application origins are not allowed. This API is intended for your backend.
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.
{
"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. |
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. |
labelIds | Optional. Up to 50 label ids of this product. |
Creation uses the product’s default open status and normal priority. Neither can be overridden in this request.
Threads require authorExternalId and have no agent-author field. Only messages can name an agent author, under the restrictions below.
Response — 201:
{
"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
}Omitting metadata, rating, and labelIds produces {}, null, and an empty labels array, respectively.
The thread includes the current status object: id, name, color, order, category, and archive flag. assignedAgentId is null until assigned through the authenticated API. lastReplyAt is the newest undeleted public message’s creation time, or null if none exists. Priority is low, normal, high, or urgent.
A label id belonging to another product is reported as not found — see 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. |
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. |
All supplied filters must match.
GET /threads?category=open&sort=updated&limit=25The 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.
GET /threads/b8d662ea-26ef-410f-8912-de406f4498cdA thread belonging to another product is a plain 404.
POST /messages
Posts a message into a thread.
{
"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 | 1–50,000 characters when supplied, stored verbatim. Omit only when the message carries at least one uploaded attachment. An empty string is rejected. |
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. |
Set exactly one of authorExternalId and authorAgentId. Supplying both or neither returns a validation 400 with both names in fieldErrors. Read the field message to distinguish the two cases: it says either …, not both or set either ….
Use authorExternalId for a customer message, with your identifier for the customer who opened the thread.
Use authorAgentId for a bot or backend profile that belongs to the product and has no Lepsto identity binding.
A profile bound to a colleague’s Lepsto identity must reply through the Workspace app or authenticated API. A product public key does not authorize replies under that binding.
Naming a bound profile returns 403 with the agent id in the message:
{
"message": "Agent \"d2548149-398f-4d35-9880-4e078a78876b\" is bound to a Lepsto identity and cannot author messages on the public edge; use an agent with no Lepsto binding, or authorExternalId",
"error": "Forbidden",
"statusCode": 403
}This Support refusal has no code or step. By contrast, the edge’s scope 403 has error.code: "public_key_scope" and error.step: "public_key_check".
The binding restriction applies only to the public API. The authenticated tools can accept the same agent author; success there does not establish that a public-key request will succeed.
Direction and visibility remain independent of author type. Either allowed author can write inbound or outbound, public or internal messages.
Reserve and confirm files with POST /attachments and POST /attachments/finalize before including their ids. Files created through the authenticated API are also eligible: both interfaces use the same records.
Response — 201:
{
"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 public message updates the thread’s lastReplyAt; an internal note does not. A public inbound message also moves a pending-category thread to its default open status. Renaming the status does not affect this rule.
GET /messages
Returns messages oldest first.
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. |
Combining cursor and offset returns validation 400, naming both fields.
The response is { "messages": [...], "limit": …, "offset": …, "nextCursor": … }. For catch-up reads, use since to select messages strictly after a timestamp, then send each nextCursor as cursor until it is null. Cursors distinguish messages at the same timestamp. A timestamp-only restart excludes every message at that time, so retain the cursor when continuing a partial page.
The message has one set author field and one null author field. Confirmed attachments include freshly signed download URLs; unconfirmed files are excluded.
Omitting visibility returns internal notes as well as public messages. Use visibility=public before returning a conversation to your end users. Keep keys with this read access on your backend.
POST /attachments
Reserves a file for a thread and returns a signed upload URL. Upload bytes directly to storage, then confirm through POST /attachments/finalize. Both public routes are available from Support 0.10.0.
{
"threadId": "b8d662ea-26ef-410f-8912-de406f4498cd",
"fileName": "screenshot.png",
"contentType": "image/png",
"sizeBytes": 184320
}Field | Rules |
|---|---|
threadId | Required. A thread of this product. |
fileName | Required. 1–255 characters. Only the base name reaches the storage key; any directory part is discarded. |
contentType | Required. One of the eleven accepted types, as an exact string — a parameter such as ; charset=utf-8 is refused. |
sizeBytes | Required. Integer, 1 to 26,214,400 (25 MiB). Declared up front so an oversized upload is never signed for. |
The eleven accepted types are image/jpeg, image/png, image/gif, image/webp, image/heic, video/mp4, video/webm, video/quicktime, application/pdf, text/plain and application/zip. Anything else is refused before a URL is signed.
The response contains the attachment, uploadUrl, and uploadExpiresAt. The URL expires after 15 minutes and acts as a bearer credential for that object; protect it like a credential.
A reserved attachment starts pending and has no author. Finalize it before a message claims it. Pending attachments cannot be included in messages and are excluded from message output.
At most 20 live pending reservations are allowed per thread. Exceeding this returns Support’s 429 too_many_pending_attachments refusal shape, without an edge code. Expired reservations stop occupying slots.
A thread belonging to another product is reported as not found.
Uploading the bytes
Upload the bytes with PUT to uploadUrl. This request goes directly to storage, bypassing Support and the public edge.
Include both signed headers. Missing or changed values cause storage to reject the upload:
Content-Type: image/png
x-goog-content-length-range: 0,26214400Content-Type must exactly match the reserved type. Always use the literal x-goog-content-length-range: 0,26214400, regardless of the actual file size; it specifies the signed ceiling.
For 400 MalformedSecurityHeader, check that both headers were sent correctly. A later finalize call can report No uploaded file found because storage rejected the upload. The reservation does not prove that bytes arrived.
POST /attachments/finalize
Checks the stored object and confirms that it can be attached to a message.
{ "attachmentId": "0f2b8c4a-91d7-4e63-8a05-7c1e3d9b6f24" }Support checks that the object exists and is within the size limit, then records the size reported by storage. A missing object leaves the attachment pending.
The first 4 KiB must match the declared content type. A mismatch, such as SVG uploaded as PNG or markup uploaded as text/plain, deletes the object and marks the attachment rejected. The response is 400 attachment_rejected: <reason>, where the reason is signature_mismatch, not_utf8, nul_byte, or markup_in_text.
This check does not scan for viruses or establish that the content is safe. It verifies the declared file type.
A rejected attachment cannot be finalized: retries return the same 400. Correct the file, reserve it again, and upload the replacement.
Success sets status: "uploaded" and returns a downloadUrl using the verified type. The file can then be included in POST /messages. Repeating finalize on an uploaded attachment returns a fresh download URL.
DELETE /attachments/{id}
Deletes one file that no message carries, together with its file in storage. Available from Support 0.21.0. The id is the attachment id from POST /attachments. Use it when a customer removes a file from a message they have not sent, or to erase personal data.
DELETE /attachments/0f2b8c4a-91d7-4e63-8a05-7c1e3d9b6f24{
"id": "0f2b8c4a-91d7-4e63-8a05-7c1e3d9b6f24",
"threadId": "b8d662ea-26ef-410f-8912-de406f4498cd",
"status": "uploaded",
"objectDestroyed": true
}Field | Means |
|---|---|
status | The state the attachment was in: pending, uploaded, or rejected. |
objectDestroyed | true if storage held the file and removed it; false if there was none to remove. |
Support removes the file from storage before the record, so a download URL issued earlier stops working at once.
Status | When |
|---|---|
404 | No such attachment, or it belongs to another product. Deleting the same attachment again also returns 404. |
409 | A message carries the file; the message begins attachment_claimed:. Delete the message instead through the authenticated API, which removes its files. |
503 | Storage could not remove the file; the message begins attachment_cleanup_failed:. The record is kept, and repeating the call is safe. |
The Idempotency-Key header is honoured: a repeat with the same key returns the first 200 again. A 503 is not stored, so a retry runs again.
POST /attachments/purge
Deletes every file of a thread that no message carries, with their files in storage. Available from Support 0.21.0.
{ "threadId": "b8d662ea-26ef-410f-8912-de406f4498cd" }{
"threadId": "b8d662ea-26ef-410f-8912-de406f4498cd",
"deleted": { "pending": 1, "uploaded": 2, "rejected": 0 },
"objectsDestroyed": 3,
"objectsMissing": 0,
"skippedClaimed": 4
}Field | Means |
|---|---|
deleted | Records removed, by the state they were in. |
objectsDestroyed | Files removed from storage. |
objectsMissing | Removed records whose file was already gone or never uploaded. |
skippedClaimed | Files that messages carry. They are left alone; delete their messages and purge again. |
A thread that does not exist or belongs to another product returns 404. A repeat purge returns zeros. A storage failure part-way returns 503 attachment_cleanup_failed:; files already removed stay removed, and repeating the call finishes the rest. Idempotency-Key is honoured.
On a thread still in use, purge can remove a file a customer is attaching at that moment, and there is no “older than” option. The message that names the removed file is then refused with 400 “Attachments not available for this message”; upload the file again. For the full erasure steps, see Labels and attachments.
GET /statuses
Returns the product’s status vocabulary, in display order.
Parameter | Rules |
|---|---|
includeArchived | Boolean. Also return archived statuses. Defaults to false. |
{
"statuses": [
{
"id": "22cb75ad-dca9-427e-a079-380822009205",
"name": "open",
"color": "#2563eb",
"order": 0,
"category": "open",
"archived": false
}
]
}The first read on a new product seeds four statuses: open, pending, resolved, and closed.
Use category for automation and reporting: it is open, pending, resolved, or closed. Names, colors, and order are editable display properties. Archived statuses remain readable but cannot receive threads.
Configure statuses through the authenticated API.
GET /labels
Returns the product’s label dictionary, by name.
{
"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. Use the returned ids in POST /threads as labelIds. Creating labels requires the authenticated API.
GET /agents
Returns agent profiles ordered by display name. Use the id to resolve a message’s authorAgentId into a display name, job title, and avatar.
Parameter | Rules |
|---|---|
includeInactive | Boolean. Also return deactivated profiles. Defaults to false. |
{
"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. Deactivated profiles are excluded by default but remain readable by id so older messages can still identify their authors.
GET /agents/{id}
Returns a profile by id, including deactivated profiles. A profile from another product returns 404.
lesslyUserId is absent here, not null
Authenticated profile reads include lesslyUserId, set to the bound Lepsto identity or null for an unbound profile. Public reads omit the key for every profile.
Do not interpret the missing key as an unbound profile. null has that meaning on the authenticated API, but the public response does not disclose whether a binding exists.
Use the authenticated tools or SDK when you need the binding. This field difference between the two APIs is expected. See Agents.
Writing profiles is not published
Public POST /agents and PATCH /agents/{id} return 404. Create, edit, or deactivate profiles through the Workspace app or authenticated tools.
GET /surveys/{surveyId}
Returns a published survey with its whole question set, for a client to render. Available from Support 0.8.0.
GET /surveys/9f3c1b02-77ad-4e51-b0c9-5a8e2d4f6017A 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.
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.
Submit the respondent’s answers from your backend using the product public key. This operation has no MCP tool or authenticated API route. A request carrying a Lepsto identity returns 401 with error.code: "public_edge_only" before the handler runs; no response is stored.
{
"authorExternalId": "user_8842",
"completed": true,
"answers": { "q_channel": "docs" },
"metadata": { "locale": "en-GB" }
}Key answers by question id and match each question’s type. completed: false allows partial responses; required answers are enforced when it is true. Draft, archived, unknown, and other-product surveys return 404.
The result is the response object directly, without a data wrapper. The body cap is 256 KiB. See survey response fields.
GET /rules
Returns the 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.
GET /rules?url=https%3A%2F%2Fshop.example.com%2Fcheckout&device=mobileQuery | 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 for published surveys are returned. You cannot override this with an enabled filter. Omitting url returns 400.
Support matches URL and device. Your runtime evaluates visitor attributes, runs triggers, and enforces frequency. See The client-execution contract.
Create, update, and delete rules through the authenticated API. Public POST /rules, PATCH /rules/{id}, and DELETE /rules/{id} return 404.
Not found, never forbidden
A record belonging to another product returns not found, without revealing its owner. This also applies to nested references, such as creating a thread with another product’s label.
The response does not distinguish a nonexistent id from an id outside your product.
Where to go next
- Lepsto Support: the model behind these routes.
- Threads and messages: threads, authorship, visibility and the
ratingfield in depth. - Labels and attachments: the upload handshake, the accepted types and what Support does not do to a file.
- Surveys and delivery rules: the survey routes and where a survey is shown.
- Tools, SDK and errors: the full operation surface, including everything not published here.