Upload Attachment
curl --request POST \
--url https://app.tuco.ai/api/attachments/upload \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{}'import requests
url = "https://app.tuco.ai/api/attachments/upload"
payload = {}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({})
};
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 => json_encode([
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$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("{}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
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>")
.header("Content-Type", "application/json")
.body("{}")
.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["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body{
"url": "<string>",
"name": "<string>",
"size": 123,
"contentType": {},
"workspaceId": "<string>"
}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 Attachment
curl --request POST \
--url https://app.tuco.ai/api/attachments/upload \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{}'import requests
url = "https://app.tuco.ai/api/attachments/upload"
payload = {}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({})
};
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 => json_encode([
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$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("{}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
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>")
.header("Content-Type", "application/json")
.body("{}")
.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["Content-Type"] = 'application/json'
request.body = "{}"
response = http.request(request)
puts response.read_body{
"url": "<string>",
"name": "<string>",
"size": 123,
"contentType": {},
"workspaceId": "<string>"
}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_sk_xxxxxxxxxxxxx
Request
Send amultipart/form-data request with a single file field. There is no JSON
body — this is a file upload.
file
required
The file to upload, as a multipart
file field. Max 25 MB. Type is validated by
file extension (browsers report unreliable MIME for .caf/.heic).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_sk_xxxxxxxxxxxxx" \
-F "file=@/path/to/voice-note.m4a"
curl -X POST "https://app.tuco.ai/api/attachments/upload" \
-H "Authorization: Bearer tuco_sk_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-campaign.
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 25 MB | { "error": "File is N MB. Attachments must be under 25 MB." } |