Send Message
curl --request POST \
--url https://app.tuco.ai/api/messages \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"message": "<string>",
"attachmentUrls": [
"<string>"
],
"attachmentNames": [
"<string>"
],
"voiceNote": true,
"recipientPhone": "<string>",
"leadId": "<string>",
"recipientEmail": "<string>",
"messageType": "<string>",
"fromLineId": "<string>",
"recipientName": "<string>"
}
'import requests
url = "https://app.tuco.ai/api/messages"
payload = {
"message": "<string>",
"attachmentUrls": ["<string>"],
"attachmentNames": ["<string>"],
"voiceNote": True,
"recipientPhone": "<string>",
"leadId": "<string>",
"recipientEmail": "<string>",
"messageType": "<string>",
"fromLineId": "<string>",
"recipientName": "<string>"
}
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({
message: '<string>',
attachmentUrls: ['<string>'],
attachmentNames: ['<string>'],
voiceNote: true,
recipientPhone: '<string>',
leadId: '<string>',
recipientEmail: '<string>',
messageType: '<string>',
fromLineId: '<string>',
recipientName: '<string>'
})
};
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([
'message' => '<string>',
'attachmentUrls' => [
'<string>'
],
'attachmentNames' => [
'<string>'
],
'voiceNote' => true,
'recipientPhone' => '<string>',
'leadId' => '<string>',
'recipientEmail' => '<string>',
'messageType' => '<string>',
'fromLineId' => '<string>',
'recipientName' => '<string>'
]),
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 \"message\": \"<string>\",\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"voiceNote\": true,\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"recipientName\": \"<string>\"\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 \"message\": \"<string>\",\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"voiceNote\": true,\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"recipientName\": \"<string>\"\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 \"message\": \"<string>\",\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"voiceNote\": true,\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"recipientName\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"success": true,
"status": "<string>",
"message": {
"_id": "<string>",
"message": "<string>",
"messageType": "<string>",
"status": "<string>",
"fromLineId": "<string>",
"recipientPhone": "<string>",
"recipientEmail": "<string>",
"leadId": "<string>",
"scheduledDate": "<string>",
"sentAt": "<string>",
"deliveredAt": "<string>",
"createdAt": "<string>"
}
}Messages
Send Message
Send an iMessage, SMS, or email to a contact — the core automation endpoint. REST endpoint in the Tuco AI iMessage API — bearer-token auth, JSON.
POST
/
api
/
messages
Send Message
curl --request POST \
--url https://app.tuco.ai/api/messages \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"message": "<string>",
"attachmentUrls": [
"<string>"
],
"attachmentNames": [
"<string>"
],
"voiceNote": true,
"recipientPhone": "<string>",
"leadId": "<string>",
"recipientEmail": "<string>",
"messageType": "<string>",
"fromLineId": "<string>",
"recipientName": "<string>"
}
'import requests
url = "https://app.tuco.ai/api/messages"
payload = {
"message": "<string>",
"attachmentUrls": ["<string>"],
"attachmentNames": ["<string>"],
"voiceNote": True,
"recipientPhone": "<string>",
"leadId": "<string>",
"recipientEmail": "<string>",
"messageType": "<string>",
"fromLineId": "<string>",
"recipientName": "<string>"
}
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({
message: '<string>',
attachmentUrls: ['<string>'],
attachmentNames: ['<string>'],
voiceNote: true,
recipientPhone: '<string>',
leadId: '<string>',
recipientEmail: '<string>',
messageType: '<string>',
fromLineId: '<string>',
recipientName: '<string>'
})
};
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([
'message' => '<string>',
'attachmentUrls' => [
'<string>'
],
'attachmentNames' => [
'<string>'
],
'voiceNote' => true,
'recipientPhone' => '<string>',
'leadId' => '<string>',
'recipientEmail' => '<string>',
'messageType' => '<string>',
'fromLineId' => '<string>',
'recipientName' => '<string>'
]),
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 \"message\": \"<string>\",\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"voiceNote\": true,\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"recipientName\": \"<string>\"\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 \"message\": \"<string>\",\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"voiceNote\": true,\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"recipientName\": \"<string>\"\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 \"message\": \"<string>\",\n \"attachmentUrls\": [\n \"<string>\"\n ],\n \"attachmentNames\": [\n \"<string>\"\n ],\n \"voiceNote\": true,\n \"recipientPhone\": \"<string>\",\n \"leadId\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"recipientName\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"success": true,
"status": "<string>",
"message": {
"_id": "<string>",
"message": "<string>",
"messageType": "<string>",
"status": "<string>",
"fromLineId": "<string>",
"recipientPhone": "<string>",
"recipientEmail": "<string>",
"leadId": "<string>",
"scheduledDate": "<string>",
"sentAt": "<string>",
"deliveredAt": "<string>",
"createdAt": "<string>"
}
}The 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.This is the primary endpoint for automation tools like GetSales, n8n, Make, or your own backend.
One call = one message. Tuco handles retries, delivery verification, and fallback internally.
Authentication
Pass your workspace API key as a Bearer token, or use a Clerk session token.Authorization: Bearer tuco_xxxxxxxxxxxxx
Request body
string
The text content to send. Required unless
attachmentUrls is provided (you can send attachments only, or message + attachments).Send the finished text. This endpoint does no merge-field substitution — personalize on your side before you call it. A body still containing {{firstName}}, {firstname}, {name} or {lastname} is rejected with 400 MALFORMED_NAME_TEMPLATE, as is a greeting whose name expanded to nothing ("Hey , …"). The guard exists because unfilled templates once shipped to real customers; merge fields are resolved by campaigns, not by this endpoint.string[]
Optional array of URLs (max 2, max 25 MB each). When provided,
message can be empty. Supported formats: images (PNG, JPG, GIF, WebP, HEIC), video (MP4, MOV), audio (MP3, M4A, WAV, AAC, OGG, CAF), and PDF. Supported sources: any public URL, UploadThing/Blob URLs that belong to your workspace. To send audio as a native iMessage voice note (the waveform bubble), add voiceNote: true. The audio must be a direct, publicly reachable file URL — a YouTube or Drive page is rejected with a 400. Easiest source is Leads → Uploads (/leads?tab=uploads). See Voice note requirements.Sending the same attachment URL repeatedly (e.g. a promo video in an automation)? Tuco caches attachments per line — the second send skips the download and upload, making it significantly faster.
string[]
Optional display names for the attachments, aligned 1:1 with
attachmentUrls. Omit and Tuco uses the filename from the URL.boolean
default:"false"
Send a single audio attachment as a native iMessage voice note (the waveform bubble) instead of a generic audio file. Tuco transcodes the audio for you. Requires exactly one audio attachment in
attachmentUrls — see Send with Attachments for the full rules.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 leadId. Tuco matches an existing lead on this number, or
creates one under the Quick Sends list.Required unless you pass leadId or recipientEmail.string
Send to an existing lead using their stored contact details. When provided together with
recipientPhone, the body’s phone number is the send-to address and the lead is used for linking.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, which is uncommon and
impossible to assume. Addressing by phone number is what works.messageType: "email", or iMessage to a contact you already know is
reachable at that Apple ID. Required unless you pass recipientPhone or leadId.string
default:"imessage"
Channel to use. When omitted, defaults to
"imessage".| Value | Channel | Addresses tried (from lead) |
|---|---|---|
"imessage" | iMessage via device relay | phone → altPhone1-3 → email → altEmail1-3 |
"sms" | SMS via Twilio | phone → altPhone1-3 only |
"email" | email → altEmail1-3 only |
string
Tuco line ID to send from. Optional — when omitted, Tuco round-robins
across your workspace’s active lines automatically.
string
Display name for the recipient. Derived from the lead when omitted.
Alternate contact fields
Alternate contact fields
Scheduling & time window
Scheduling & time window
Control when the message is sent. All fields are optional.
When omitted, the message sends immediately (subject to line limits and device gaps).
string
ISO 8601 timestamp to send in the future (e.g.
"2025-10-15T14:30:00Z").string
IANA timezone for the send window (e.g.
"America/New_York"). Required
if using sendWindowStart/sendWindowEnd.string
Earliest time to send,
HH:mm format (e.g. "09:00").string
Latest time to send,
HH:mm format (e.g. "17:00").number[]
Days when sending is allowed.
0 = Sunday, 6 = Saturday.
Example: [1,2,3,4,5] for weekdays only.Fallback & advanced
Fallback & advanced
boolean
default:"false"
When
true, if the message ends in failed status (technical error after
retries), Tuco sends a fallback SMS via your configured fallback number.
If no fallback is configured on the workspace the request is still accepted —
the iMessage send proceeds and simply has no SMS to fall back to. Check
GET /api/workspace/fallback-config
first if you need the fallback to actually fire.boolean
default:"false"
When
true, Tuco skips the iMessage availability check entirely and sends
the message straight via your workspace’s configured fallback — Twilio, GHL, or
a custom webhook (Settings → When iMessage isn’t available). Use it when you
already know the recipient isn’t on iMessage, or you simply want SMS.The send never touches iMessage, so it does not consume the line’s daily
cap or the availability-check budget, and the response status is "fallback".If no fallback is configured — or the fallback dispatch fails (e.g. a bad Twilio
from-number) — the message is not delivered: the response returns
"success": false with a channel-tagged error, and the message is recorded
with "fallbackSmsStatus": "failed". The request itself still succeeds — read the
success flag in the body rather than relying on the status code.Unlike sendFallbackSmsOnFailed (which is reactive — fallback only after an
iMessage send fails), forceFallback is proactive: it never attempts iMessage
in the first place.string | boolean
default:"false"
Workspace-level reply gate. When set, Tuco suppresses this send if the
contact has already replied in your workspace — the same protection
campaigns get from “stop on reply”, now available per request.
false/"false"/ omitted → always send (default, backward compatible)true/"true"→ skip if the contact has ever replied- a window string → skip only if they replied within that window:
"1m","1h","1d","2d","5d","7d","14d","1mo","1yr"(m=minute,h=hour,d=day,w=week,mo=30 days,yr=365 days)
200 with skippedOnly: true (see
Skip if the contact already replied).
An unrecognized value returns 400 INVALID_SKIP_IF_REPLIED.string
Free-form string to group related messages for reporting.
string
Optional tracing ID. If omitted, Tuco generates one (
app_…). The same ID
is preferred from the x-correlation-id request header. It flows through
every internal log event for this send so you can grep one ID and see the entire
request → gate → send → webhook chain. See
API Overview → Correlation IDs.Request headers
Request headers
Headers Tuco recognizes in addition to
Authorization.| Header | Purpose |
|---|---|
x-correlation-id | Request-scoped tracing ID (preferred over body field). Echoed back in logs under correlationId. |
x-execution-id | GHL workflow execution ID. Only set when calling from a GHL workflow context. |
x-ghl-workflow-id | GHL workflow definition ID. Pairs with x-execution-id. |
Idempotency-Key | When supplied, Tuco caches the response and replays it on retry within the idempotency window. Use one unique key per logical send. |
Examples
Create or import a lead first usingPOST /api/leads, then send using leadId as shown below.
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"message": "Hi, this is a test from Tuco.",
"leadId": "667f1f77bcf86cd799439012"
}'
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"message": "Hi from Tuco.",
"leadId": "667f1f77bcf86cd799439013",
"hsContactId": "120001",
"hsPortalId": "991001"
}'
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"message": "Hi from Tuco.",
"leadId": "667f1f77bcf86cd799439014",
"ghlContactId": "ghl_contact_001",
"ghlLocationId": "eL7DD22BdZ0rismu7qCA"
}'
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"message": "Here is the file.",
"leadId": "667f1f77bcf86cd799439012",
"attachmentUrls": ["https://example.com/doc.pdf"]
}'
# The exact body GoHighLevel's "Webhook" workflow action posts. You only fill in
# customData.message — Tuco derives the recipient + GHL identity from the rest.
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"contact_id": "KWqc16yBbIV7hLfiRTse",
"full_name": "Matheus Maia",
"email": "alex@example.com",
"phone": "+13055550100",
"location": { "id": "57m52yUR4RkI2k1VuHhL" },
"customData": { "message": "Hi Matheus" }
}'
Send straight from a GoHighLevel webhook
You can point a GoHighLevel Workflow → Webhook action directly at this endpoint — no GHL Marketplace app or action secret required, just your Tuco API key in theAuthorization header. GHL’s Webhook action already includes the full contact
payload; Tuco reads GHL’s native field names so the only thing you map in Custom
Data is message.
1
Add a Webhook action to your workflow
Method
POST, URL https://app.tuco.ai/api/messages.2
Add the auth header
Authorization = Bearer tuco_xxxxxxxxxxxxx.3
Add Custom Data
Set
message (required). Optionally add lineId (pin a sending line — otherwise
Tuco round-robins) and attachmentUrls (a single URL or comma-separated list).| Canonical field | Filled from (Custom Data first, then GHL native) |
|---|---|
message | customData.message |
fromLineId | customData.lineId |
attachmentUrls | customData.attachmentUrls (string or CSV → array) |
recipientPhone | customData.recipientPhone → top-level phone |
recipientEmail | customData.recipientEmail → top-level email |
recipientName | customData.recipientName → full_name → first_name + last_name |
ghlContactId | customData.ghlContactId → contact_id |
ghlLocationId | customData.ghlLocationId → location.id |
contactOwnerEmail | customData.contactOwnerEmail → user.email |
This mapping is additive and backward compatible — it only fills a field you did
not already send. Existing integrations that post canonical fields (
recipientPhone,
ghlContactId, …) are completely unaffected.If
{{contact.phone}} is empty on the contact, the request fails with
missing_recipient. Make sure the contact has a phone or email before the webhook fires.Response
The API always returns201 when the message is successfully created.
The status field tells you what happens next.
Pre-send checks are never errors. Line limits, time windows, device gaps,
and contact gaps cause
status: "pending" or "scheduled" — the message is
accepted and will send when conditions are met.Success (201 Created or 200 OK when duplicate lead used)
{
"success": true,
"status": "sent",
"message": {
"_id": "69b344bc6dae1d942ece4d0e",
"message": "Here's your document.",
"messageType": "imessage",
"status": "sent",
"fromLineId": "69ab427ac1feb77d7f46462e",
"recipientPhone": "+919876543210",
"recipientEmail": "jane@example.com",
"recipientName": "Jane Doe",
"leadId": "69b2c41d352a78479d2c623b",
"workspaceId": "org_3AZs4H8UsfFxVFONr6H4K75okaG",
"createdAt": "2026-03-12T22:57:00.856Z",
"updatedAt": "2026-03-12T22:57:02.892Z",
"sentAt": "2026-03-12T22:57:02.892Z",
"externalMessageId": "5CA93911-F0FE-4D1F-8D8C-1E3495A124F6"
},
"leadId": "69b2c41d352a78479d2c623b",
"ghlContactId": "aMUQn0u0Z7cw0NQ7tJ5R",
"ghlLocationId": "eL7DD22BdZ0rismu7qCA",
"hsPortalId": null,
"hsContactId": null
}
200 with duplicateOnly: true, existingLeadIds, and leadIds so your automation can continue without treating it as an error.
boolean
true when the message was created.string
Current status of the message. See the status table below.
object
The full message document. Key fields:
Show Message fields
Show Message fields
string
Unique message ID
string
Body text
string
Channel (
imessage, sms, email)string
Lifecycle status
string
Line used to send
string
Recipient phone
string
Recipient email
string
Linked lead ID
string
When the message will send (if scheduled)
string
When actually sent
string
When delivery confirmed
string
Creation timestamp
Status values
| Status | Meaning | Is it an error? |
|---|---|---|
"sent" | Message sent immediately (sync path) | No |
"pending" | Created, worker will process. Line limits / time window / device gap may delay it. | No — it will send when checks pass |
"scheduled" | Will send at scheduledDate, or rescheduled due to device gap | No |
"fallback" | Recipient has no iMessage; fallback SMS sent if configured | No (business rule) |
"failed" | All retries exhausted or availability API error | Yes (technical) |
"pending" and "scheduled" mean the message is accepted and queued. The worker sends it when:- Time window is satisfied (inside
sendWindowStart–sendWindowEnd) - Allowed day is satisfied (today is in
allowedDaysOfWeek) - Line limits reset (daily total or new conversations limit)
- Device gap is met (default 30s between sends from same device)
- Contact gap is met (default 45s between first messages to different contacts on same line)
Duplicate protection
Tuco automatically blocks duplicate sends — no header required. Two POSTs with identical(workspaceId, recipientPhone, recipientEmail, message) within
15 minutes are collapsed: the second one returns 200 with the original
message and does not fire a new send.
HTTP 200 — duplicate caught
{
"success": true,
"duplicateOnly": true,
"duplicateMessage": "Same message text already sent to this contact 42s ago — returning existing message instead of sending again",
"message": { /* the original sibling message doc */ },
"leadId": "..."
}
duplicateOnly === true and treat it as a no-op success. The
message._id in the response is the original send, so your CRM workflow can
keep moving without re-sending.
This works for concurrent firings (the most common cause of accidental
duplicates — an integration retrying within milliseconds) AND for upstream
retries up to 15 minutes apart. The guard is built on an atomic Mongo write,
so concurrent identical POSTs cannot both proceed.
Failed sends: if your first send ended in
status: "failed" and you
immediately retry with the same body within 15 minutes, you’ll get the failed
sibling back with duplicateOnly: true. To force a fresh send: change the
body or wait 15 minutes.Idempotency-Key is still supported and runs before the dedup guard.
Use it when your client controls retries — it gives you a 24-hour window
keyed on the exact request rather than on body content.
Skip if the contact already replied
SetskipIfReplied to avoid following up with a contact who has already responded
to you. Tuco checks your workspace’s inbound history for this recipient (phone or
email) and, if it finds a reply, does not create or send the message. This is the
per-request version of the campaign “stop on reply” rule — useful for drip
automations in n8n/Make/GHL where you don’t want to nudge someone who already wrote
back.
Values
skipIfReplied | Behavior |
|---|---|
omitted / false / "false" | Always send (default). |
true / "true" | Skip if the contact has ever replied in your workspace. |
"1m" "1h" "1d" "2d" "5d" "7d" "14d" "1mo" "1yr" | Skip only if they replied within that window. |
m = minute, h = hour, d = day, w = week, mo = 30 days, yr = 365 days.
General form: <number><unit> (e.g. "3d", "36h"). Note "1m" is one minute and
"1mo" is one month.
Scope is your workspace, not a single line. A reply to any of your lines counts —
which line you send from doesn’t matter. The check is strictly scoped to your workspace
and never sees another workspace’s replies.
Request
Skip the follow-up if they replied in the last 7 days
curl -X POST "https://app.tuco.ai/api/messages" \
-H "Authorization: Bearer tuco_xxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"message": "Just following up — still interested?",
"recipientPhone": "+12025551234",
"skipIfReplied": "7d"
}'
Response when the send is skipped (200 OK)
No message is created. success is true (the request was handled — do not retry)
and skippedOnly distinguishes it from a real send.
HTTP 200 — skipped because the contact already replied
{
"success": true,
"skippedOnly": true,
"skipReason": "recipient_replied",
"skipMessage": "Skipped: this contact already replied in your workspace within the last 7d. The skipIfReplied gate prevented this send.",
"repliedAt": "2026-06-27T18:40:11.020Z",
"message": null,
"leadId": "667f1f77bcf86cd799439012"
}
skippedOnly === true and treat it as a successful no-op. When the contact
has not replied (or replied outside the window), the request proceeds exactly as a
normal send and returns the usual 201 response.
An unrecognized
skipIfReplied value returns 400 with
code: "INVALID_SKIP_IF_REPLIED". Use true, false, or one of the documented
window strings.message.api.skipped_replied,
message.api.skip_if_replied_passed, message.api.skip_if_replied_invalid) under your
correlationId, so you can audit exactly why a send was or wasn’t suppressed.
Errors
Errors are returned only for validation failures — never for pre-send checks.| Status | When | Example |
|---|---|---|
400 | Message and attachments both empty | { "error": "Message and attachmentUrls cannot both be empty" } |
400 | Invalid messageType | { "error": "Invalid messageType. Must be email, sms, or imessage" } |
400 | No active lines + no fromLineId | { "error": "No active lines in workspace..." } |
400 | No recipient anywhere | { "error": "Either recipientEmail or recipientPhone is required..." } |
400 | Unrecognized skipIfReplied value | { "error": "...", "code": "INVALID_SKIP_IF_REPLIED" } |
400 | message still contains a merge field, or a name expanded to nothing | { "error": "Malformed message: name template variable is empty...", "code": "MALFORMED_NAME_TEMPLATE" } |
400 | voiceNote: true with no attachment | { "error": "voiceNote: true needs an audio attachment...", "code": "VOICE_NOTE_NO_ATTACHMENT" } |
400 | voiceNote: true with a non-audio attachment | { "error": "voiceNote: true needs a direct link to an audio file...", "code": "VOICE_NOTE_NOT_AUDIO" } |
400 | fromLineId is not a line in your workspace | { "error": "The specified line does not belong to this workspace.", "code": "LINE_NOT_FOUND" } |
400 | More than 2 attachments | { "error": "Maximum 2 attachments per message" } |
400 | Attachment URL belongs to another workspace | { "error": "Attachment URL does not belong to this organization" } |
400 | Every attachment is unreachable and there is no text | { "error": "No attachment is sendable and the message text is empty...", "code": "ATTACHMENT_UNSENDABLE_NO_TEXT" } |
400 | recipientPhone can’t be dialled (bad NANP area/exchange code) | { "error": "That number can't receive messages (exchange code 019 cannot start with 0 or 1). Message not sent." } |
400 | Bad scheduledDate, sendWindowStart/End, or timezone | { "error": "Invalid scheduledDate format" } |
401 | Invalid or missing API key | { "error": "Unauthorized" } |
402 | Subscription past due (workspace read-only) | { "error": "READ_ONLY", "code": "READ_ONLY", "reason": "past_due" } |
404 | leadId provided but not found | { "error": "Lead not found or access denied" } |
What happens after the API call
1
Message created
Tuco inserts a record in the
messages collection with status pending
(or scheduled if scheduledDate is provided).2
Pre-send checks (worker)
The worker checks line limits, time window, device gap, and contact gap.
If any fail, the message stays queued and is retried later — not failed.
3
Availability check
If the workspace has a line with Private API, Tuco checks whether the
recipient supports iMessage. If not → status becomes
fallback and
fallback SMS fires (when configured).forceFallback: true skips this step entirely — the message goes
straight to the configured fallback channel and never runs an availability
check (so it doesn’t consume the availability-check budget or line cap).4
Send
For individual messages: up to 5 send attempts with delivery
verification between each (polling for up to 90 seconds). For campaigns:
1 attempt per line.
5
Outcome
sent → appears in Unibox + webhook fires.
failed → error recorded + optional fallback SMS.
delivered → confirmed later via device callbacks.Lead resolution
When you send without aleadId, Tuco resolves the recipient automatically:
Lead found by phone/email
Lead found by phone/email
If an existing lead in your workspace matches the
recipientPhone or
recipientEmail, the message is linked to that lead. Any altPhone/altEmail
fields you pass will update the lead (merge, not overwrite).New lead created (Quick Sends)
New lead created (Quick Sends)
If no matching lead exists, Tuco creates one under a list called “Quick Sends”
(created automatically if it doesn’t exist). Alt contact fields are stored on
the new lead.
leadId provided
leadId provided
The lead must exist in your workspace. The message is linked to it.
recipientPhone/recipientEmail from the body override the lead’s stored
contact for this specific send.Alt contact fields
When you passaltPhone1–altPhone3 or altEmail1–altEmail3, they are
stored on the lead (not the message). The worker uses them as fallback
addresses when the primary address fails the iMessage availability check.
Priority order for messageType: "imessage":
phone → altPhone1 → altPhone2 → altPhone3 → email → altEmail1 → altEmail2 → altEmail3
Alt fields are merged onto the lead. If a lead already has
altPhone1 set
and you send a new message without altPhone1, the existing value is preserved.
Only explicitly provided fields are overwritten.Fallback SMS on failure
By default, when a message fails (technical error), no SMS fallback is sent. To enable:- Configure Twilio on your workspace (
GET /api/workspace/fallback-config). - Set
sendFallbackSmsOnFailed: truein the request body (per-message) or on the workspace (applies to all messages).
Setting
sendFallbackSmsOnFailed: true without a configured fallback is not an error.
The request returns 201 and the iMessage send goes ahead as normal — there is simply no
SMS to fall back to if it fails. Read
GET /api/workspace/fallback-config to confirm
a fallback is set up before you rely on it.| Scenario | Fallback SMS fires? |
|---|---|
Status = fallback (no iMessage), Twilio configured | Yes — always |
Status = failed, sendFallbackSmsOnFailed: true, Twilio configured | Yes |
Status = failed, sendFallbackSmsOnFailed: false (default) | No |
Status = failed, flag is true but no fallback configured | No — the send is accepted, there is just nothing to fall back to |
Fallback SMS requires
recipientPhone on the message. Email-only messages
will not trigger fallback SMS.