Labels and attachments
Label a thread, upload a file for a message to carry, and delete files no message carries.
Use labels to organize your queue. To include a file in a conversation, reserve it, upload it to storage, confirm it, then attach it to a message. Delete files that no message carries one at a time or per thread.
Before you start
- A thread in your product, created with
support_thread_create. - A platform identity with product-scoped write access for the changes and read access for verification. MCP tokens must grant the relevant operations.
- An HTTP client that can
PUTraw bytes with the required headers.
The example adds a screenshot to a shop customer’s ticket. Replace abbreviated thread and attachment ids, file details, and example label values with your own.
Labels
A label is a tag with no built-in automation or reporting behavior. Threads can carry multiple labels.
- Create a label with
support_label_create. For example:
{ "name": "billing", "color": "#2563eb" }name must contain 1–100 characters and be unique within the product. A duplicate returns a conflict. color is optional and accepts a six-digit hex string; unlike a status, a label can have no color.
- Save the returned label id. Verify it with
support_label_get, or find it insupport_label_list, ordered by name. A product with no labels returns an empty list; none are seeded.
Labels cannot be renamed, deleted, or archived. Choose names you can continue using.
Put labels on a thread
- Send up to 50
labelIdswhen creating the thread withsupport_thread_create, or updating it withsupport_thread_update. All labels must belong to the product. An invalid id returns not found and rejects the whole call.
labelIds replaces the entire set. Include the existing ids when adding another label, or send [] to clear the set. Repeating the same replacement leaves the same label set.
Check the result. Read the thread and confirm its labels. You can also filter support_thread_list by labelIds. A request for both billing and urgent labels matches threads carrying both. Label filters combine with all other queue filters.
Attachments
Files upload directly to storage through a signed URL. Support reserves the file and confirms the upload; it does not receive the upload bytes through its API.
Use support_attachment_create and support_attachment_finalize, or the public POST /attachments and POST /attachments/finalize routes available since Support 0.10.0. Both interfaces use the same attachment records, so you can reserve through one and finalize or attach through the other. Public API calls must come from your backend. See Public HTTP API.
1. Reserve the file
Call support_attachment_create with the thread, the file name, its exact content type and its size in bytes:
{
"threadId": "b50f26b4…",
"fileName": "screenshot.png",
"contentType": "image/png",
"sizeBytes": 184320
}Save the returned attachment id, uploadUrl, and uploadExpiresAt. The upload URL lasts 15 minutes and grants access to that object to anyone holding it. Treat it as a credential.
The limits that apply to this call:
Limit | Value |
|---|---|
sizeBytes | 1 to 26,214,400 bytes (25 MiB), declared up front so an oversized upload is never signed for |
contentType | One of eleven types, as an exact string |
Pending attachments per thread | 20; past that the call is refused with 429 too_many_pending_attachments |
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. Supply the exact string, without parameters such as ; charset=utf-8. Other values are rejected before signing.
The 20-pending limit applies separately to each thread. Reservations stop counting against it when their upload URLs expire.
Support derives the storage location from the product and attachment id. Only the file’s base name is used; any directory portion you send is discarded.
2. Upload the bytes
Send the file bytes to uploadUrl with PUT. This request goes directly to storage.
Two headers are mandatory, and the signature covers both:
Content-Type: image/png
x-goog-content-length-range: 0,26214400Send the exact reserved Content-Type. Always send x-goog-content-length-range: 0,26214400, regardless of this file’s size. That value is the signed size ceiling. Storage rejects a missing or changed signed header.
If storage returns 400 MalformedSecurityHeader, check both upload headers. Calling finalize after a failed upload returns No uploaded file found because storage never accepted the bytes; reserving the attachment alone does not upload it.
3. Confirm the upload
Call support_attachment_finalize with attachmentId. Support checks whether storage has the object and reads its actual size. A missing object leaves the attachment pending. An oversized object is rejected; the stored size replaces the declared size.
Support checks the first 4 KiB against the declared type. An SVG declared as PNG, or markup declared as text/plain, is rejected. The object is deleted from storage, the attachment becomes rejected, and the call returns 400 attachment_rejected: <reason>. Reasons are signature_mismatch, not_utf8, nul_byte, and markup_in_text. This state is terminal: repeating finalize returns the same error. Correct the file, then reserve and upload it again.
Check the result. Finalize returns status: "uploaded", a confirmation timestamp, and a downloadUrl served with the verified type. Repeating finalize on an uploaded attachment returns a fresh download URL without reading storage again.
Pending attachments cannot be included in a message and do not appear in message reads.
4. Attach it to a message
Call support_message_create with up to ten attachmentIds. Each must belong to the message’s thread, be uploaded, and be unclaimed by another message.
Duplicate ids or any invalid attachment reject the whole message. For an attachment-only message, omit body; do not send an empty string. A supplied body must contain 1–50,000 characters.
Check the result. Read the message and confirm each attachment’s fileName, contentType, and sizeBytes. Every read signs a fresh downloadUrl. When a cached download URL expires, read the message again for a new one.
What Support does not do to a file
Support stores file metadata: name, declared type, confirmed size, thread, and owning message. File bytes stay in the product’s platform-provisioned storage bucket and are not written to local disk.
Support does not generate thumbnails, scan for viruses, or extract text. Apply your product’s upload-safety controls before showing files to customers.
The first-4-KiB check verifies that the bytes match the declared type. It does not establish that the content is safe; a valid PNG can pass regardless of its contents.
The limits are 25 MiB per file, ten attachments per message, and 20 pending reservations per thread. Once a message claims a file, the file stays with that message until the message is deleted; see Correct or remove a message.
Delete a file no message carries
A file that was reserved, and perhaps uploaded, but never named by a message is an orphan. From Support 0.21.0 you can delete orphans with support_attachment_delete and support_attachment_purge, which need write access, or with the matching public routes. See Public HTTP API.
Delete one file
Call support_attachment_delete with the attachment id:
{ "id": "0f2b8c4a-91d7-4e63-8a05-7c1e3d9b6f24" }On the public API this is DELETE /attachments/{id}. The response gives the state the attachment was in and whether storage held its file:
{
"id": "0f2b8c4a-91d7-4e63-8a05-7c1e3d9b6f24",
"threadId": "b50f26b4…",
"status": "uploaded",
"objectDestroyed": true
}status is pending, uploaded, or rejected. objectDestroyed is false when storage held no file: a reservation that was never uploaded, or a file that finalize already deleted.
Support removes the file from storage first and the record second, so a download URL issued earlier stops working at once.
Status | Means |
|---|---|
404 | No such attachment, or it belongs to another product. Deleting the same attachment a second time also returns 404 |
409 | attachment_claimed: — a message carries the file. Delete the message instead, with support_message_delete (your own) or support_message_delete_any (admin); that removes its files with it |
503 | attachment_cleanup_failed: — storage could not remove the file. The record is kept and nothing is lost; repeat the call |
The call honours the Idempotency-Key header: a repeat with the same key returns the first 200 again. A 503 is not stored, so a retry runs again.
Delete every orphan of a thread
Call support_attachment_purge with a thread id. It deletes every file of that thread that no message carries, in any state:
{ "threadId": "b50f26b4…" }On the public API this is POST /attachments/purge. The response counts what happened:
{
"threadId": "b50f26b4…",
"deleted": { "pending": 1, "uploaded": 2, "rejected": 0 },
"objectsDestroyed": 3,
"objectsMissing": 0,
"skippedClaimed": 4
}deletedcounts the records removed, by the state they were in.objectsDestroyedcounts the files removed from storage;objectsMissingcounts deleted records whose file storage no longer held or never received.skippedClaimedcounts files that messages carry. Purge leaves them alone; delete their messages, then purge again.
A thread that does not exist or belongs to another product returns 404. A second purge of the same thread returns zeros. If storage fails part-way, the call returns 503 attachment_cleanup_failed:; files already removed stay removed, and repeating the call finishes the rest. Idempotency-Key is honoured here too.
Purge cannot tell a forgotten file from one a customer is attaching right now, and it has no “older than” option. On a thread still in use it can remove that file. The message that names it is then refused with 400 “Attachments not available for this message”, and the file must be uploaded again.
Cleanup Support does on its own
An hourly background job deletes orphans in any state once 24 hours have passed since the later of the reservation and the upload. An orphan is therefore removed between 24 and about 25 hours after its last activity, so an unused reservation does not stay in storage.
A file deleted this way, or by either call above, is removed from the storage bucket at once, and download URLs stop working immediately. The bucket keeps deleted bytes for 7 more days (soft delete), so the bytes are gone for good at most 7 days after deletion.
An end-user erasure request also removes the orphans in that end user’s threads, together with the files their messages carry.
Erase an end user’s files
To remove every file an end user uploaded through Support without erasing the end user, call from your backend:
support_message_delete_anyfor each of the end user’s messages. This removes each message’s files with it.support_attachment_purgefor each of the end user’s threads.
Check the result. skippedClaimed is 0 in every purge response. A non-zero count means a message still carries a file, typically a message from someone else on the same thread. Delete that message too, or decide it is not the end user’s data, and purge again.
A Lepsto erasure request for the end user does steps like these for you.
Where to go next
- Threads and messages: the threads and messages these hang off.
- Public HTTP API: the same handshake as two keyed public routes.
- Tools, SDK and errors: every tool named here, and how it is called.