Attachment uploads¶
A client normally uploads each file first and then references it when creating the message. The flow has three steps (four for large files):
- Request an upload plan for the files.
- Send each file's bytes with
PUTto its upload URL, or each part to its part URL. - For a multipart upload, complete it.
- Create the message with the returned upload keys.
Small files can instead travel inside the message request itself as multipart/form-data; see Multipart body.
Attachment uploads accept user sessions and bot tokens. The caller needs VIEW_CHANNEL, SEND_MESSAGES and ATTACH_FILES in a guild channel, must not be timed out, and must be a recipient of a private channel.
Size limits¶
The maximum size of one file depends on the uploader's plan and on the guild's boost level. The effective limit is the larger of the two.
| Source | Limit per file |
|---|---|
| Free account | 10 MiB (10485760 bytes) |
| TEBHOS Pro account | 200 MiB (209715200 bytes) |
| Guild boost level 1 | 50 MiB (52428800 bytes), in that guild's channels |
| Guild boost level 2 or higher | 100 MiB (104857600 bytes), in that guild's channels |
Note
The limits block of instance discovery reports a larger max_attachment_file_size. The attachment upload routes enforce the per-account and per-boost limits in this table, so use them when validating files in a client.
A private channel has no boost level, so only the account limit applies there. A file above the limit is rejected with 400 BAD_REQUEST when the plan is requested. A message can carry at most 10 attachments.
Requesting an upload plan¶
POST /channels/{channel_id}/attachments
Requests upload URLs for 1 to 10 files at once.
Rate limit: channel:attachment:upload, 10 per 10 seconds per account and channel.
| Field | Type | Description |
|---|---|---|
attachments |
array[object] | 1-10 entries, one per file |
attachments[].id |
integer or string | A client-chosen ID, echoed back so you can match entries |
attachments[].filename |
string | The file name; must not be empty |
attachments[].file_size |
integer | The exact size in bytes; must be above 0 |
attachments[].content_type? |
string | The MIME type. Defaults to application/octet-stream |
The response is {"attachments": [...]} with one entry per requested file:
| Field | Type | Description |
|---|---|---|
id |
integer or string | The client-chosen ID from the request |
upload_mode |
string | singlepart for a file of at most 10 MiB (10485760 bytes), multipart for a larger file |
filename |
string | The file name from the request |
upload_filename |
string | The upload key. Reference it when creating the message |
upload_url |
string | singlepart only: the URL to PUT the bytes to |
upload_id |
string | multipart only: the multipart upload ID, needed to complete the upload |
part_size |
integer | multipart only: the size of every part except the last |
parts |
array[object] | multipart only: {part_number, upload_url} for each part, numbered from 1 |
file_size |
integer | The declared size |
content_type |
string | The content type to send with the upload |
attachment_id |
string | Same value as upload_filename |
storage_key |
string | The storage path of the file |
url |
string | The public URL the file will have after upload |
curl -X POST https://tebhos.com/api/v1/channels/1489002177550843904/attachments \
-H "Authorization: Bearer $TEBHOS_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"attachments": [{"id": 0, "filename": "photo.png", "file_size": 48213, "content_type": "image/png"}]}'
{
"attachments": [
{
"id": 0,
"upload_mode": "singlepart",
"filename": "photo.png",
"upload_filename": "3f6c2a8e-6d1b-4f0e-9a52-1c7f4b2d9e10",
"upload_url": "https://storage.example.com/attachments/1489002177550843904/1489002299998765056/photo.png?X-Amz-Signature=...",
"file_size": 48213,
"content_type": "image/png",
"attachment_id": "3f6c2a8e-6d1b-4f0e-9a52-1c7f4b2d9e10",
"storage_key": "attachments/1489002177550843904/1489002299998765056/photo.png",
"url": "https://cdn.tebhos.com/attachments/1489002177550843904/1489002299998765056/photo.png"
}
]
}
Part size¶
A multipart plan aims at about 20 parts: the part size is the file size divided by 20, rounded up to a whole MiB, and never below 10 MiB. Part n covers bytes (n - 1) × part_size up to the next part; the last part holds the remainder.
Single-file variant¶
POST /channels/{channel_id}/attachments/upload-url requests a plan for one file with the body {"filename": string, "content_type": string, "size": integer}. It returns attachment_id, upload_url, storage_key, url, expires_in (seconds) and max_size. It shares the same bucket and limits.
Transferring the bytes¶
Send a PUT request to upload_url with the file's bytes as the body. Do not send an Authorization header, and do not change the URL's path or query string.
A single upload URL is valid for 5 minutes (300 seconds); a part URL is valid for 1 hour. An expired URL cannot be refreshed; request a new plan.
For a multipart file, PUT each part's bytes to its part URL, in any order and in parallel if you like.
Where the upload URL points depends on the instance: with presigned uploads (presigned_attachment_uploads true in instance features) it is a signed object storage URL; otherwise it is a path under the API itself. The client flow is the same in both cases.
The complete URL is the credential
An upload URL authorizes writing one object. Treat the whole URL, query string included, as a secret until it expires, and keep it out of logs, referrers and redirects.
Completing a multipart upload¶
POST /channels/{channel_id}/attachments/complete
Joins the uploaded parts of one or more multipart uploads into their files. Call it after every part has been sent and before creating the message.
Rate limit: channel:attachment:upload (shared with upload plans), 10 per 10 seconds per account and channel.
| Field | Type | Description |
|---|---|---|
uploads |
array[object] | 1-10 entries |
uploads[].upload_filename |
string | The upload key from the plan |
uploads[].upload_id |
string | The upload_id from the plan |
For each entry the server checks that the upload was planned by the caller in this channel as a multipart upload, lists the parts in storage, and joins them. The joined file then goes through the same processing as metadata confirmation: its size is checked against the caller's limit, its SHA-256 digest against the blocked-file list, and image dimensions and a placeholder are computed. A separate confirmation call is not needed.
The response is {"uploads": [{"upload_filename": string}]}, one entry per completed upload.
| Status | Condition |
|---|---|
400 INVALID_FORM_BODY (errors[].code UPLOADED_ATTACHMENT_NOT_FOUND, path uploads.{i}.upload_filename) |
The upload key is unknown, already used, belongs to another account or channel, or was planned as singlepart |
400 INVALID_FORM_BODY (errors[].code NO_UPLOADED_PARTS_TO_FINALIZE) |
No part was uploaded; the upload is aborted |
400 BAD_REQUEST |
The uploaded parts exceed the caller's size limit; the upload is aborted |
403 CONTENT_BLOCKED |
The file is on the blocked-file list; it is deleted |
503 SERVICE_UNAVAILABLE |
Object storage could not be reached; the upload is aborted when joining fails |
curl -X POST https://tebhos.com/api/v1/channels/1489002177550843904/attachments/complete \
-H "Authorization: Bot $TEBHOS_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"uploads": [{"upload_filename": "3f6c2a8e-6d1b-4f0e-9a52-1c7f4b2d9e10", "upload_id": "2~abc..."}]}'
Optional metadata confirmation¶
POST /channels/{channel_id}/attachments/{attachment_id}/confirm
After the upload, a client may record metadata on the pending attachment. Every field is optional. This step is not required; the same metadata can be sent when creating the message.
| Field | Type | Description |
|---|---|---|
width? |
integer | Image or video width in pixels |
height? |
integer | Image or video height in pixels |
duration_ms? |
integer | Audio or video duration in milliseconds |
placeholder? |
string | A small blurred placeholder |
title? |
string | A title, at most 256 characters; defaults to the file name without extension |
waveform? |
string | The waveform of a voice message |
Claiming the upload¶
Reference each uploaded file in Create message with an attachments array of objects carrying its upload_filename:
{
"content": "Here is the photo",
"attachments": [
{
"upload_filename": "3f6c2a8e-6d1b-4f0e-9a52-1c7f4b2d9e10",
"width": 1280,
"height": 720,
"flags": 0
}
]
}
| Field | Type | Description |
|---|---|---|
upload_filename |
string | The upload key from the plan |
width? |
integer | Image or video width in pixels |
height? |
integer | Image or video height in pixels |
description? |
string | Alternative text |
title? |
string | A title |
duration? |
integer | Audio or video duration in seconds |
waveform? |
string | The waveform of a voice message |
flags? |
integer | Attachment flags. 8 (1 << 3) marks the attachment as a spoiler |
Metadata sent here fills only fields that were not already set by the confirmation step. Alternatively, send attachment_ids (an array of upload_filename values) and spoiler_attachment_ids.
An upload key is single-use and belongs to the account that requested it. A key that is unknown, already used, or owned by another account is skipped without an error, so check the returned message's attachments to confirm every file was attached. Uploading alone creates no message and dispatches no Gateway event.
Retention¶
A stored attachment is kept for a period that depends on its size: files up to 5 MiB are kept for about three years, and the period shrinks with size down to 14 days for files of 500 MiB. Expired attachments are removed automatically.
Profile and guild images¶
Avatars, banners, guild icons, emojis and stickers use their own upload-URL routes, described on the pages for the current user, guilds, guild emojis and guild stickers.
Stream previews¶
A stream preview is a JPEG image attached to a screen share. It uses the Streams API, not the attachment flow. A preview can be sent inline or through an upload URL from POST /streams/{stream_key}/preview/upload-url; the image must be at most 1000000 bytes, and the upload URL is valid for one day.
Failures¶
API requests return the standard error response. A failed PUT to object storage returns the storage provider's own error format.