Upload files
Upload message attachments, user avatars, group avatars, chat room covers and group files from your server, for example to send image notices from a system account, set avatars when importing users, or put material into group files. For purposes, size limits, formats and retention, see Files overview.
Uploading takes three steps:
- Create an upload to get an upload address.
- Upload the file content directly to object storage.
- Complete the upload to get the file address.
| File size | How |
|---|---|
| Up to 32 MB | Single upload: creating returns one upload address, written with one PUT |
| Over 32 MB | Multipart upload: 16 MB per part; get part upload addresses and upload part by part; after an interruption, list uploaded parts and resume |
This page starts with two complete examples, then describes each API.
Client uploads
When users upload from clients, the client calls the same APIs with a User Token under the /client/v1/media prefix (such as POST /client/v1/media/uploads), with the same flow and parameters, except that owner can't be given (the owner is the uploader), rates are limited per user, and group files require group membership. The SDKs wrap this, so clients sending images or changing avatars don't go through your server.
Upload an image and send it
Send bob an image as alice.
1. Create the upload. The image photo.jpg is 269867 bytes:
curl -X POST "$IM_API/$ORG/$APP/media/uploads" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"purpose": "attachment",
"kind": "image",
"size": 269867,
"content_type": "image/jpeg",
"name": "photo.jpg"
}'{
"file_id": "99606093402996736",
"upload_method": "single",
"upload": {
"url": "https://storage.example.com/im-private/staging/t99604914749046784/a99604957103128576/99606093402996736?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=20261002T203903Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=content-length%3Bhost&X-Amz-Signature=a5900ffd...",
"method": "PUT",
"headers": { "Content-Length": "269867" }
},
"part_size": null,
"part_count": 0,
"expires_at": "2026-10-02T21:39:03.259Z"
}2. Upload the content. Request upload.url with upload.method, with the file content as the body. curl --upload-file sets Content-Length automatically:
curl -X PUT "https://storage.example.com/im-private/staging/t99604914749046784/a99604957103128576/99606093402996736?X-Amz-Algorithm=AWS4-HMAC-SHA256&...&X-Amz-Signature=a5900ffd..." \
--upload-file photo.jpgObject storage returns 200 OK with no body.
3. Complete the upload.
curl -X POST "$IM_API/$ORG/$APP/media/uploads/99606093402996736/complete" \
-H "Authorization: Bearer $APP_TOKEN"You get the file object. Metadata such as capture information was stripped from the image, so size is slightly smaller than the original:
{
"file_id": "99606093402996736",
"purpose": "attachment",
"kind": "image",
"status": "active",
"url": "https://im.example.com/media/v1/f/99606093402996736/DC0neNm6HBbxGW9bN6g_iA",
"thumbnail_url": "https://im.example.com/media/v1/f/99606093402996736/DC0neNm6HBbxGW9bN6g_iA/thumb",
"name": "photo.jpg",
"content_type": "image/jpeg",
"size": 269803,
"width": 1080,
"height": 720,
"owner": null,
"group_id": null,
"via": "openapi",
"created_at": "2026-10-02T20:39:03.259Z",
"expires_at": "2026-11-01T20:39:03.259Z",
"in_use": false,
"sha256": "6c458b05f583eca6c08cd7296ec4d2811c68cf59cbc3c8b30feeb8cdc45994ef",
"created_by": "99604957107322880",
"blocked_at": null,
"blocked_by": null,
"block_rule_source": null,
"block_reason": null,
"deleted_at": null,
"delete_reason": null
}4. Send the image message. Copy url, thumbnail_url, width, height and size from the file object into the image message's body, and set format from content_type. Only url is required; the other fields let clients lay out and display the message before downloading, so include them all. For every message type's body see Message types, and for sending see Send messages:
curl -X POST "$IM_API/$ORG/$APP/messages" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from": "alice",
"to_user": "bob",
"client_msg_id": "img-0001",
"type": "image",
"body": {
"url": "https://im.example.com/media/v1/f/99606093402996736/DC0neNm6HBbxGW9bN6g_iA",
"thumbnail_url": "https://im.example.com/media/v1/f/99606093402996736/DC0neNm6HBbxGW9bN6g_iA/thumb",
"width": 1080,
"height": 720,
"size": 269803,
"format": "jpg"
}
}'When bob's client receives the message, it exchanges the thumbnail and original for download addresses with a User Token and shows them, see File addresses.
- Put the file address in the message, not a download address: download addresses expire within 90 minutes.
- The same file can be sent to several conversations without uploading again. Attachments are kept for
attachment_retention_days(30 by default) from upload; after that the message remains and downloading shows the file as expired. - For file messages (
typefile),nameinbodyis also required, usually the uploaded file name; voice and video messages also needduration_seconds, which you fill in because the server doesn't parse audio or video. Upload a video's cover separately as an image and use its address.
Set a user's avatar
Upload the avatar first, then update the user's profile with its public address. Uploading requires owner to name the user who'll use it, so when importing users, create the user first (without an avatar), then upload the avatar, then update the profile.
1. Create the upload with purpose user_avatar:
curl -X POST "$IM_API/$ORG/$APP/media/uploads" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"purpose": "user_avatar",
"kind": "image",
"size": 43484,
"content_type": "image/jpeg",
"owner": "carol"
}'2. Upload the content, as above:
curl -X PUT "<upload.url>" --upload-file avatar.jpg3. Complete the upload to get the avatar's public address. The 1200 × 1200 original was scaled to 640 × 640:
curl -X POST "$IM_API/$ORG/$APP/media/uploads/99606376459796480/complete" \
-H "Authorization: Bearer $APP_TOKEN"{
"file_id": "99606376459796480",
"purpose": "user_avatar",
"kind": "image",
"status": "active",
"url": "https://cdn.example.com/t99604914749046784/a99604957103128576/u/99606376459796480_thhts5lb.jpg",
"thumbnail_url": null,
"name": null,
"content_type": "image/jpeg",
"size": 14083,
"width": 640,
"height": 640,
"owner": "carol",
"group_id": null,
"via": "openapi",
"created_at": "2026-10-02T20:40:10.745Z",
"expires_at": null,
"in_use": false,
"sha256": "2c1638f06c486e40ad416456ccd8dd3cae5d34d6c2e6350b87fbcf421d3a66b5",
"created_by": "99604957107322880",
"blocked_at": null,
"blocked_by": null,
"block_rule_source": null,
"block_reason": null,
"deleted_at": null,
"delete_reason": null
}4. Update the user's profile, see Update a user's profile:
curl -X PATCH "$IM_API/$ORG/$APP/users/carol" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"avatar_url": "https://cdn.example.com/t99604914749046784/a99604957103128576/u/99606376459796480_thhts5lb.jpg"
}'Shortly afterwards, Get a file shows the avatar's in_use as true.
- Avatars not set in a profile within 24 hours of upload are deleted automatically.
- If you change avatars later, the old one is deleted after 7 days; you don't need to do anything.
- User avatars only count for
owner's own profile; setting one on another user doesn't count as use and it's deleted on schedule too. - Group avatars work the same way with purpose
group_avatarand noowner; put the public address inavatar_urlwhen updating the group. - Chat room covers work the same way with purpose
chatroom_avatarand noowner; put the public address inavatar_urlwhen creating or updating the chat room. Covers are deleted 7 days after the room is dissolved.
Create an upload
Declare the file to upload and get a file ID and upload address. The server checks the purpose and type combination, size limits and the app's storage quota, but not the content, which is checked when the upload completes. Every call creates a new upload without deduplication; uploads not completed are deleted when they expire and don't count toward storage.
When your server uploads group files, it only checks that the group exists and isn't dissolved; it doesn't check whether owner is a member, and isn't limited by group_file_enabled or group bans. Creating uploads isn't allowed while the app is read-only, but uploads already created can still be completed.
/{org_name}/{app_name}/media/uploadsPath parameters
For org_name and app_name, see the server overview.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
purpose | String | Yes | attachment message attachment / user_avatar / group_avatar / chatroom_avatar chat room cover / group_file, see File purposes |
kind | String | Yes | image, voice, video or file. Attachments can be any of the four, avatars and covers only image, group files image, video or file. Completing the upload checks the content's format against the kind, see Format checks and image processing |
size | Number | Yes | File size in bytes, greater than 0. The uploaded content must be exactly this many bytes. Limits: attachments and group files max_upload_bytes in the runtime policy (100 MB by default), with images at most 20 MB and voice at most 5 MB (or max_upload_bytes if smaller); avatars and covers 5 MB |
content_type | String | No | The file's MIME type, such as image/jpeg, up to 128 characters. Only a hint; the format detected from the content at completion is what counts |
name | String | Depends | File name, up to 255 characters, same rules as name in file messages. Required for file kinds and group files, optional otherwise. Used as the saved file name when downloading file kinds and group files |
group_id | String | For group files | The group ID. Only group files can have it; the group must exist and not be dissolved |
owner | String | For user avatars | The owning user's username; the user must exist. For user avatars, the user who'll use it, and only their profile can; optional for other purposes, to record which user a file belongs to, such as attachments uploaded on a user's behalf. If the app enables erase_messages_on_user_delete, deleting that user deletes their attachments and group files too |
Request example
Upload a 35 MB video (over 32 MB, so multipart):
curl -X POST "$IM_API/$ORG/$APP/media/uploads" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"purpose": "attachment",
"kind": "video",
"size": 36712345,
"content_type": "video/mp4",
"name": "发布会.mp4"
}'Upload a PDF into group files, recorded as uploaded by bob:
curl -X POST "$IM_API/$ORG/$APP/media/uploads" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"purpose": "group_file",
"kind": "file",
"size": 3145728,
"name": "Q3 需求评审.pdf",
"group_id": "99606031272771584",
"owner": "bob"
}'Response
On success you get 201 Created:
| Field | Type | Description |
|---|---|---|
file_id | String | The file ID, used by the later upload APIs and file APIs |
upload_method | String | single: upload everything at once with upload; multipart: split by part_size and upload part by part. Decided by the server from size; over 32 MB is multipart |
upload | Object | The single-upload address; null for multipart. See Upload the file content |
upload.url | String | The upload address, valid for 1 hour |
upload.method | String | The request method, always PUT |
upload.headers | Object | Headers the upload must include; currently Content-Length, equal to size |
part_size | Number | Part size for multipart uploads, always 16777216 (16 MB), with the last part being the remainder; null for single uploads |
part_count | Number | Number of parts, size divided by 16 MB rounded up; 0 for single uploads |
expires_at | String | When the upload expires: 1 hour later for single uploads, 24 hours for multipart. Uploads not completed by then are discarded |
The multipart response:
{
"file_id": "99606786000027648",
"upload_method": "multipart",
"upload": null,
"part_size": 16777216,
"part_count": 3,
"expires_at": "2026-10-03T20:41:48.387Z"
}For a single-upload response, see Upload an image and send it.
Errors
| HTTP status | code | Description |
|---|---|---|
| 400 | invalid_argument | details.reason invalid_purpose: an invalid purpose, or a purpose and kind combination that isn't allowed |
| 400 | invalid_argument | details.reason invalid_name: a file kind or group file without name, or an invalid name |
| 400 | invalid_argument | No details: size isn't greater than 0; content_type is longer than 128 characters; a group file without group_id, or another purpose with group_id; a user avatar without owner. message says which |
| 403 | app_unavailable | The app is read-only and can't upload, see Rate limits and app status |
| 404 | not_found | The owner user doesn't exist or was deleted ("User not found"); the group doesn't exist or was dissolved ("Group not found or dissolved") |
| 409 | limit_exceeded | details.reason storage_quota: the app's storage quota is full, with limit_bytes and used_bytes, see Storage usage and quota; details.reason group_file_limit: the group already has 1000 group files |
| 413 | payload_too_large | size is over the limit for this purpose and kind; details.max_bytes is the limit |
{
"error": {
"code": "payload_too_large",
"message": "文件超过大小上限",
"details": { "max_bytes": 20971520 },
"request_id": "VRKDMUL77JAZIGGSWDSE5V7GBE"
}
}Upload the file content
File content goes straight to object storage, not through IM APIs. Upload addresses are already signed, so don't send an Authorization header.
- Single upload: request
upload.urlwithupload.method(PUT), every header inupload.headers, and the whole file as the body. - Multipart upload: for each part, upload its content with the
method,urlandheadersfrom Get part upload addresses. Part N is the 16 MB starting at byte (N − 1) × 16 MB, and the last part is the remainder. Parts can be uploaded in parallel and in any order.
Note:
- Requests must have
Content-Lengthexactly equal to the signed length (sizeor the part size). A different length gets403(SignatureDoesNotMatch) from object storage, and chunked transfer encoding (Transfer-Encoding: chunked) gets411. Most HTTP libraries setContent-Lengthautomatically when uploading a file or byte array. Content-Typeisn't checked and can be left out; the format detected at completion counts.- Successful uploads get
200 OKfrom object storage; failures return object storage's own XML errors, not this service's error format. - Upload addresses can be reused while valid: after a network interruption or failure, upload again to the same address, and the last successful upload counts. Once an address expires, single uploads need a new upload created; multipart uploads can request a new address for that part.
curl -X PUT "https://storage.example.com/im-private/t99604914749046784/a99604957103128576/m/99606786000027648?X-Amz-Algorithm=AWS4-HMAC-SHA256&...&partNumber=1&uploadId=ZTBmOTBh...&X-Amz-Signature=b665663e..." \
--upload-file part1.binGet part upload addresses
Sign upload addresses for parts of a multipart upload, up to 100 parts per call. You can request all parts at once or as you go; when addresses expire, request them again, and parts already uploaded aren't affected. Only for uploads with upload_method multipart.
/{org_name}/{app_name}/media/uploads/{file_id}/partsPath parameters
For org_name and app_name, see the server overview.
| Parameter | Type | Description |
|---|---|---|
file_id | String | The file ID returned when creating the upload |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
part_numbers | Array<Number> | Yes | Part numbers starting at 1, between 1 and part_count, 1 to 100 per call, no duplicates |
Request example
curl -X POST "$IM_API/$ORG/$APP/media/uploads/99606786000027648/parts" \
-H "Authorization: Bearer $APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"part_numbers": [1, 2, 3]
}'Response
On success you get 200 OK:
| Field | Type | Description |
|---|---|---|
parts | Array<Object> | Upload addresses for each part, in request order |
parts[].part_number | Number | The part number |
parts[].url | String | The part's upload address |
parts[].method | String | The request method, always PUT |
parts[].headers | Object | Headers the part upload must include; currently Content-Length, the part's size |
parts[].expires_at | String | When the address expires: 1 hour later, but no later than the upload's expiry |
{
"parts": [
{
"part_number": 1,
"url": "https://storage.example.com/im-private/t99604914749046784/a99604957103128576/m/99606786000027648?X-Amz-Algorithm=AWS4-HMAC-SHA256&...&partNumber=1&uploadId=ZTBmOTBh...&X-Amz-Signature=b665663e...",
"method": "PUT",
"headers": { "Content-Length": "16777216" },
"expires_at": "2026-10-02T21:41:53.960Z"
},
{
"part_number": 2,
"url": "https://storage.example.com/im-private/t99604914749046784/a99604957103128576/m/99606786000027648?X-Amz-Algorithm=AWS4-HMAC-SHA256&...&partNumber=2&uploadId=ZTBmOTBh...&X-Amz-Signature=8e08a1ca...",
"method": "PUT",
"headers": { "Content-Length": "16777216" },
"expires_at": "2026-10-02T21:41:53.960Z"
},
{
"part_number": 3,
"url": "https://storage.example.com/im-private/t99604914749046784/a99604957103128576/m/99606786000027648?X-Amz-Algorithm=AWS4-HMAC-SHA256&...&partNumber=3&uploadId=ZTBmOTBh...&X-Amz-Signature=ec6fd43f...",
"method": "PUT",
"headers": { "Content-Length": "3157913" },
"expires_at": "2026-10-02T21:41:53.960Z"
}
]
}Errors
| HTTP status | code | Description |
|---|---|---|
| 400 | invalid_argument | part_numbers is empty, has more than 100 entries, is outside 1 to part_count or has duplicates; or this is a single upload with no parts |
| 404 | not_found | The upload doesn't exist, was canceled or expired, or wasn't created through this app's OpenAPI |
| 409 | version_conflict | The upload is already complete; details.reason is upload_completed |
List uploaded parts
Return the parts of a multipart upload that uploaded successfully, all at once in ascending part order. After an interruption, use it to find missing parts, upload only those, then complete the upload. Only for multipart uploads.
/{org_name}/{app_name}/media/uploads/{file_id}/partsPath parameters
For org_name and app_name, see the server overview.
| Parameter | Type | Description |
|---|---|---|
file_id | String | The file ID returned when creating the upload |
Request example
curl "$IM_API/$ORG/$APP/media/uploads/99606786000027648/parts" \
-H "Authorization: Bearer $APP_TOKEN"Response
On success you get 200 OK:
| Field | Type | Description |
|---|---|---|
parts | Array<Object> | Uploaded parts in ascending order; empty if none have been uploaded |
parts[].part_number | Number | The part number |
parts[].size | Number | The part's uploaded size in bytes. Should be 16777216 except for the last part; re-upload a part if it differs |
Parts 1 and 3 uploaded, part 2 missing:
{
"parts": [
{ "part_number": 1, "size": 16777216 },
{ "part_number": 3, "size": 3157913 }
]
}Errors
| HTTP status | code | Description |
|---|---|---|
| 400 | invalid_argument | This is a single upload with no parts |
| 404 | not_found | The upload doesn't exist, was canceled or expired, or wasn't created through this app's OpenAPI |
| 409 | version_conflict | The upload is already complete; details.reason is upload_completed |
Complete the upload
Call this after the content is uploaded, with no body; multipart uploads don't need a part list either, because the server checks the uploaded parts itself. The server checks the size, detects the format from the content and processes images and avatars (see Format checks and image processing); once it passes, the file becomes usable and you get the file object.
- Safe to retry: calling it again on a completed upload returns
200 OKwith the original result. A completion request is processed for at most 2 minutes; on timeout you get500 internal, the upload stays valid, and you can retry later. If an earlier completion request is still being processed, you get409 version_conflict(upload_completing); retry later. - Whether a failure can be retried: when the content isn't fully uploaded (
upload_incomplete), processing is busy (image_busy), or the content was re-uploaded during checking (upload_changed), the upload stays valid; upload the missing content or complete again later. When the format doesn't match the kind, the image can't be processed, the group's files are full, the owner was deleted or the group was dissolved, the upload is discarded and you need to create a new one; completing it again then returns400 invalid_argument(upload_failed). - After completion: upload addresses still work until they expire, but uploading again doesn't change the completed file.
/{org_name}/{app_name}/media/uploads/{file_id}/completePath parameters
For org_name and app_name, see the server overview.
| Parameter | Type | Description |
|---|---|---|
file_id | String | The file ID returned when creating the upload |
Request example
curl -X POST "$IM_API/$ORG/$APP/media/uploads/99606786000027648/complete" \
-H "Authorization: Bearer $APP_TOKEN"Response
On success you get 200 OK with a file object. For the multipart video above (videos have no thumbnail, dimensions or sha256):
{
"file_id": "99606786000027648",
"purpose": "attachment",
"kind": "video",
"status": "active",
"url": "https://im.example.com/media/v1/f/99606786000027648/AIqdvqpQk8b2JOVXzRFOfA",
"thumbnail_url": null,
"name": "发布会.mp4",
"content_type": "video/mp4",
"size": 36712345,
"width": null,
"height": null,
"owner": null,
"group_id": null,
"via": "openapi",
"created_at": "2026-10-02T20:41:48.396Z",
"expires_at": "2026-11-01T20:41:48.396Z",
"in_use": false,
"sha256": null,
"created_by": "99604957107322880",
"blocked_at": null,
"blocked_by": null,
"block_rule_source": null,
"block_reason": null,
"deleted_at": null,
"delete_reason": null
}Errors
| HTTP status | code | Description |
|---|---|---|
| 400 | invalid_argument | details.reason upload_incomplete: a single upload has no content yet ("File not uploaded yet"), or a multipart upload is missing parts or a part has the wrong size ("Parts incomplete or wrong size"). The upload stays valid; upload the rest and complete again |
| 400 | invalid_argument | details.reason size_mismatch: the uploaded size differs from size; details.size is the actual size. The upload stays valid; upload again and complete again |
| 400 | invalid_argument | details.reason unsupported_type: the format doesn't match the kind; details.content_type is the detected format. The upload is discarded |
| 400 | invalid_argument | details.reason image_too_large: the image is over 50 megapixels; invalid_image: the image can't be decoded. The upload is discarded |
| 400 | invalid_argument | details.reason upload_failed: the upload was already discarded for one of the reasons above; create a new upload |
| 403 | file_blocked | The file completed but was later blocked for violating rules |
| 404 | not_found | The upload doesn't exist, was canceled or expired, or wasn't created through this app's OpenAPI ("Upload not found, canceled or expired"); the owner user was deleted during the upload ("User not found"); the group of a group file was dissolved during the upload ("Group not found or dissolved"). The last two discard the upload |
| 409 | version_conflict | details.reason upload_completing: another completion request is processing this file; upload_changed: the content was re-uploaded during checking. Both can be completed again later |
| 409 | limit_exceeded | details.reason group_file_limit: the group already has 1000 group files. The upload is discarded |
| 429 | rate_limited | details.reason image_busy: image processing is busy; wait for Retry-After and complete again |
| 500 | internal | A timeout ("Upload completion timed out, try again later") or other internal error; the upload stays valid, so retry later |
{
"error": {
"code": "invalid_argument",
"message": "文件的格式不符合类型",
"details": { "content_type": "text/plain", "reason": "unsupported_type" },
"request_id": "BN22NWAAYNNI3ERPKAWSHIBRPH"
}
}Cancel an upload
Give up an upload that isn't complete; uploaded content is deleted afterwards. Canceling an upload that's already canceled, expired or discarded also succeeds. Completed uploads can't be canceled; to delete the file, use Delete a file.
/{org_name}/{app_name}/media/uploads/{file_id}Path parameters
For org_name and app_name, see the server overview.
| Parameter | Type | Description |
|---|---|---|
file_id | String | The file ID returned when creating the upload |
Request example
curl -X DELETE "$IM_API/$ORG/$APP/media/uploads/99606941961027584" \
-H "Authorization: Bearer $APP_TOKEN"Response
On success you get 204 No Content with no body.
Errors
| HTTP status | code | Description |
|---|---|---|
| 404 | not_found | The upload doesn't exist, or wasn't created through this app's OpenAPI |
| 409 | version_conflict | The upload is already complete; details.reason is upload_completed |
