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

# Receiving replies

> Webhook payload, signature verification, and retry semantics.

When a lead replies, Chert delivers a real-time `message.received` event to your webhook subscriptions. Events are durable, signed, and replayable.

| Event type         | When it fires                       | Latency | Use when                                               |
| ------------------ | ----------------------------------- | ------- | ------------------------------------------------------ |
| `message.received` | Real-time on every inbound iMessage | Seconds | Building a live agent or chat UI. Recommended default. |

Subscribe by passing `events: ["message.received"]` on `POST /api/v1/webhook-subscriptions`.

<img src="https://mintcdn.com/cherttechnologiesinc/sZbsoWFwCb6zxt3B/api/diagrams/event-lifecycle.svg?fit=max&auto=format&n=sZbsoWFwCb6zxt3B&q=85&s=a36246f2d8622d5453a6e518496c82fb" alt="Event lifecycle" width="1000" height="360" data-path="api/diagrams/event-lifecycle.svg" />

## Configuring a webhook

Two options:

1. **Console** — your Chert administrator configures a webhook endpoint for your tenant.
2. **API** — create a subscription programmatically with `POST /api/v1/webhook-subscriptions`. See [Webhook subscriptions](/api/webhook-subscriptions).

The webhook subscription secret is independent from your API `signing_secret` — rotate either without touching the other.

If you cannot expose a public URL during development, use the [SSE stream](/api/events) or [dev inbox](/api/events#dev-inbox) instead.

## `message.received` envelope

Real-time, fires immediately when an inbound iMessage lands.

```json message.received theme={null}
{
  "event": "message.received",
  "event_id": "chert:msg:7b7f4a1cc9d54809a1e4f1b2",
  "timestamp": "2026-05-24T01:35:34.000Z",
  "api_version": "v1",
  "webhook_version": "2026-05-04",
  "event_type": "message.received",
  "trace_id": "...",
  "partner_id": "<your-tenant-slug>",
  "data": {
    "chat": {
      "id": "17f6e9ed-...",
      "is_group": false,
      "handles": [
        { "handle": "+14152165723", "service": "imessage", "is_me": true },
        { "handle": "+14155551234", "service": "imessage", "is_me": false }
      ],
      "owner_handle": { "handle": "+14152165723", "service": "imessage", "is_me": true }
    },
    "message": {
      "id": "0D36042D-C551-4CB4-9BE7-853832D6F2FE",
      "direction": "inbound",
      "sender_handle": { "handle": "+14155551234", "service": "imessage", "is_me": false },
      "parts": [
        { "type": "text", "value": "Sure, what time?" },
        {
          "type": "media",
          "attachment_id": "in_536f2fe3cafef93946d0bf9b4ac2e3ef",
          "filename": "photo.jpg",
          "mime_type": "image/jpeg",
          "size_bytes": 790394
        }
      ],
      "sent_at": "2026-05-24T01:35:34.000Z",
      "delivered_at": null,
      "read_at": null,
      "service": "imessage"
    },
    "crm_deep_link": "https://console.trychert.com/?lead=17f6e9ed-..."
  }
}
```

### Field map

| You want                                                   | Path                                                    |
| ---------------------------------------------------------- | ------------------------------------------------------- |
| Event type                                                 | `event` (also in the `X-Webhook-Event` header)          |
| Dedupe key                                                 | `event_id` (also in `X-Webhook-Event-Id`)               |
| Chat id (use with `/chats/{id}/...` endpoints)             | `data.chat.id`                                          |
| Tenant slug                                                | `partner_id`                                            |
| Sender phone                                               | `data.message.sender_handle.handle`                     |
| Your line phone                                            | `data.chat.owner_handle.handle`                         |
| Chat handles / participants                                | `data.chat.handles`                                     |
| Inbound text                                               | `data.message.parts.find(p => p.type === "text").value` |
| Inbound attachment metadata                                | `data.message.parts.filter(p => p.type === "media")`    |
| Inbound attachment bytes                                   | `GET /api/v1/attachments/{attachment_id}/content`       |
| Inbound message id — valid input to `/messages/{id}/react` | `data.message.id`                                       |
| Sender line                                                | `data.chat.owner_handle.handle`                         |
| Group flag                                                 | `data.chat.is_group`                                    |

> **Compat note.** Some legacy deliveries also include top-level `from`, `to`, `text`, `message_guid`, and `line_id` aliases. New integrations should prefer the `data.*` paths because they are richer and consistent across Chert-managed lines.

### Receiving images and attachments

Inbound media is handled by the Chert API layer automatically. When a lead sends an image or file, the `message.received` webhook carries a `media` part in `data.message.parts[]` with an `attachment_id`, filename, MIME type, and byte size. Fetch the bytes with the same tenant auth you use for other API calls:

```bash theme={null}
ATTACHMENT_ID="in_536f2fe3cafef93946d0bf9b4ac2e3ef"

curl "https://console.trychert.com/api/v1/attachments/$ATTACHMENT_ID/content" \
  -H "Authorization: Bearer $SECRET" \
  -H "x-chert-tenant: $SLUG" \
  --output photo.jpg
```

The attachment id is tenant-scoped. If an inbound contains both text and media, keep the array order from `data.message.parts[]` when reconstructing the message.

### Replying

Two working paths. Pick by what you need:

```bash theme={null}
# Plain reply — phone-addressed, no chat-id lookup needed
curl -X POST https://console.trychert.com/api/v1/send \
  -H "Authorization: Bearer $SECRET" -H "x-chert-tenant: $SLUG" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+14155551234", "body": "Yes, works for me" }'

# Chat-addressed reply — required for media parts, typing indicators, and contact cards
curl -X POST https://console.trychert.com/api/v1/chats/$CHAT_ID/messages \
  -H "Authorization: Bearer $SECRET" -H "x-chert-tenant: $SLUG" \
  -H "Content-Type: application/json" \
  -d '{ "message": { "parts": [{ "type": "text", "value": "Yes, works for me" }] } }'

```

`$CHAT_ID` is `data.chat.id` from the webhook. `$MESSAGE_GUID` is `message_guid` (or `data.message.id`) and can be used for tapbacks with `/messages/{id}/react`. Use a normal chat follow-up for replies.

## Headers

These headers are sent by Chert **to your webhook endpoint** during event delivery. Do not send `x-chert-tenant` in your webhook receiver — that header is only for requests your application makes **to Chert APIs**.

### Recommended webhook delivery headers

| Header                      | Format             | Description                                           |
| --------------------------- | ------------------ | ----------------------------------------------------- |
| `X-Webhook-Event`           | string             | Event type, e.g. `message.received`.                  |
| `X-Webhook-Event-Id`        | string             | Stable identifier. Use as a dedup key.                |
| `X-Webhook-Timestamp`       | unix seconds       | Timestamp used in the signature payload.              |
| `X-Webhook-Subscription-Id` | string             | Id of the subscription that triggered this delivery.  |
| `X-Webhook-Signature`       | `t=<ts>,v1=<hex>`  | HMAC signature computed with the subscription secret. |
| `content-type`              | `application/json` | Always `application/json`.                            |

### Legacy compatibility headers

Chert also emits these older lowercase headers for existing integrations. New integrations should read the recommended `X-Webhook-*` headers above.

| Header              | Format          | Description                              |
| ------------------- | --------------- | ---------------------------------------- |
| `x-chert-event`     | string          | Same event type as `X-Webhook-Event`.    |
| `x-chert-event-id`  | string          | Same dedup key as `X-Webhook-Event-Id`.  |
| `x-chert-timestamp` | unix seconds    | Same timestamp as `X-Webhook-Timestamp`. |
| `x-chert-signature` | `v1,<ts>,<hex>` | Legacy HMAC signature format.            |

Both signature formats compute the same HMAC: `HMAC-SHA256(subscription_secret, "<ts>.<raw_body>")`.

## Verifying the signature

Compute the HMAC over the **raw** request body (exact bytes as received) and compare with constant-time equality. Do not parse and re-serialize the JSON before verifying.

<CodeGroup>
  ```js Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto"

  // Handles both x-chert-signature (v1,<ts>,<hex>)
  // and X-Webhook-Signature (t=<ts>,v1=<hex>).
  export function verify(rawBody, header, secret) {
    let ts, hex
    const legacy = /^v1,(\d+),([a-f0-9]+)$/i.exec(header || "")
    const modern  = /^t=(\d+),v1=([a-f0-9]+)$/i.exec(header || "")
    if (legacy)      { [, ts, hex] = legacy }
    else if (modern) { [, ts, hex] = modern }
    else return false

    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
    const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex")
    const a = Buffer.from(hex, "hex")
    const b = Buffer.from(expected, "hex")
    return a.length === b.length && timingSafeEqual(a, b)
  }
  ```

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

  def verify(raw_body: bytes, header: str, secret: str) -> bool:
      legacy = re.match(r"^v1,(\d+),([a-f0-9]+)$", header or "")
      modern = re.match(r"^t=(\d+),v1=([a-f0-9]+)$", header or "")
      m = legacy or modern
      if not m: return False
      ts, hex_ = m.group(1), m.group(2)
      if abs(time.time() - int(ts)) > 300: return False
      expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(hex_, expected)
  ```

  ```bash curl theme={null}
  # Extract ts and hex from x-chert-signature: v1,<ts>,<hex>
  EXPECTED=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SUBSCRIPTION_SECRET" -hex | awk '{print $2}')
  [ "$EXPECTED" = "$HEX_FROM_HEADER" ] && echo "ok" || echo "mismatch"
  ```
</CodeGroup>

Reject any request that fails verification with `401`.

## Retry behavior

| Response from your endpoint | Chert action                                                                         |
| --------------------------- | ------------------------------------------------------------------------------------ |
| `2xx`                       | Marked delivered.                                                                    |
| `5xx` or network error      | Up to 3 total delivery attempts with short exponential backoff.                      |
| `4xx`                       | Not retried during that delivery. Marked failed if no matching destination succeeds. |

Failed events may be retried later by Chert. Pull events through `GET /api/v1/events` and replay manually after fixing your endpoint.

## At-least-once semantics

You may receive the same event more than once during retries or replays. Always dedup on `event_id` (also exposed in the `x-chert-event-id` header).

```js theme={null}
const seen = new Set()
function handle(event) {
  if (seen.has(event.event_id)) return
  seen.add(event.event_id)
  // ...
}
```

## Replaying missed events

If your endpoint was down, list events with `delivery.status = failed` and replay them:

```bash theme={null}
curl https://console.trychert.com/api/v1/events?status=failed \
  -H "x-chert-tenant: $SLUG" -H "authorization: Bearer $SECRET"

curl -X POST https://console.trychert.com/api/v1/events/{id}/replay \
  -H "x-chert-tenant: $SLUG" -H "authorization: Bearer $SECRET"
```

To replay to one subscription instead of every matching active subscription, add `?subscription_id=<subscription_id>`.

See [Events](/api/events) for the full debugging surface.

## See also

* [Events](/api/events)
* [Webhook subscriptions](/api/webhook-subscriptions)
* [Authentication](/api/authentication)
* [Errors](/api/errors)
