> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuco.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Message Webhooks

> Real-time notifications for message sent, failure, fallback, reply, and AI-draft review events. Part of the Tuco AI iMessage API reference — bearer-token auth, JSON responses.

## 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.

<Info>
  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.
</Info>

***

## Event types

| Event                       | When                                                                  | Payload `message` type                      | Status field |
| --------------------------- | --------------------------------------------------------------------- | ------------------------------------------- | ------------ |
| `message.sent`              | Message was successfully sent (accepted by Tuco)                      | **Object** (full message doc)               | `"sent"`     |
| `message.failed`            | Message failed after all retries (technical error)                    | **Object** (full message doc)               | `"failed"`   |
| `message.fallback`          | Recipient has no iMessage; primary channel unavailable                | **Object** (full message doc)               | `"fallback"` |
| `message.opened`            | Recipient opened/read the message (read receipt; when supported)      | **Object** (full message doc with `readAt`) | —            |
| `message.reply`             | A lead or contact replied to a message you sent                       | **String** (reply text only)                | —            |
| `message.reaction`          | A lead reacted to one of your messages                                | **Object** (reaction event payload)         | —            |
| `message.ai_draft`          | AI copilot drafted a reply for review                                 | **String** (draft text only)                | —            |
| `message.ai_draft_approved` | Draft approved & sent (any channel: dashboard, Telegram, unibox, API) | **String** (draft text only)                | —            |
| `message.ai_draft_rejected` | Draft rejected (any channel)                                          | **String** (draft text only)                | —            |

***

## `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:

```json theme={null}
{
  "event": "message.ai_draft",
  "timestamp": "2026-03-12T21:20:02.114Z",
  "workspaceId": "org_abc123",
  "leadId": "69b2c41d352a78479d2c623b",
  "lead": { "firstName": "Jane", "lastName": "Doe", "phone": "+15551230001" },
  "message": "Sure — the onboarding call runs about 30 minutes. Thursday 2pm work?",
  "data": {
    "replyId": "69b330a1cad37b94a846ef01",
    "draftMessage": "Sure — the onboarding call runs about 30 minutes. Thursday 2pm work?",
    "conversationRecipient": "+15551230001",
    "inboundText": "How long is the onboarding call?",
    "confidence": 0.92,
    "mode": "copilot",
    "approveEndpoint": "/api/knowledge-base/auto-replies"
  }
}
```

**Approve (sends the draft) or reject it** with your API key:

```bash theme={null}
curl -X POST https://app.tuco.ai/api/knowledge-base/auto-replies \
  -H "Authorization: Bearer tuco_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "replyId": "69b330a1cad37b94a846ef01", "action": "approve" }'
```

`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.

<Info>
  When a contact owner is set (`contactOwnerEmail`), reply email notifications are routed only to the contact owner rather than all workspace users.
</Info>

**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

| Field                    | Description                                                                                                                                                                                                                  |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event`                  | `"message.reply"`                                                                                                                                                                                                            |
| `timestamp`              | When the webhook was sent (ISO UTC)                                                                                                                                                                                          |
| `workspaceId`            | Workspace that owns the conversation                                                                                                                                                                                         |
| `messageId`              | ID of the **reply** message (the one they sent)                                                                                                                                                                              |
| `leadId`                 | Lead ID when the reply is linked to a lead. Omitted if no lead could be matched.                                                                                                                                             |
| `firstName`              | Lead first name (when lead is present). Backward-compat flat field.                                                                                                                                                          |
| `lastName`               | Lead last name (when lead is present). Backward-compat flat field.                                                                                                                                                           |
| `phone`                  | Lead phone (when lead is present). Backward-compat flat field.                                                                                                                                                               |
| `listId`                 | Lead list ID (when lead is present). Backward-compat flat field.                                                                                                                                                             |
| `campaignId`             | Campaign ID when the reply is tied to a campaign.                                                                                                                                                                            |
| `campaignName`           | Campaign name when tied to a campaign.                                                                                                                                                                                       |
| `fromLineId`             | Line ID that received the reply. Backward-compat flat field.                                                                                                                                                                 |
| `recipientName`          | Display name (lead name or recipient). Backward-compat flat field.                                                                                                                                                           |
| `repliedAtUtc`           | When the reply was received (UTC ISO 8601). **Always present.**                                                                                                                                                              |
| `parentMessages`         | Last 2 outbound messages you sent to this lead. **Always present** (array; empty if none). Same shape as GET /api/replies.                                                                                                   |
| `message`                | **Reply text only** (string) — what they replied with.                                                                                                                                                                       |
| `attachmentDownloadUrls` | **Present only when the lead's reply included a photo/file.** Public, signed, 30-day URLs — `GET` each one (no auth header) to download the image/file bytes with the correct `Content-Type`. Omitted for text-only replies. |
| `attachmentNames`        | Filenames aligned 1:1 with `attachmentDownloadUrls` (e.g. `["IMG_1380.jpeg"]`). Omitted for text-only replies.                                                                                                               |
| `attachmentUrls`         | Raw private-storage URLs for reference. These return `403` on their own — use `attachmentDownloadUrls` to fetch the bytes.                                                                                                   |
| `originalMessageId`      | ID of the message they replied to (when known)                                                                                                                                                                               |
| `originalMessage`        | Original outbound message object (when known)                                                                                                                                                                                |
| `lead`                   | Full lead object when available. Omitted if no lead could be matched.                                                                                                                                                        |

### `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:

| Field                    | Description                                                                                                                         |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `leadId`                 | Lead ID (string)                                                                                                                    |
| `messageId`              | Reply message ID                                                                                                                    |
| `respondedAtUtc`         | When the reply was received (UTC ISO)                                                                                               |
| `recipientEmail`         | Recipient email or undefined                                                                                                        |
| `recipientPhone`         | Recipient phone or undefined                                                                                                        |
| `name`                   | Display name (lead name or recipient)                                                                                               |
| `attachmentDownloadUrls` | Signed, fetchable download URLs for the latest reply's attachments (only when it had any).                                          |
| `attachmentNames`        | Filenames aligned 1:1 with `attachmentDownloadUrls`.                                                                                |
| `parentMessages`         | Array of `{ messageId, message, sentAtUtc, batchId, stepIndex }` (same as API)                                                      |
| `recentReplies`          | Array of `{ messageId, message, receivedAtUtc, attachmentDownloadUrls?, attachmentNames? }` — last 10 inbound replies (same as API) |

Plus `recentReplies` — the last 10 inbound replies from this lead (array, possibly empty):

| Field                    | Description                                                                         |
| ------------------------ | ----------------------------------------------------------------------------------- |
| `messageId`              | Reply message ID                                                                    |
| `message`                | Reply text                                                                          |
| `receivedAtUtc`          | When the reply was received (UTC ISO)                                               |
| `attachmentDownloadUrls` | Signed, fetchable download URLs — present only on replies that carried attachments. |
| `attachmentNames`        | Filenames aligned 1:1 with `attachmentDownloadUrls`.                                |

Example payload:

```json theme={null}
{
  "event": "message.reply",
  "timestamp": "2025-03-02T15:00:00.000Z",
  "workspaceId": "org_xxx",
  "messageId": "674abc...",
  "leadId": "667f1f77bcf86cd799439012",
  "firstName": "Jane",
  "lastName": "Doe",
  "phone": "+12025551234",
  "listId": "674list...",
  "campaignId": "674...",
  "campaignName": "Q1 Outreach",
  "fromLineId": "699f704bc405fae6ca299a08",
  "recipientName": "Jane Doe",
  "repliedAtUtc": "2025-03-02T14:59:58.000Z",
  "parentMessages": [
    { "messageId": "674def...", "message": "Hi Jane, we have an offer...", "sentAtUtc": "2025-03-01T10:00:00.000Z", "batchId": "campaign_674..._0", "stepIndex": 0 }
  ],
  "message": "Thanks, I'm interested!",
  "data": {
    "reply": {
      "leadId": "667f1f77bcf86cd799439012",
      "messageId": "674abc...",
      "respondedAtUtc": "2025-03-02T14:59:58.000Z",
      "recipientEmail": "jane@example.com",
      "recipientPhone": null,
      "name": "Jane Doe",
      "parentMessages": [
        { "messageId": "674def...", "message": "Hi Jane, we have an offer...", "sentAtUtc": "2025-03-01T10:00:00.000Z", "batchId": "campaign_674..._0", "stepIndex": 0 }
      ],
      "recentReplies": [
        { "messageId": "674abc...", "message": "Thanks, I'm interested!", "receivedAtUtc": "2025-03-02T14:59:58.000Z" }
      ]
    },
    "repliedAtUtc": "2025-03-02T14:59:58.000Z",
    "parentMessages": [ { "messageId": "674def...", "message": "Hi Jane, we have an offer...", "sentAtUtc": "2025-03-01T10:00:00.000Z", "batchId": "campaign_674..._0", "stepIndex": 0 } ]
  },
  "originalMessageId": "674def...",
  "lead": { "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com" }
}
```

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

```json theme={null}
{
  "event": "message.reply",
  "workspaceId": "org_xxx",
  "messageId": "674abc...",
  "message": "here's the damage on my car",
  "attachmentDownloadUrls": [
    "https://app.tuco.ai/api/attachments/crm-media/IMG_1380.jpeg?u=aHR0cHM...&exp=1786752000000&sig=Vh9k..."
  ],
  "attachmentNames": ["IMG_1380.jpeg"],
  "attachmentUrls": ["https://abc.private.blob.vercel-storage.com/workspaces/org_xxx/attachments/2996...-IMG_1380.jpeg"],
  "data": {
    "reply": {
      "leadId": "667f1f77bcf86cd799439012",
      "messageId": "674abc...",
      "respondedAtUtc": "2025-03-02T14:59:58.000Z",
      "attachmentDownloadUrls": ["https://app.tuco.ai/api/attachments/crm-media/IMG_1380.jpeg?u=...&exp=...&sig=..."],
      "attachmentNames": ["IMG_1380.jpeg"],
      "recentReplies": [
        { "messageId": "674abc...", "message": "here's the damage on my car", "receivedAtUtc": "2025-03-02T14:59:58.000Z",
          "attachmentDownloadUrls": ["https://app.tuco.ai/api/attachments/crm-media/IMG_1380.jpeg?u=...&exp=...&sig=..."],
          "attachmentNames": ["IMG_1380.jpeg"] }
      ]
    }
  }
}
```

<Warning>
  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`](/api-reference/endpoint/get-replies) or wait for the next `message.reply`.
</Warning>

***

## `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), `campaignId`, `campaignName`, and integration IDs.

### Full example body

```json theme={null}
{
  "event": "message.sent",
  "timestamp": "2026-03-12T22:57:03.000Z",
  "workspaceId": "org_3AZs4H8UsfFxVFONr6H4K75okaG",
  "messageId": "69b344bc6dae1d942ece4d0e",
  "message": {
    "_id": "69b344bc6dae1d942ece4d0e",
    "message": "Here's your document.",
    "messageType": "imessage",
    "status": "sent",
    "fromLineId": "69ab427ac1feb77d7f46462e",
    "recipientPhone": "+919042956129",
    "recipientEmail": "goforbg@icloud.com",
    "recipientName": "BG Real",
    "leadId": "69b2c41d352a78479d2c623b",
    "workspaceId": "org_3AZs4H8UsfFxVFONr6H4K75okaG",
    "createdByUserId": "user_3AZrxEi8CR2uMq9simYrwcJpfsL",
    "sentAt": "2026-03-12T22:57:02.892Z",
    "createdAt": "2026-03-12T22:57:00.856Z",
    "updatedAt": "2026-03-12T22:57:02.892Z"
  },
  "leadId": "69b2c41d352a78479d2c623b",
  "lead": { "_id": "69b2c41d352a78479d2c623b", "firstName": "BG", "lastName": "Real", "email": "goforbg@icloud.com", "phone": "+919042956129", "listId": "69ab444ac1feb77d7f46462f", "workspaceId": "org_3AZs4H8UsfFxVFONr6H4K75okaG", "source": "gohighlevel" },
  "campaignId": null,
  "campaignName": null,
  "ghlContactId": "aMUQn0u0Z7cw0NQ7tJ5R",
  "ghlLocationId": "eL7DD22BdZ0rismu7qCA",
  "hsPortalId": null,
  "hsContactId": null,
  "data": {}
}
```

<ResponseField name="event" type="string">`"message.sent"`</ResponseField>
<ResponseField name="messageId" type="string">Tuco message ID</ResponseField>
<ResponseField name="message" type="object">Full message document (not string)</ResponseField>
<ResponseField name="leadId" type="string">Lead ID when known</ResponseField>
<ResponseField name="lead" type="object">Full lead when available</ResponseField>
<ResponseField name="ghlContactId" type="string | null">GHL contact ID for integrations</ResponseField>
<ResponseField name="ghlLocationId" type="string | null">GHL location ID for integrations</ResponseField>
<ResponseField name="timestamp" type="string">When the webhook was fired (ISO UTC)</ResponseField>

***

## `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

| Field                    | Description                                                                                     |
| ------------------------ | ----------------------------------------------------------------------------------------------- |
| `event`                  | `"message.reaction"`                                                                            |
| `timestamp`              | When the webhook was sent (ISO UTC)                                                             |
| `workspaceId`            | Workspace that owns the conversation                                                            |
| `messageId`              | The original message id that received the reaction, when Tuco can match it                      |
| `leadId`                 | Lead id when the conversation is linked to a lead                                               |
| `firstName`              | Lead first name when available                                                                  |
| `lastName`               | Lead last name when available                                                                   |
| `phone`                  | Lead phone when available                                                                       |
| `fromLineId`             | The line that received the reaction                                                             |
| `recipientName`          | Lead or contact display name                                                                    |
| `message`                | The raw reaction text emitted by the upstream channel                                           |
| `data.reaction`          | Normalized reaction type such as `love`, `like`, `dislike`, `laugh`, `emphasize`, or `question` |
| `data.reactedAtUtc`      | When Tuco recorded the reaction                                                                 |
| `data.reactionMessageId` | Upstream reaction message id when available                                                     |

### Example payload

```json theme={null}
{
  "event": "message.reaction",
  "timestamp": "2026-05-18T16:30:00.000Z",
  "workspaceId": "org_xxx",
  "messageId": "6a07878bafb1789f8fcb68e6",
  "leadId": "667f1f77bcf86cd799439012",
  "firstName": "Jane",
  "lastName": "Doe",
  "phone": "+12025551234",
  "fromLineId": "699f704bc405fae6ca299a08",
  "recipientName": "Jane Doe",
  "message": "Loved “Looks good to me”",
  "data": {
    "reaction": "love",
    "reactedAtUtc": "2026-05-18T16:29:58.000Z",
    "reactionMessageId": "BB-123"
  }
}
```

***

## `message.failed`

Sent when a message has exhausted all retry attempts or encountered an
unrecoverable error (e.g. availability API failure). The **`message`** field is the **full message object** (includes `errorMessage` when set). **`data`** may include `error` (reason string).

### Full example body

```json theme={null}
{
  "event": "message.failed",
  "timestamp": "2026-03-12T22:45:00.000Z",
  "workspaceId": "org_xxx",
  "messageId": "69b3414e6dae1d942ece4d0d",
  "message": {
    "_id": "69b3414e6dae1d942ece4d0d",
    "message": "Hi from N8N - mar 13",
    "messageType": "imessage",
    "status": "failed",
    "recipientPhone": "+919894486982",
    "recipientName": "Usha Giridhar",
    "leadId": "69b3414e6dae1d942ece4d0c",
    "workspaceId": "org_xxx",
    "errorMessage": "Prospect's iMessage not available on +919894486982",
    "createdAt": "2026-03-12T22:42:22.379Z",
    "updatedAt": "2026-03-12T22:42:22.962Z"
  },
  "leadId": "69b3414e6dae1d942ece4d0c",
  "lead": { "_id": "69b3414e6dae1d942ece4d0c", "firstName": "Usha", "lastName": "Giridhar", "phone": "+919894486982" },
  "campaignId": null,
  "campaignName": null,
  "ghlContactId": "8LUvKi8KL6oPnjHuUeAG",
  "ghlLocationId": "eL7DD22BdZ0rismu7qCA",
  "hsPortalId": null,
  "hsContactId": null,
  "data": { "error": "Prospect's iMessage not available on +919894486982" }
}
```

<ResponseField name="event" type="string">`"message.failed"`</ResponseField>
<ResponseField name="message" type="object">Full message document (includes errorMessage)</ResponseField>
<ResponseField name="data.error" type="string">Human-readable explanation of the failure</ResponseField>

<Tip>
  When `sendFallbackSmsOnFailed` is enabled (per-message or workspace-level) and
  Twilio is configured, Tuco automatically sends a fallback SMS on this event.
  You don't need to build that logic yourself.
</Tip>

***

## `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

```json theme={null}
{
  "event": "message.fallback",
  "timestamp": "2026-03-12T22:42:23.000Z",
  "workspaceId": "org_xxx",
  "messageId": "69b3414e6dae1d942ece4d0d",
  "message": {
    "_id": "69b3414e6dae1d942ece4d0d",
    "message": "Hi from N8N - mar 13",
    "messageType": "imessage",
    "status": "fallback",
    "recipientPhone": "+919894486982",
    "recipientName": "Usha Giridhar",
    "leadId": "69b3414e6dae1d942ece4d0c",
    "workspaceId": "org_xxx",
    "errorMessage": "Prospect's iMessage not available on +919894486982",
    "createdAt": "2026-03-12T22:42:22.379Z",
    "updatedAt": "2026-03-12T22:42:22.962Z"
  },
  "leadId": "69b3414e6dae1d942ece4d0c",
  "lead": { "_id": "69b3414e6dae1d942ece4d0c", "firstName": "Usha", "lastName": "Giridhar", "phone": "+919894486982" },
  "campaignId": null,
  "campaignName": null,
  "ghlContactId": "8LUvKi8KL6oPnjHuUeAG",
  "ghlLocationId": "eL7DD22BdZ0rismu7qCA",
  "hsPortalId": null,
  "hsContactId": null,
  "data": {
    "reason": "Prospect's iMessage not available on +919894486982",
    "checkedAddresses": ["+919894486982"]
  }
}
```

<ResponseField name="event" type="string">`"message.fallback"`</ResponseField>
<ResponseField name="message" type="object">Full message document (status fallback)</ResponseField>
<ResponseField name="data.reason" type="string">Human-readable explanation</ResponseField>

<ResponseField name="data.checkedAddresses" type="string[]">
  All addresses Tuco checked for iMessage availability before deciding fallback.
  Useful for debugging and auditing.
</ResponseField>

### Common patterns

<CardGroup cols={2}>
  <Card title="Auto-send SMS" icon="message-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.
  </Card>

  <Card title="Tag lead in CRM" icon="tag">
    Flag the lead as "no iMessage" so future campaigns pick the right channel
    automatically.
  </Card>
</CardGroup>

***

## `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

```json theme={null}
{
  "event": "message.opened",
  "timestamp": "2026-03-13T10:00:00.000Z",
  "workspaceId": "org_xxx",
  "messageId": "69b344bc6dae1d942ece4d0e",
  "message": {
    "_id": "69b344bc6dae1d942ece4d0e",
    "message": "Here's your document.",
    "messageType": "imessage",
    "status": "sent",
    "fromLineId": "69ab427ac1feb77d7f46462e",
    "recipientPhone": "+919042956129",
    "recipientEmail": "goforbg@icloud.com",
    "recipientName": "BG Real",
    "leadId": "69b2c41d352a78479d2c623b",
    "workspaceId": "org_xxx",
    "createdByUserId": "user_xxx",
    "sentAt": "2026-03-12T22:57:02.892Z",
    "readAt": "2026-03-13T10:00:00.000Z",
    "createdAt": "2026-03-12T22:57:00.856Z",
    "updatedAt": "2026-03-13T10:00:00.000Z"
  },
  "leadId": "69b2c41d352a78479d2c623b",
  "lead": { "_id": "69b2c41d352a78479d2c623b", "firstName": "BG", "lastName": "Real", "email": "goforbg@icloud.com", "phone": "+919042956129" },
  "campaignId": null,
  "campaignName": null,
  "ghlContactId": "aMUQn0u0Z7cw0NQ7tJ5R",
  "ghlLocationId": "eL7DD22BdZ0rismu7qCA",
  "hsPortalId": null,
  "hsContactId": null,
  "data": { "readAt": "2026-03-13T10:00:00.000Z" }
}
```

<ResponseField name="event" type="string">`"message.opened"`</ResponseField>
<ResponseField name="message" type="object">Full message document including **readAt** (ISO UTC when read)</ResponseField>
<ResponseField name="data.readAt" type="string">When the message was opened (ISO UTC)</ResponseField>

***

## Shared fields (all events)

Every webhook payload includes these base fields:

| Field                                                        | Description                                                                                                                                    |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `event`                                                      | Event type: `message.sent`, `message.failed`, `message.fallback`, `message.opened`, or `message.reply`                                         |
| `timestamp`                                                  | When the webhook was sent (ISO UTC)                                                                                                            |
| `workspaceId`                                                | Your workspace ID                                                                                                                              |
| `messageId`                                                  | Tuco message ID — use as your primary key (for reply, this is the **reply** message ID)                                                        |
| `message`                                                    | **For sent/failed/fallback/opened:** full message object. **For reply:** string (reply text only)                                              |
| `leadId`                                                     | Lead involved (when known)                                                                                                                     |
| `lead`                                                       | Full lead object when available                                                                                                                |
| `contactOwnerEmail`                                          | Email of the workspace user assigned as contact owner for this lead. May be `null` for legacy leads.                                           |
| `ghlContactId`, `ghlLocationId`, `hsPortalId`, `hsContactId` | Integration IDs (nullable)                                                                                                                     |
| `data`                                                       | Event-specific data (e.g. `reason`, `checkedAddresses` for fallback; `reply`, `repliedAtUtc`, `parentMessages` for reply; `readAt` for opened) |

***

## Headers & signature verification

Every webhook POST includes these headers:

| Header             | Description                                                              |
| ------------------ | ------------------------------------------------------------------------ |
| `Content-Type`     | `application/json`                                                       |
| `X-Tuco-Signature` | HMAC-SHA256 hex digest of the JSON body, signed with your webhook secret |
| `X-Tuco-Event`     | Event type (e.g. `message.sent`)                                         |
| `X-Tuco-Timestamp` | ISO 8601 UTC timestamp of when the webhook was fired                     |

**Verify signatures** to confirm webhooks actually came from Tuco:

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verifyWebhook(body, signature, secret) {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(JSON.stringify(body))
      .digest('hex');
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }

  // In your handler:
  const sig = req.headers['x-tuco-signature'];
  if (!verifyWebhook(req.body, sig, process.env.TUCO_WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, json

  def verify_webhook(body: dict, signature: str, secret: str) -> bool:
      expected = hmac.new(
          secret.encode(),
          json.dumps(body, separators=(',', ':')).encode(),
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(signature, expected)
  ```
</CodeGroup>

<Tip>
  Your webhook secret is shown **once** when you create the webhook. Save it immediately.
  You can't retrieve it later — you'd need to delete and recreate the webhook.
</Tip>

***

## Consuming webhooks safely

<Steps>
  <Step title="Use messageId + event as your key">
    Make updates **idempotent**. If you've already processed a
    `(messageId, event)` pair, ignore duplicates.
  </Step>

  <Step title="Move status forward only">
    Only update your local status in the forward direction:
    `queued → sent → delivered`. Never move backwards.
  </Step>

  <Step title="Return 200 quickly">
    Tuco expects a `2xx` response within a few seconds. Do heavy processing
    asynchronously after acknowledging the webhook.
  </Step>

  <Step title="Log workspaceId and leadId">
    These fields let you trace events back to the right tenant and contact
    in multi-workspace setups.
  </Step>
</Steps>

<Warning>
  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](/api-reference/endpoint/test-webhook) to verify your endpoint is reachable.
</Warning>
