Skip to main content
When a lead replies, Chert delivers a real-time message.received event to your webhook subscriptions. Events are durable, signed, and replayable.
Event typeWhen it firesLatencyUse when
message.receivedReal-time on every inbound iMessageSecondsBuilding a live agent or chat UI. Recommended default.
Subscribe by passing events: ["message.received"] on POST /api/v1/webhook-subscriptions. Event lifecycle

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.
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 or dev inbox instead.

message.received envelope

Real-time, fires immediately when an inbound iMessage lands.
message.received
{
  "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 wantPath
Event typeevent (also in the X-Webhook-Event header)
Dedupe keyevent_id (also in X-Webhook-Event-Id)
Chat id (use with /chats/{id}/... endpoints)data.chat.id
Tenant slugpartner_id
Sender phonedata.message.sender_handle.handle
Your line phonedata.chat.owner_handle.handle
Chat handles / participantsdata.chat.handles
Inbound textdata.message.parts.find(p => p.type === "text").value
Inbound attachment metadatadata.message.parts.filter(p => p.type === "media")
Inbound attachment bytesGET /api/v1/attachments/{attachment_id}/content
Inbound message id — valid input to /messages/{id}/reactdata.message.id
Sender linedata.chat.owner_handle.handle
Group flagdata.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:
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:
# 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.
HeaderFormatDescription
X-Webhook-EventstringEvent type, e.g. message.received.
X-Webhook-Event-IdstringStable identifier. Use as a dedup key.
X-Webhook-Timestampunix secondsTimestamp used in the signature payload.
X-Webhook-Subscription-IdstringId of the subscription that triggered this delivery.
X-Webhook-Signaturet=<ts>,v1=<hex>HMAC signature computed with the subscription secret.
content-typeapplication/jsonAlways 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.
HeaderFormatDescription
x-chert-eventstringSame event type as X-Webhook-Event.
x-chert-event-idstringSame dedup key as X-Webhook-Event-Id.
x-chert-timestampunix secondsSame timestamp as X-Webhook-Timestamp.
x-chert-signaturev1,<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.
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)
}
Reject any request that fails verification with 401.

Retry behavior

Response from your endpointChert action
2xxMarked delivered.
5xx or network errorUp to 3 total delivery attempts with short exponential backoff.
4xxNot 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).
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:
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 for the full debugging surface.

See also