Send with Attachments
curl --request POST \
--url https://app.tuco.ai/api/messages \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"attachmentUrls": [
"<string>"
],
"attachmentNames": [
"<string>"
],
"message": "<string>",
"recipientPhone": "<string>",
"leadId": "<string>",
"recipientEmail": "<string>",
"voiceNote": true
}
'import requests
url = "https://app.tuco.ai/api/messages"
payload = {
"attachmentUrls": ["<string>"],
"attachmentNames": ["<string>"],
"message": "<string>",
"recipientPhone": "<string>",
"leadId": "<string>",
"recipientEmail": "<string>",
"voiceNote": True
}
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({
attachmentUrls: ['<string>'],
attachmentNames: ['<string>'],
message: '<string>',
recipientPhone: '<string>',
leadId: '<string>',
recipientEmail: '<string>',
voiceNote: true
})
};
fetch('https://app.tuco.ai/api/messages', 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/messages",
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([
'attachmentUrls' => [
'<string>'
],
'attachmentNames' => [
'<string>'
],
'message' => '<string>',
'recipientPhone' => '<string>',
'leadId' => '<string>',
'recipientEmail' => '<string>',
'voiceNote' => true
]),
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/messages"
payload := strings.NewReader("{\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"message\": \"<string>\",\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"voiceNote\": true\n}")
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/messages")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"message\": \"<string>\",\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"voiceNote\": true\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.tuco.ai/api/messages")
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 = "{\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"message\": \"<string>\",\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"voiceNote\": true\n}"
response = http.request(request)
puts response.read_bodyMessages
Send with Attachments
Send images, videos, voice notes, and files via iMessage — with or without text. REST endpoint in the Tuco AI iMessage API — bearer-token auth, JSON.
POST
/
api
/
messages
Send with Attachments
curl --request POST \
--url https://app.tuco.ai/api/messages \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"attachmentUrls": [
"<string>"
],
"attachmentNames": [
"<string>"
],
"message": "<string>",
"recipientPhone": "<string>",
"leadId": "<string>",
"recipientEmail": "<string>",
"voiceNote": true
}
'import requests
url = "https://app.tuco.ai/api/messages"
payload = {
"attachmentUrls": ["<string>"],
"attachmentNames": ["<string>"],
"message": "<string>",
"recipientPhone": "<string>",
"leadId": "<string>",
"recipientEmail": "<string>",
"voiceNote": True
}
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({
attachmentUrls: ['<string>'],
attachmentNames: ['<string>'],
message: '<string>',
recipientPhone: '<string>',
leadId: '<string>',
recipientEmail: '<string>',
voiceNote: true
})
};
fetch('https://app.tuco.ai/api/messages', 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/messages",
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([
'attachmentUrls' => [
'<string>'
],
'attachmentNames' => [
'<string>'
],
'message' => '<string>',
'recipientPhone' => '<string>',
'leadId' => '<string>',
'recipientEmail' => '<string>',
'voiceNote' => true
]),
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/messages"
payload := strings.NewReader("{\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"message\": \"<string>\",\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"voiceNote\": true\n}")
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/messages")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"message\": \"<string>\",\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"voiceNote\": true\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.tuco.ai/api/messages")
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 = "{\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"message\": \"<string>\",\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"voiceNote\": true\n}"
response = http.request(request)
puts response.read_bodyThe Try it panel runs against production (
https://app.tuco.ai) with the key you paste
into it. There is no sandbox — a request from this page sends a real message from one of your lines.Request body
Use the samePOST /api/messages endpoint. Add attachmentUrls to your request:
string[]
required
Array of public URLs to attach. Max 2 attachments per message, max 25 MB each.
string[]
Optional display names for each attachment (same order as
attachmentUrls). When omitted, the filename from the URL is used.string
Optional text to send with the attachments. When omitted, only the attachment is sent (no “Attachment” placeholder text).
string
The phone number to text, in E.164 format (e.g.
"+12025551234"). This is how you address
almost every send — you do not need a Tuco leadId. Tuco finds the matching lead in your
workspace, or creates one under the Quick Sends list automatically.Required unless you pass leadId or recipientEmail.string
Send to an existing lead using their stored contact details. Required unless you pass
recipientPhone or recipientEmail.string
Use
recipientPhone unless you specifically need this. An email address only reaches someone
over iMessage if that exact address is registered to their Apple account — rare, and not something
you can assume. Addressing by phone number is what works.recipientPhone or leadId.boolean
default:"false"
Send a single audio attachment as a native iMessage voice note (the waveform
bubble) instead of a generic audio file. Accepts
true or the string "true".
Tuco transcodes whatever audio you supply — MP3, M4A, WAV, AAC, OGG, OPUS, FLAC,
AIFF, AMR, CAF — into the exact format iMessage needs, so you do not have to
convert anything yourself.The request is rejected with a 400 if voiceNote is set and the attachment is not
audio (a video, an image, or a web-page link), rather than sending something that
cannot become a waveform. See Voice note requirements.All other
POST /api/messages body fields
apply here too — including forceFallback
(skip the iMessage availability check and route straight to your configured
fallback). One caveat: the SMS/fallback channel is text-only, so a forceFallback
send delivers the message text but not the attachments — those go over
iMessage only.Supported formats
| Category | Formats | Notes |
|---|---|---|
| Images | PNG, JPG, JPEG, GIF, WebP, HEIC | Renders inline in iMessage |
| Video | MP4, MOV | Up to 25 MB. Renders as playable video |
| Audio / Voice notes | MP3, M4A, WAV, AAC, OGG, WebM, FLAC, CAF | Sent as a generic audio file by default. Add "voiceNote": true to deliver it as a native iMessage voice note (the waveform bubble) — Tuco transcodes it for you |
| Documents | Renders as attachment preview |
Voice notes
An audio attachment sends as a generic audio file by default. To get the native iMessage voice note — the waveform bubble with a play button — addvoiceNote: true:
{
"recipientPhone": "+12025551234",
"attachmentUrls": ["https://example.com/voice-note.mp3"],
"voiceNote": true
}
Voice note requirements
All five have to be true. These are conditions on the audio you link to, not request fields.A direct link to an audio FILE
The URL has to return the audio itself. A YouTube, Google Drive or Dropbox share page
is a web page, not a file — those are rejected. So is any link that needs a login.
Publicly reachable, no authentication
Our sending device fetches the URL directly. If it needs a cookie, a session or a
signed-in browser, the fetch fails and nothing is sent.
Under 5 minutes
Recordings longer than 300 seconds are rejected.
Under 25 MB
Same ceiling as every other attachment sent by URL. Files you push through
POST /api/attachments/upload have a tighter 5 MB cap.Not the first message to a contact
The first-ever message to a new contact must be plain text — no attachment or voice
note. Send a text first, then the voice note as a follow-up.
The easy way to get a valid URL: upload the recording in the app under
Leads → Uploads (
/leads?tab=uploads) and use the link it gives you back. That link
is already public, direct, and correctly typed — which is what most failed voice notes
turn out to be missing.What happens if the audio is wrong. Tuco converts your file and then verifies its own
output before sending. If the result would not play — the wrong codec, a stereo track, a
corrupt file — the send is stopped and the message is marked failed with the reason.
We do not deliver a silent
0:00 bubble. You will see one of these on the message:| What you see | What to do |
|---|---|
| That link isn’t an audio file | Use a direct file URL, not a web page |
| Voice note unreachable | The link isn’t public — re-upload it |
| Voice note too long | Trim it under 5 minutes |
| Voice note couldn’t be sent | Re-record, or upload a standard MP3/M4A/WAV |
| This line can’t send voice notes | Contact support |
Do not rename an MP3 to
.caf. Tuco treats any .caf as a finished voice note
and sends it as-is, so a renamed MP3 (or a .caf from a consumer web converter,
which is usually PCM rather than Opus) is rejected and not sent — it would
otherwise arrive as a dead 0:00 bubble. Send the original .mp3 with
voiceNote: true and let Tuco encode it.Passing
voiceNote: true alongside a real .caf is always safe: a valid voice note
passes straight through untouched, and an invalid one gets re-encoded instead of
rejected. If you are sending .caf files you did not produce with
afconvert -f caff -d opus@24000 -c 1, include the flag.When a voice note will not send
Voice notes fail permanently (no retry) in these cases, and the reason is shown on the message in your Unibox:| Reason | What happened |
|---|---|
| First message to a new contact | The first-ever message to a contact must be plain text — no attachment or voice note. Send text first, then the voice note as a follow-up. |
| Attachment is a web page, not audio | attachmentUrls pointed at a YouTube/Calendly-style page instead of a direct audio file URL. |
| Audio longer than 5 minutes | Voice notes are capped at 300 seconds. |
| Attachment is not audio | voiceNote: true with an image or PDF. |
| Line cannot send voice notes | The line’s device relay does not currently support native voice notes. Contact support. |
Examples
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"message": "Check this out!",
"recipientPhone": "+12025551234",
"attachmentUrls": ["https://example.com/photo.jpg"]
}'
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"recipientPhone": "+12025551234",
"attachmentUrls": ["https://app.tuco.ai/api/attachments/crm-media/<id>-note.mp3"],
"voiceNote": true
}'
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"recipientPhone": "+12025551234",
"attachmentUrls": ["https://example.com/intro-video.mp4"]
}'
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"message": "I made this for you. Whenever you are ready: example.com/schedule",
"recipientPhone": "+12025551234",
"attachmentUrls": ["https://example.com/personalized-video.mp4"]
}'
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"message": "Check this out!",
"leadId": "667f1f77bcf86cd799439012",
"attachmentUrls": ["https://example.com/photo.jpg"]
}'
Limits
| Limit | Value |
|---|---|
| Max attachments per message | 2 |
| Max file size per attachment | 25 MB |
| Supported sources | Any public URL, UploadThing URLs, Blob URLs belonging to your workspace |
Attachment caching: When you send the same attachment URL multiple times (e.g. a promo video in a campaign), Tuco caches it per line. The second send skips download + upload, making delivery significantly faster.
Device gap
Each attachment counts as part of a single message — not a separate send. Sending text + video in one API call uses one device gap (30s), not two. This is significantly faster than sending them as separate messages.For automations that send a video followed by a text CTA, combine them into a single API call with
message + attachmentUrls instead of two separate calls. This halves your device gap overhead.Error responses
| Status | When | Body |
|---|---|---|
400 | attachmentUrls not an array | { "error": "attachmentUrls must be an array" } |
400 | More than 2 attachments | { "error": "Maximum 2 attachments per message" } |
400 | voiceNote set but the attachment isn’t audio | { "code": "VOICE_NOTE_NOT_AUDIO" } — names the offending file |
400 | voiceNote set with no attachment | { "code": "VOICE_NOTE_NO_ATTACHMENT" } |
403 | URL doesn’t belong to workspace | { "error": "Attachment URL does not belong to this organization" } |
404 | leadId not found in your workspace | { "error": "Lead not found or access denied" } |
Voice-note problems are not returned as HTTP errors — the API accepts the send and
the message is marked failed with a reason once the worker evaluates it. Check the
message in your Unibox or via
GET /api/messages.
See When a voice note will not send.