Skip to main content

Overview

Message webhooks push status updates to a URL you control whenever a message reaches a terminal state. Configure them in your workspace settings — Tuco handles delivery automatically.
Webhook calls are POST requests with a JSON body. All events share the same base fields; each event type adds its own specific fields. Outgoing message events are fired when a message is sent (accepted by Tuco), not when delivery is confirmed on the recipient’s device.
Test before you go live. Send a test delivery for any event and variant — it is built by the same code as a live delivery, so if your parser handles the test it handles production. Every delivery (test or live) is in the delivery log with the exact body sent and your response, and any one can be replayed. The same tools are in the dashboard under Integrations → Webhooks.

Event types


message.ai_draft — review AI drafts from your own tools

When your knowledge base runs in Copilot mode, every AI-drafted reply fires message.ai_draft before anything is sent. The payload carries the draft text on message (string) and a data.replyId handle:
Approve (sends the draft) or reject it with your API key:
action is approve (claims the draft and sends it), reject, or mark_sent (claim only — you already delivered the text yourself). The claim is atomic and first-action-wins across every channel, so an approval from Telegram, the dashboard, and your API call can never double-send. A 409 response means another channel already actioned the draft. Every resolution then fires message.ai_draft_approved or message.ai_draft_rejected — regardless of which channel actioned it — with data.approvalChannel (dashboard, api, telegram, telegram_edit, telegram_retry, unibox_direct_send) so your workflow can log who approved what, including approvals that never touched your tools.

message.reply

Sent when a new reply from a lead or contact is recorded. Use it to update your CRM, trigger automations, or log conversations.
When a contact owner is set (contactOwnerEmail), reply email notifications are routed only to the contact owner rather than all workspace users.
Payload shape: Top-level flat fields; data.reply is identical to one item in GET /api/replies (same keys, same types). Reply text is top-level message (string). parentMessages is always present (array; possibly empty).

Top-level fields

message (reply text only)

For message.reply, message is the reply text only (string) — what the lead or contact replied with.

data.reply — matches GET /api/replies item exactly

data.reply has the exact same shape as one element of GET /api/replies replies array: Plus recentReplies — the last 10 inbound replies from this lead (array, possibly empty): Example payload:

Reply with a photo/file

When the lead replies with an image or file, the same payload additionally carries attachmentDownloadUrls + attachmentNames (top-level and inside data.reply / its recentReplies). GET a download URL to store the bytes — no Authorization header needed, the signature is the capability:
Multiple attachments per reply. A single reply can carry many photos/files — attachmentDownloadUrls and attachmentNames are parallel arrays with one entry per attachment (there is no longer a 2-attachment cap; the full set is captured).Large bursts arrive progressively. When a lead sends a big batch (e.g. 20 photos), the first few are attached to the message.reply webhook right away and the rest are ingested in the background — the underlying iMessage server hands each attachment over slowly, so a large burst takes a couple of minutes to finish. The webhook is not re-sent as the remainder land, so to get the complete set for a messageId, call GET /api/replies a minute or two after the reply.
Each attachmentDownloadUrls link is a signed, 30-day capability to read that one file. Fetch and store the bytes in your own system rather than persisting the URL. Need a fresh link later? Call GET /api/replies or wait for the next message.reply.

message.sent

Sent when a message has been sent (accepted by Tuco). This is the only outgoing-message event fired to your webhook; Tuco does not fire a separate event when delivery is confirmed on the recipient’s device. Payload: The message field is the full message object (not a string), with _id, message, messageType, status, fromLineId, recipientPhone, recipientEmail, recipientName, leadId, workspaceId, createdByUserId, sentAt, createdAt, updatedAt, etc. Top-level also includes leadId, lead (full lead when available), and integration IDs.

Full example body

string
"message.sent"
string
How the message actually left: imessage, p2p_sms, email, or call. See P2P SMS.
string
Tuco message ID
object
Full message document (not string)
string
Lead ID when known
object
Full lead when available
string | null
GHL contact ID for integrations
string | null
GHL location ID for integrations
string
When the webhook was fired (ISO UTC)
message.sent fires exactly once per message, whichever way the send was confirmed.

P2P SMS (green bubble from your line)

When a line has P2P SMS on and the recipient isn’t on iMessage, the line’s paired iPhone sends a regular SMS from the line’s own number. That is a successful send by your line, so you get message.sent with "channel": "p2p_sms", and no message.fallback. The message object keeps messageType: "imessage" (what was requested) and carries smsForwarded: true, so read channel rather than messageType to know how it actually went out. Abbreviated: only the fields that differ from the full example above are shown.
P2P SMS is different from fallback. Fallback (message.fallback) means the message went out through your workspace’s fallback provider (Twilio, GoHighLevel, or your own fallback webhook) from a different number. A message never produces both.

message.reaction

Sent when a lead or contact reacts to an existing message in a conversation. Tuco treats reactions differently from replies:
  • they are stored as reactions on the original message
  • they do not become a normal text reply
  • they do not trigger AI reply handling

Top-level fields

Example payload


message.failed

Sent once when a message definitely did not go out: message.failed is not sent when delivery simply could not be confirmed (the message may still have arrived — check the conversation), or when Tuco declines to send a message (for example a link or attachment in the very first message to a new contact). The message field is the full message object, including errorMessage, failureReason (line_down_expired or transient_exhausted) and failureClass. data.error repeats the reason.

Full example body

string
"message.failed"
object
Full message document (includes errorMessage)
string
Human-readable explanation of the failure
Tuco does not send an SMS because of message.failed. To retry another way, do it from your workflow when this event arrives. (Recipients without iMessage are a different case: see message.fallback.)

message.fallback

Sent when Tuco determines that the primary channel (e.g. iMessage) is not available for this recipient. This is a business outcome, not a technical failure. The message field is the full message object. data includes reason and optionally checkedAddresses (string[]).

Full example body

string
"message.fallback"
object
Full message document (status fallback)
string
Human-readable explanation
string[]
All addresses Tuco checked for iMessage availability before deciding fallback. Useful for debugging and auditing.

Common patterns

Auto-send SMS

Trigger an SMS via your own Twilio/fallback when you receive message.fallback. Or enable Tuco’s built-in fallback SMS on the workspace.

Tag lead in CRM

Flag the lead as “no iMessage” so future sends pick the right channel automatically.

message.opened

Sent when the recipient has opened/read the message (read receipt). Fired where the channel supports read receipts. Use it to track engagement or trigger follow-ups. Payload: Same top-level shape as message.sent. The message field is the full message object (not a string), including readAt (ISO 8601 UTC) when the message was read. data may include readAt.

Full example body

string
"message.opened"
object
Full message document including readAt (ISO UTC when read)
string
When the message was opened (ISO UTC)

conversation.handover

Fires once when the AI SDR decides a conversation needs a person and stops replying to it. This is the event to wire to your routing, your inbox or your on-call rota — it is the only signal that a lead is waiting on a human.
The AI is already paused when this fires (data.handover.aiPaused is always true). Nothing further is sent on that conversation until a person takes it.
Payload shape

data.handover

string
The contact the conversation is with — phone in E.164, or email.
string
What tripped the handover, e.g. a rule name or detected intent.
string
Plain-English explanation of why the bot stopped. Safe to show a rep.
number
Model confidence in the handover decision, 0–1. Omitted when not scored.
string
The lead’s message that triggered it. Omitted when there is none.
string
What the AI would have said, held back. Hand this to whoever picks the conversation up so the lead does not have to repeat themselves. Omitted when there was no draft.
string
Owner of the contact, for routing. Also present at the top level.
string
ISO 8601 timestamp of the handover.
boolean
Always true. The AI has stopped replying to this conversation.
leadId and contactOwnerEmail are also lifted to the top level so you can route on owner without reaching into data.handover. They are omitted when unknown.

Shared fields (all events)

Every webhook payload includes these base fields:

call.recording.ready

Sent once per call, when the call’s recording has been stored. Use data.recordingDownloadUrl to fetch the audio: it is a signed, public link valid for 30 days and needs no Authorization header. data.recordingUrl is the raw storage location and is not fetchable with an API key.

Full example body

string
Tuco call id.
string
Number the call was placed from (your line), E.164.
string
Number that was called, E.164.
number | null
Talk time in seconds.
string | null
Call status when the recording was stored, e.g. billed.
string
Signed link that streams the audio (30 days).
string
File name, e.g. call.mp3.

dnc.added

Sent once per new Do-Not-Contact entry. Use it to stop the same person on your other channels (WhatsApp, email, dialer) the moment they opt out of iMessage. It fires when:
  • a contact replies with an opt-out keyword (STOP, UNSUBSCRIBE, OPT OUT, REMOVE ME, DO NOT CONTACT) on iMessage, or a carrier keyword (STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) to your Telnyx or Twilio fallback number
  • someone adds a contact in the dashboard, or you call POST /api/dnc
A second STOP from someone already on the list does not fire again. Bulk CSV and list imports, and DNC mirrored in from GoHighLevel or HubSpot, do not fire — read those with GET /api/dnc?since=. The reply itself still arrives as message.reply with optedOut: true. Try it without waiting for a real opt-out:
Variants: opt_out (replied STOP) and manual (added via the dashboard or API).

Full example body

string
DNC entry id. Same id GET /api/dnc returns, so you can dedupe across the webhook and polling.
string | null
Phone number, E.164.
string | null
Email address, lower case.
string
opt-out for a reply, otherwise the reason given when it was added (default manual).
string
keyword-<WORD> for a reply (e.g. keyword-STOP), api for POST /api/dnc with an API key, manual-ui for the dashboard.
string
The opt-out text they sent. Only present for replies.
string
When the entry was added (ISO 8601, UTC).

Headers & signature verification

Every webhook POST includes these headers:

Delivery guarantees

  • Each event fires once per webhook. message.sent fires once per message, whichever way the send was confirmed; message.reply once per inbound message; message.failed, message.fallback, message.opened, message.reaction and the message.ai_draft* events once per message, reaction or draft.
  • If your endpoint does not answer 2xx within 10 seconds, Tuco retries once after 5 seconds with the same X-Tuco-Delivery-Id. If your endpoint already processed the first attempt (it was just slow), drop the retry by that id.
  • Every delivery is kept for 30 days in the delivery log, with the exact body sent and your endpoint’s response.
Verify the signature against the raw request body exactly as received. Re-serializing a parsed body can change it and make a valid signature fail.
Verify signatures to confirm webhooks actually came from Tuco:
Your webhook secret is shown once when you create the webhook. Save it immediately. It can’t be retrieved later. If you lose it, rotate the secret to get a new one.

Consuming webhooks safely

1

Use messageId + event as your key

Make updates idempotent. If you’ve already processed a (messageId, event) pair, ignore duplicates.
2

Move status forward only

Only update your local status in the forward direction (for example sent after queued). Never move backwards.
3

Return 200 quickly

Tuco expects a 2xx response within a few seconds. Do heavy processing asynchronously after acknowledging the webhook.
4

Log workspaceId and leadId

These fields let you trace events back to the right tenant and contact in multi-workspace setups.
Tuco does not retry webhook delivery. Each event fires once (fire-and-forget). If your endpoint is down or returns a non-2xx status, that delivery is lost. Make sure your endpoint is reliable and returns 2xx quickly. You can use the health-check endpoint to verify your endpoint is reachable.