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>"
],
"recipientPhone": "<string>",
"recipientEmail": "<string>",
"messageType": "<string>",
"fromLineId": "<string>",
"leadId": "<string>",
"recipientName": "<string>"
}
'import requests
url = "https://app.tuco.ai/api/messages"
payload = {
"message": "<string>",
"attachmentUrls": ["<string>"],
"recipientPhone": "<string>",
"recipientEmail": "<string>",
"messageType": "<string>",
"fromLineId": "<string>",
"leadId": "<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>'],
recipientPhone: '<string>',
recipientEmail: '<string>',
messageType: '<string>',
fromLineId: '<string>',
leadId: '<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>'
],
'recipientPhone' => '<string>',
'recipientEmail' => '<string>',
'messageType' => '<string>',
'fromLineId' => '<string>',
'leadId' => '<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 \"recipientPhone\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"leadId\": \"<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 \"recipientPhone\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"leadId\": \"<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 \"recipientPhone\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"leadId\": \"<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>"
],
"recipientPhone": "<string>",
"recipientEmail": "<string>",
"messageType": "<string>",
"fromLineId": "<string>",
"leadId": "<string>",
"recipientName": "<string>"
}
'import requests
url = "https://app.tuco.ai/api/messages"
payload = {
"message": "<string>",
"attachmentUrls": ["<string>"],
"recipientPhone": "<string>",
"recipientEmail": "<string>",
"messageType": "<string>",
"fromLineId": "<string>",
"leadId": "<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>'],
recipientPhone: '<string>',
recipientEmail: '<string>',
messageType: '<string>',
fromLineId: '<string>',
leadId: '<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>'
],
'recipientPhone' => '<string>',
'recipientEmail' => '<string>',
'messageType' => '<string>',
'fromLineId' => '<string>',
'leadId' => '<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 \"recipientPhone\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"leadId\": \"<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 \"recipientPhone\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"leadId\": \"<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 \"recipientPhone\": \"<string>\",\n \"recipientEmail\": \"<string>\",\n \"messageType\": \"<string>\",\n \"fromLineId\": \"<string>\",\n \"leadId\": \"<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>"
}
}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_sk_xxxxxxxxxxxxx
Request body
string
The text content to send. Required unless
attachmentUrls is provided (you can send attachments only, or message + attachments). Personalization placeholders like {{firstName}} are resolved from the lead at send time.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 (CAF), and PDF. Supported sources: any public URL, UploadThing/Blob URLs that belong to your workspace.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
Phone number in E.164 format (e.g.
"+12025551234").
Required unless recipientEmail or leadId is provided.string
Email address for email or iMessage (Apple ID).
Required unless
recipientPhone or leadId is provided.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
Send to an existing lead using their stored contact details. When provided
together with
recipientPhone/recipientEmail, the body recipient is used
as the send-to address while the lead is used for linking.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 Twilio number.
Returns 400 with code: "FALLBACK_NOT_CONFIGURED" if enabled but no
fallback is set up on your workspace.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". (This is distinct from the
sendFallbackSmsOnFailed 400 — forceFallback always returns 200 with the
success flag in the body.)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_sk_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_sk_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_sk_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_sk_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_sk_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_sk_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_sk_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 | sendFallbackSmsOnFailed: true but fallback not configured | { "error": "...", "code": "FALLBACK_NOT_CONFIGURED" } |
400 | Unrecognized skipIfReplied value | { "error": "...", "code": "INVALID_SKIP_IF_REPLIED" } |
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).
If you set
sendFallbackSmsOnFailed: true but fallback is not configured,
the API returns 400 with code: "FALLBACK_NOT_CONFIGURED".| 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 Twilio | 400 error at API call time |
Fallback SMS requires
recipientPhone on the message. Email-only messages
will not trigger fallback SMS.