Uploads Live
Two-step upload for attachments, logos, and team chat files. Step 1 asks our API for a presigned PUT URL; step 2 streams the bytes directly to DigitalOcean Spaces. The resulting public URL is what you attach to messages or any resource that takes a file URL. The server never touches the file bytes.
Until Spaces credentials are configured on a given deployment, every presign call returns 503 storage_not_configured.
Get a presigned upload URL
Returns a one-time PUT URL valid for 5 minutes, plus the public URL the file will be reachable at after upload. You PUT the bytes directly to the upload URL; the API never sees your file.
| Field | Type | Description |
|---|---|---|
| filenamerequired | string | Original filename, 1 to 500 characters. Sanitized into the storage key. |
| contentTyperequired | string | MIME type. PUT with the same Content-Type header. |
| sizerequired | integer | File size in bytes. Max 25 MB for attachment/team_attachment, 2 MB for logo. |
| kindoptional | enum | One of attachment (default), logo, or team_attachment. logo is admin-only, image types only, 2 MB cap. team_attachment also checks the Message Team storage quota. |
{
"filename": "refund-policy.pdf",
"contentType": "application/pdf",
"size": 53120
}{
"uploadUrl": "https://...presigned-put.url",
"publicUrl": "https://acme-bucket.sfo3.digitaloceanspaces.com/uploads/workspace-id/...-refund-policy.pdf",
"key": "uploads/workspace-id/1747...-refund-policy.pdf"
}Code samples
# Step 1. ask the API for a presigned URL
curl -X POST 'https://app.message.com/api/v1/uploads/presigned' \
-H 'Authorization: Bearer YOUR_WORKSPACE_JWT' \
-H 'Content-Type: application/json' \
-d '{"filename":"refund-policy.pdf","contentType":"application/pdf","size":53120}'
# Step 2. PUT the bytes directly to the returned URL
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @./refund-policy.pdf// Step 1: ask for presigned URL
const presign = await fetch('https://app.message.com/api/v1/uploads/presigned', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size
})
}).then(r => r.json());
// Step 2: PUT the file bytes directly, same Content-Type you presigned with
await fetch(presign.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
// presign.publicUrl is now your attachment URLimport requests
# Step 1: presign
presign = requests.post(
"https://app.message.com/api/v1/uploads/presigned",
headers={"Authorization": f"Bearer {token}"},
json={"filename": "refund-policy.pdf", "contentType": "application/pdf", "size": 53120},
).json()
# Step 2: PUT bytes, same Content-Type you presigned with
with open("refund-policy.pdf", "rb") as f:
requests.put(presign["uploadUrl"], headers={"Content-Type": "application/pdf"}, data=f)
public_url = presign["publicUrl"]require "net/http"
require "json"
# Step 1: presign
uri = URI("https://app.message.com/api/v1/uploads/presigned")
req = Net::HTTP::Post.new(uri, {
"Authorization" => "Bearer #{token}",
"Content-Type" => "application/json"
})
req.body = { filename: "refund-policy.pdf", contentType: "application/pdf", size: 53120 }.to_json
presign = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
# Step 2: PUT bytes
put_uri = URI(presign["uploadUrl"])
put_req = Net::HTTP::Put.new(put_uri, { "Content-Type" => "application/pdf" })
put_req.body = File.read("refund-policy.pdf")
Net::HTTP.start(put_uri.host, put_uri.port, use_ssl: true) { |h| h.request(put_req) }<?php
// Step 1: presign
$ctx = stream_context_create([
"http" => [
"method" => "POST",
"header" => "Authorization: Bearer $token
Content-Type: application/json",
"content" => json_encode([
"filename" => "refund-policy.pdf",
"contentType" => "application/pdf",
"size" => 53120
])
],
]);
$presign = json_decode(file_get_contents("https://app.message.com/api/v1/uploads/presigned", false, $ctx), true);
// Step 2: PUT bytes
$ch = curl_init($presign["uploadUrl"]);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "PUT",
CURLOPT_POSTFIELDS => file_get_contents("refund-policy.pdf"),
CURLOPT_HTTPHEADER => ["Content-Type: application/pdf"],
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);Import a logo from a URL
Admin-only. Fetches an image from a foreign URL server-side (validated, capped at 2 MB) and re-hosts it on Spaces under the workspace's logo prefix, so the widget never hotlinks a third-party origin. Built for the brand-importer flow; usable directly too.
{ "url": "https://old-site.com/logo.png" }There is no direct multipart-form upload endpoint and no DELETE for an uploaded object. Every upload goes through the presigned-PUT flow above, and there is no API to remove an object from storage once uploaded.
Limits
| Limit | Value |
|---|---|
| Max file size (attachment / team_attachment) | 25 MB |
| Max file size (logo) | 2 MB |
| Presign URL lifetime | 5 minutes |
| Allowed MIME types | Common docs, images, audio, video. SVG blocked (script-injection risk). Executables blocked. |
Errors
| Code | When |
|---|---|
400 invalid_body | Payload failed schema validation. |
400 content_type_not_allowed | MIME type not in the allowlist for this kind. |
400 file_too_large | Size exceeds the cap for this kind. |
402 storage_limit_reached | team_attachment only: Message Team storage quota reached. |
403 admin_required | kind: "logo" or import-from-url called by a non-admin. |
503 storage_not_configured | Spaces credentials aren't set server-side. |
Common pitfalls
- PUTting with a different
Content-Typethan you presigned with. Keep them identical. - Looking for a multipart upload endpoint. There isn't one. The presigned-PUT flow is the only path.
- Looking for a delete-upload endpoint. There isn't one. Uploaded objects are not deletable via the API.