Skip to main content
POST
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.

Request body

string
The text content to send. Required unless attachmentUrls is provided (you can send attachments only, or message + attachments).Template variables are filled in at send time — on every send attempt, against the line that actually sends the message (so retries, scheduled or send-window-deferred messages, and round-robin all name the right line) and the contact’s current data. The same variables campaigns use:Single {x} or double {{x}} braces, any case. Add a fallback after a pipe: {firstName|there}. Variables work inside links too (https://cal.com/{line_first_name}). Anything in braces that is not one of these is sent exactly as written.A blank is never sent. If a variable has no value and no fallback, the request is rejected: an empty {firstName} / {lastName} → 400 MALFORMED_NAME_TEMPLATE; any other → 400 TEMPLATE_VARIABLE_UNRESOLVED (the response lists them in unresolvedVariables). Text you already filled in yourself is unaffected; an unfilled token from another tool (e.g. {{contact_name}}, {name}) or a greeting whose name expanded to nothing ("Hey , …") is still rejected with MALFORMED_NAME_TEMPLATE.
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.With an attachment or voice note, you get status: "sent" right away and it starts sending. If it fails, you get a message.failed webhook for the same message._id.
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.
Only useful in two cases: 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".
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

Stored on the lead (created or found). The worker tries these in priority order when the primary address fails iMessage availability.
string
Alternate phone number 1
string
Alternate phone number 2
string
Alternate phone number 3
string
Alternate email 1
string
Alternate email 2
string
Alternate email 3

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. "2026-10-15T14:30:00Z"). The response status is "scheduled". If you also set a send window and this time falls outside it, the message is moved to the next time the window is open. A value that can’t be parsed returns 400.
string
Earliest time to send, HH:mm 24-hour format (e.g. "09:00"), inclusive. Set both sendWindowStart and sendWindowEnd — if only one is sent, the window is ignored. Outside the window the request still returns 201 with status: "scheduled"; the message is held until the window opens. Use "00:00"–"23:59" for any hour. Anything that isn’t HH:mm returns 400.
string
Latest time to send, HH:mm (e.g. "17:00"). The end is exclusive, and it must be later than the start. An overnight window such as "22:00"–"06:00" returns 400 INVALID_SEND_WINDOW; use "00:00"–"23:59" to send at any hour.
string
IANA timezone for the send window (e.g. "America/New_York"). Optional. When omitted, your workspace timezone is used (or America/New_York if the workspace has none). Use a full name — an abbreviation like "EST" or an unknown name returns 400 INVALID_TIMEZONE when a send window is set. "UTC" is accepted. The timezone is only used together with a send window.
number[]
Days when sending is allowed, as whole numbers. 0 = Sunday, 6 = Saturday. Example: [1,2,3,4,5] for weekdays only. An empty array means every day; any other value returns 400 INVALID_ALLOWED_DAYS. Only applies together with sendWindowStart and sendWindowEnd — a day list without hours is ignored. On a day that isn’t allowed, the message waits for sendWindowStart on the next allowed day. See Send window and allowed days.
boolean
default:"false"
Keep the wait between follow-ups to the same contact. When true, and an earlier message to this contact went out late, this message is held so the original spacing is preserved: it waits until the previous message’s actual send time plus the gap between your two API calls. Works on its own — no send window or workspace setting needed. Accepts true or "true"; any other value is treated as false. While held, the response status is "scheduled". See Keep the wait between follow-ups.

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.
  • 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)
Scope is your workspace — a reply to any of your lines counts (not just the sending line). The check never crosses workspaces. On a skip, no message is created and the API returns 200 with skippedOnly: true (see Skip if the contact already replied). The check runs again right before a queued message sends: if the contact replied while it was waiting, it is cancelled instead of sent. 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.
Headers Tuco recognizes in addition to Authorization.

Examples

Create or import a lead first using POST /api/leads, then send using leadId as shown below.

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 the Authorization 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).
Tuco auto-maps GHL’s native fields onto the canonical request fields. Custom Data always wins — set any field there to override what’s derived from the contact:
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 returns 201 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)

When the recipient was matched to an existing lead (duplicate), the API returns 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:

Accepted but held

When a send window, an allowed-day rule or respectGap holds the message, the request still succeeds. status is "scheduled" and message.scheduledDate is when it will be released.
HTTP 201 — outside the send window, held until it opens
message.settings echoes the window Tuco stored: the resolved timezone, and allowedDaysOfWeek as numbers.

Status values

"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)
  • Follow-up gap has passed (only when respectGap is true)
  • 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)
None of these cause an error response.

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
Branch on 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.
For deeper detail, edge cases, and ops events, see Atomic Duplicate Guard. 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

Set skipIfReplied 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 useful for drip automations in n8n/Make/GHL where you don’t want to nudge someone who already wrote back.

Values

Units: 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

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
Branch on 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.
Every decision is logged internally (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.

A queued message is checked again before it sends

skipIfReplied is evaluated when you call the API and again right before the message is sent. If a message is waiting — for a scheduledDate, a send window, a line limit, a line that is down, or a respectGap hold — and the contact replies in the meantime, the message is cancelled instead of sent (its status becomes cancelled). With a window value such as "1h", the second check looks back from the moment of sending, not from when you made the request.

Keep the wait between follow-ups

Automations usually space follow-ups out: message 1, wait, message 2. If message 1 goes out late — held by a send window, a line limit, pacing, or a line that was briefly down — message 2 can land right behind it and the wait you designed disappears. Set respectGap: true on the follow-up to keep that wait.

How the hold is calculated

Tuco looks at the most recent message already sent to the same contact in your workspace and holds the new one until:
So the gap between your two API calls is preserved, measured from when the previous message really went out. It does not use scheduledDate.

Request

Follow-up that keeps its spacing
The response is the usual 201. When the message is being held, status is "scheduled" and scheduledDate is the time it is held until:
HTTP 201 — held to keep the gap

What to expect

  • Works without a send window. respectGap needs no sendWindowStart, sendWindowEnd or timezone.
  • It is per contact, across all your lines. The previous message can have gone out from a different line. Messages to other contacts are never affected.
  • The first message to a contact is never held — there is nothing to space it from.
  • “Previous message” means the latest one sent to that contact, whatever sent it. A manual reply from the inbox in between becomes the new reference point.
  • The gap is a minimum. Held messages are released about once a minute, so the real spacing is the gap plus up to a minute.
  • A send window still wins. If the hold ends outside sendWindowStart–sendWindowEnd (or on a day not in allowedDaysOfWeek), the message waits for the window to open.
  • If the earlier message went out on time, nothing changes — the follow-up sends normally.
  • Combine it with skipIfReplied to drop the follow-up if the contact answers while it is being held.
  • Only true or "true" turn it on. Any other value is treated as off.
respectGap spaces a follow-up from a message that has already been sent. If the earlier message is still waiting when the follow-up arrives, there is nothing to measure from yet, so respectGap does not hold the follow-up. With several lines, if the earlier message is pinned to a line that is down (fromLineId) and the follow-up is not pinned, the follow-up can go out first on a healthy line — pin both to the same line, or leave both unpinned, to avoid that.

Send window and allowed days

Request

Weekdays, 9am–6pm New York
Inside the window on an allowed day the response is the normal 201 with status: "sent". Outside it you get 201 with status: "scheduled" — see Accepted but held.

Rejected values (400)

Day numbers sent as strings (["1","2","3"]) are accepted and stored as numbers.

The window is checked again every time the message is about to send

The window you put on a message is checked when you call the API and again each time Tuco is about to send it. So if something delays the message — a failed attempt that is retried a few minutes later, a line that was briefly down, a daily or hourly limit — and the window has closed in the meantime, the message waits for the window to open again instead of going out late. allowedDaysOfWeek is applied the same way: on a day that isn’t allowed, the message waits for sendWindowStart on the next allowed day. This applies to every message sent through the API that carries its own sendWindowStart and sendWindowEnd. These checks only run when a send window is set. Without sendWindowStart and sendWindowEnd, timezone and allowedDaysOfWeek are ignored.

Errors

Errors are returned only for validation failures — never for pre-send checks.

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).
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 a leadId, Tuco resolves the recipient automatically:
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).
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.
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 pass altPhone1–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":
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:
  1. Configure Twilio on your workspace (GET /api/workspace/fallback-config).
  2. Set sendFallbackSmsOnFailed: true in 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.
Fallback SMS requires recipientPhone on the message. Email-only messages will not trigger fallback SMS.