Upload an attachment file
curl --request POST \
--url https://app.tuco.ai/api/attachments/upload \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: multipart/form-data' \
--form file=@example-fileimport requests
url = "https://app.tuco.ai/api/attachments/upload"
files = { "file": ("example-file", open("example-file", "rb")) }
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, files=files, headers=headers)
print(response.text)const form = new FormData();
form.append('file', '<string>');
const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
options.body = form;
fetch('https://app.tuco.ai/api/attachments/upload', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.tuco.ai/api/attachments/upload",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => "-----011000010111000001101001\r\nContent-Disposition: form-data; name=\"file\"; filename=\"example-file\"\r\nContent-Type: application/octet-stream\r\n\r\n<string>\r\n-----011000010111000001101001--",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: multipart/form-data"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.tuco.ai/api/attachments/upload"
payload := strings.NewReader("-----011000010111000001101001\r\nContent-Disposition: form-data; name=\"file\"; filename=\"example-file\"\r\nContent-Type: application/octet-stream\r\n\r\n<string>\r\n-----011000010111000001101001--")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://app.tuco.ai/api/attachments/upload")
.header("Authorization", "Bearer <token>")
.body("-----011000010111000001101001\r\nContent-Disposition: form-data; name=\"file\"; filename=\"example-file\"\r\nContent-Type: application/octet-stream\r\n\r\n<string>\r\n-----011000010111000001101001--")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.tuco.ai/api/attachments/upload")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request.body = "-----011000010111000001101001\r\nContent-Disposition: form-data; name=\"file\"; filename=\"example-file\"\r\nContent-Type: application/octet-stream\r\n\r\n<string>\r\n-----011000010111000001101001--"
response = http.request(request)
puts response.read_body{
"url": "https://app.tuco.ai/api/attachments/crm-media/abc?sig=...&exp=...",
"name": "voice-note.m4a",
"size": 84213,
"contentType": "audio/mp4",
"workspaceId": "org_3AZs4H8UsfFxVFONr6H4K75okaG"
}Messages
Upload Attachment
Upload an audio clip, image, video, or PDF and get a public URL to use as an attachmentUrl. REST endpoint in the Tuco AI iMessage API — bearer-token auth.
POST
/
api
/
attachments
/
upload
Upload an attachment file
curl --request POST \
--url https://app.tuco.ai/api/attachments/upload \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: multipart/form-data' \
--form file=@example-fileimport requests
url = "https://app.tuco.ai/api/attachments/upload"
files = { "file": ("example-file", open("example-file", "rb")) }
headers = {"Authorization": "Bearer <token>"}
response = requests.post(url, files=files, headers=headers)
print(response.text)const form = new FormData();
form.append('file', '<string>');
const options = {method: 'POST', headers: {Authorization: 'Bearer <token>'}};
options.body = form;
fetch('https://app.tuco.ai/api/attachments/upload', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.tuco.ai/api/attachments/upload",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => "-----011000010111000001101001\r\nContent-Disposition: form-data; name=\"file\"; filename=\"example-file\"\r\nContent-Type: application/octet-stream\r\n\r\n<string>\r\n-----011000010111000001101001--",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: multipart/form-data"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.tuco.ai/api/attachments/upload"
payload := strings.NewReader("-----011000010111000001101001\r\nContent-Disposition: form-data; name=\"file\"; filename=\"example-file\"\r\nContent-Type: application/octet-stream\r\n\r\n<string>\r\n-----011000010111000001101001--")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://app.tuco.ai/api/attachments/upload")
.header("Authorization", "Bearer <token>")
.body("-----011000010111000001101001\r\nContent-Disposition: form-data; name=\"file\"; filename=\"example-file\"\r\nContent-Type: application/octet-stream\r\n\r\n<string>\r\n-----011000010111000001101001--")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.tuco.ai/api/attachments/upload")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request.body = "-----011000010111000001101001\r\nContent-Disposition: form-data; name=\"file\"; filename=\"example-file\"\r\nContent-Type: application/octet-stream\r\n\r\n<string>\r\n-----011000010111000001101001--"
response = http.request(request)
puts response.read_body{
"url": "https://app.tuco.ai/api/attachments/crm-media/abc?sig=...&exp=...",
"name": "voice-note.m4a",
"size": 84213,
"contentType": "audio/mp4",
"workspaceId": "org_3AZs4H8UsfFxVFONr6H4K75okaG"
}Use this endpoint to turn a local file into a public, workspace-owned URL you can
drop straight into
attachmentUrls on
POST /api/messages or into the GHL Send voice note action. The URL is signed for
your workspace, so it always passes the “does not belong to this organization” guard —
no need to host the file yourself.Authentication
Pass your workspace API key as a Bearer token (the same key as/api/messages), or
use a Clerk session token.
Authorization: Bearer tuco_xxxxxxxxxxxxx
Request
Send amultipart/form-data request with a single file field. There is no JSON
body — this is a file upload, so Content-Type: application/json is rejected with
400 Expected multipart/form-data with a "file" field.
The file is capped at 5 MB and the type is validated by file extension (browsers
report unreliable MIME for .caf and .heic).
5 MB is the upload limit, not iMessage’s. A message attachment supplied as a public
URL (
attachmentUrls on send-message) can still
be up to 25 MB — that file never passes through Tuco. Uploads are held lower because they
stream through our servers, and because a voice note is capped at 5 minutes of audio anyway.Allowed types
| Category | Extensions | Use |
|---|---|---|
| Audio (voice notes) | mp3, m4a, wav, aac, ogg, caf | Source for a native iMessage voice note (with voiceNote: true) |
| Images | png, jpg, gif, webp, heic | Inline image attachment |
| Video | mp4, mov, webm | Playable video attachment |
| Documents | pdf | Attachment preview |
Voice notes: upload any of the audio formats above. When the file is used with
voiceNote: true (GHL action) or otherwise flagged for a voice note, the worker
transcodes it to the native iMessage voice-note bubble — you do not have to convert
to .caf yourself first.Example
curl -X POST "https://app.tuco.ai/api/attachments/upload" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-F "file=@/path/to/voice-note.m4a"
curl -X POST "https://app.tuco.ai/api/attachments/upload" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-F "file=@/path/to/photo.jpg"
Response
Returns200 OK with the public URL and file metadata.
{
"url": "https://app.tuco.ai/api/attachments/crm-media/...?sig=...&exp=...",
"name": "voice-note.m4a",
"size": 84213,
"contentType": "audio/mp4",
"workspaceId": "org_3AZs4H8UsfFxVFONr6H4K75okaG"
}
string
A public, signed link hosted on your workspace storage. Use it directly as an
attachmentUrl — it needs no auth to fetch and always passes the /api/messages
org-ownership guard. Valid for ~1 year; re-upload if a workflow may run longer.string
The original filename you uploaded.
number
File size in bytes.
string | null
The uploaded file’s MIME type, or
null when the browser/client didn’t report one
(common for .caf/.heic). Delivery does not depend on this field.string
The Clerk organization ID that owns the file — the workspace your API key belongs to.
Copy the returned
url straight into the GHL Send voice note action’s attachmentUrl
field, or into attachmentUrls on POST /api/messages.
Because the file lives under your workspace, it passes the ownership guard automatically.Using the URL
1
Upload the file
POST /api/attachments/upload with your audio/image/etc. as the file field.
Grab url from the response.2
Send it
Pass
url as an attachmentUrl — in attachmentUrls on
POST /api/messages, or in the GHL
Send voice note action.3
Re-upload if it's older than a year
The signed link is valid for ~1 year. For a long-running workflow, re-upload
periodically so the URL never expires mid-sequence.
No-code alternative: the Uploads UI
You don’t need to call the API to get a URL. In the dashboard:/uploads— a dedicated Upload audio etc page.- The Upload audio etc tab on the Leads page.
This is the fastest path for a one-off voice note: record it, drop it on /uploads,
copy the link, and paste it into the GHL Send voice note action’s Attachment URL.
Errors
| Status | When | Body |
|---|---|---|
401 | Missing or invalid API key / session | { "error": "Unauthorized" } |
400 | Not multipart/form-data / no file field | { "error": "Expected multipart/form-data with a \"file\" field." } |
400 | file field missing | { "error": "No file provided (multipart field \"file\")." } |
400 | File is empty (0 bytes) | { "error": "File is empty." } |
400 | Unsupported extension | { "error": "…: unsupported type. Allowed: mp3, m4a, wav, …" } |
413 | File over 5 MB | { "error": "File is N MB. Attachments must be under 5 MB." } |
413 | Audio over 5 MB | { "error": "NAME is N MB. Audio must be under 5 MB — a voice note can only be 5 minutes long. Trim or compress the recording." } |
Authorizations
Workspace API key: Authorization: Bearer tuco_xxxxxxxxxxxxx
Body
multipart/form-data
The file to upload. Max 5 MB. Type validated by extension.
Response
Uploaded — returns the public URL and file metadata
Public, signed link (valid ~1 year) hosted on your workspace storage. Use directly as an attachmentUrl.
Original filename.
File size in bytes.
Clerk organization ID that owns the file.
MIME type, or null when the client didn't report one.