Skip to content

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):

  1. Request an upload plan for the files.
  2. Send each file's bytes with PUT to its upload URL, or each part to its part URL.
  3. For a multipart upload, complete it.
  4. 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.

curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/png" \
  --data-binary @photo.png

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.