message.comDevelopers

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

POST/api/v1/uploads/presignedAuth: Bearer

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.

FieldTypeDescription
filenamerequiredstringOriginal filename, 1 to 500 characters. Sanitized into the storage key.
contentTyperequiredstringMIME type. PUT with the same Content-Type header.
sizerequiredintegerFile size in bytes. Max 25 MB for attachment/team_attachment, 2 MB for logo.
kindoptionalenumOne 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.
Body
{
  "filename": "refund-policy.pdf",
  "contentType": "application/pdf",
  "size": 53120
}
200 OK
{
  "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

cURL
# 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
JavaScript
// 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 URL
Python
import 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"]
Ruby
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
<?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

POST/api/v1/uploads/import-from-urlAuth: Bearer

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.

Body
{ "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

LimitValue
Max file size (attachment / team_attachment)25 MB
Max file size (logo)2 MB
Presign URL lifetime5 minutes
Allowed MIME typesCommon docs, images, audio, video. SVG blocked (script-injection risk). Executables blocked.

Errors

CodeWhen
400 invalid_bodyPayload failed schema validation.
400 content_type_not_allowedMIME type not in the allowlist for this kind.
400 file_too_largeSize exceeds the cap for this kind.
402 storage_limit_reachedteam_attachment only: Message Team storage quota reached.
403 admin_requiredkind: "logo" or import-from-url called by a non-admin.
503 storage_not_configuredSpaces credentials aren't set server-side.

Common pitfalls

  • PUTting with a different Content-Type than 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.