---
title: "Event Reference"
source: https://sumgenius.ai/docs/webhook-events
generated: 2026-09-14
---

# Webhook Events

Every event ChatGenius can deliver to your endpoint: the shared envelope, the attachment shape, and all 31 event types with full-profile JSON examples.

Webhook & API Add-on · $29/month · Requires Creator+ plan

Messages

[message.received](#message-received)

[message.reel_shared](#message-received)

[message.post_shared](#message-received)

[message.postback](#message-postback)

[comment.received](#comment-received)

[comment.trigger_fired](#comment-trigger-fired)

[comment.handled](#comment-handled)

Channel inbound

[sms.message_received](#channel-messages)

[whatsapp.message_received](#channel-messages)

[telegram.message_received](#channel-messages)

Leads

[lead.email_captured](#lead-captured)

[lead.phone_captured](#lead-captured)

Appointments

[appointment.booked](#appointments)

[appointment.cancelled](#appointments)

[appointment.rescheduled](#appointments)

Conversation

[conversation.escalated](#conversation-escalated)

[conversation.resolved](#conversation-resolved)

[conversation.assigned](#conversation-assigned)

Contacts

[contact.tag_added](#contact-tags)

[contact.tag_removed](#contact-tags)

[contact.stage_changed](#contact-stage-changed)

[contact.updated](#contact-updated)

[contact.field_changed](#contact-field-changed)

Content Studio

[post.scheduled](#post-events)

[post.published](#post-events)

[post.failed](#post-events)

Flows

[flow.published](#flow-events)

[flow.activated](#flow-events)

[flow.paused](#flow-events)

[flow.session_completed](#flow-events)

Delivery

[message.send_failed](#send-failed)

### The envelope

Every event is posted as JSON with `Content-Type: application/json`, in one of two styles. **Standard**, which every endpoint created since July 2026 receives, wraps the event body: `{"type", "timestamp", "data"}`, and you read the fields from `data.*`. **Legacy**, for integrations created before July 2026, posts the flat body with no wrapper. Everything inside is identical between the two; the style is fixed per endpoint and shown on the endpoint in the portal.

The event type also rides in the `X-SumGenius-Event` request header on every delivery, and every delivery is signed: see [verifying signatures](https://sumgenius.ai/docs/receiving-webhooks#verify). How much customer data is included is controlled by your [payload profile](https://sumgenius.ai/docs/receiving-webhooks#payload-profiles) (`full` or `minimal`).

Event body fields

- event_idstring 32-character hex id, unique per event and stable across retries; it is also the delivery id header. Use it to deduplicate.
- event_typestring One of the 31 types in the [index](#events).
- occurred_atUTC ISO-8601 When the event happened on the platform.
- source.platformenum `facebook` · `instagram` · `sms` · `whatsapp` · `telegram`
- source.channelenum `meta_dm` · `twilio_sms` · `whatsapp_cloud` · `telegram`
- conversation.idinteger | null ChatGenius conversation id; pass it to the Send API to reply. Can be `null` on the very first inbound message from a new person.
- conversation.assigned_to / assigned_to_namefull profile only Team member id and name the conversation is assigned to, or `null`.
- customer.platform_user_idstring Platform-assigned user id. Always present, on both profiles: your stable join key.
- customer enrichmentfull profile only `platform_user_name`, `platform_user_username`, `contact_id`, `tags`, `stage`, `custom_fields`. `custom_fields` carries the contact's set, active [custom field](https://sumgenius.ai/docs/rest-api#custom-fields) values as `{field_key: value}`, `{}` when none are set.
- customer follow statusfull profile · Meta DM events `is_follower` (do they follow you), `is_following` (do you follow them), `follower_count`. Instagram only, `null` means unknown (never synced, or a Facebook user). From the cached profile sync, except on `comment.received` where `is_follower` is **checked live at comment time**, the gate a follow-gate automation needs before deciding what to reply.
- messageobject `{id, text_raw, attachments[]}`. Non-message events replace this block with their own (`comment`, `appointment`, `lead`, `escalation`, `send_failed`).
- metaobject `version` (currently `v1`), `payload_profile`, `generated_at`.

**Standard envelope · message.received**

```
{
  "type":      "message.received",
  "timestamp": "2026-02-20T08:15:03Z",
  "data": {
    "event_id":    "a3f8e1c2d4b567890abcdef012345678",
    "event_type":  "message.received",
    "occurred_at": "2026-02-20T08:15:00Z",
    "source": {
      "platform": "instagram",
      "channel":  "meta_dm"
    },
    "conversation": {
      "id":               10482,
      "assigned_to":      90011,
      "assigned_to_name": "Anna"
    },
    "customer": {
      "platform_user_id":       "17841400123456789",
      "platform_user_name":     "Jane Doe",
      "platform_user_username": "janedoe",
      "contact_id":    555,
      "tags":          ["VIP", "Returning"],
      "stage":         "qualified",
      "custom_fields": { "crm_deal_id": "D-2214" }
    },
    "message": {
      "id":          "mid.1234567890abcdef",
      "text_raw":    "Hi, do you have availability next Tuesday?",
      "attachments": []
    },
    "meta": {
      "version":         "v1",
      "payload_profile": "full",
      "generated_at":   "2026-02-20T08:15:00Z"
    }
  }
}
```

**Legacy (flat) equivalent**

```
{
  "event_id":    "a3f8e1c2d4b567890abcdef012345678",
  "event_type":  "message.received",
  "occurred_at": "2026-02-20T08:15:00Z",
  "source": {
    "platform": "instagram",
    "channel":  "meta_dm"
  },
  ...
}
```

### Attachments

Media the customer sent arrives in `message.attachments`. Four fields are always present; the rest ride on the `full` profile only.

- typestring Attachment category, e.g. `image` · `audio` · `video`.
- media_typestring MIME type when available.
- media_idstring Platform media identifier.
- received_atUTC ISO-8601 When the attachment was received.
- urlfull profile only Media URL. Time-limited, provided by the platform; download promptly if you need to keep it.
- title / captionfull profile only Title or caption text when present.

**Attachment object**

```
{
  "type":        "image",
  "media_type":  "image/jpeg",
  "media_id":    "18025531234567890",
  "received_at": "2026-02-20T08:15:00Z",

  // full profile only:
  "url":     "https://lookaside.fbsbx.com/ig_messaging_cdn/...",
  "title":   null,
  "caption": null
}
```

## Message events

Inbound DMs and comments on Facebook and Instagram.

### message.received

A customer sent a direct message on Facebook or Instagram (`source.platform` is `facebook` or `instagram`). Fires on text and on attachments.

Share variants

`message.reel_shared` and `message.post_shared` fire **in addition to** `message.received` for the same message when an Instagram reel or post is shared into the thread, carrying the same `message.id`. Subscribe to them when you want to react to shares specifically; deduplicate against `message.id` if you consume both.

**message.received · full profile**

```
{
  "event_id":    "a3f8e1c2d4b567890abcdef012345678",
  "event_type":  "message.received",
  "occurred_at": "2026-02-20T08:15:00Z",
  "source": {
    "platform": "instagram",
    "channel":  "meta_dm"
  },
  "conversation": {
    "id":               10482,
    "assigned_to":      90011,
    "assigned_to_name": "Anna"
  },
  "customer": {
    "platform_user_id":       "17841400123456789",
    "platform_user_name":     "Jane Doe",
    "platform_user_username": "janedoe",
    "contact_id":    555,
    "tags":          ["VIP", "Returning"],
    "stage":         "qualified",
    "custom_fields": { "crm_deal_id": "D-2214" }
  },
  "message": {
    "id":          "mid.1234567890abcdef",
    "text_raw":    "Hi, do you have availability next Tuesday?",
    "attachments": []
  },
  "meta": {
    "version":         "v1",
    "payload_profile": "full",
    "generated_at":   "2026-02-20T08:15:00Z"
  }
}
```

### comment.received

A customer left a comment on one of your Instagram or Facebook posts, reels, or live videos. Fires for **every** inbound comment, whether or not a ChatGenius comment trigger is configured.

There is no `message` block; the payload carries `source_post` and `comment` instead. `conversation.id` is `null` for comments: the follow-up DM, if a trigger sends one, arrives as its own `message.received` event.

- source_postobject `id` is always present: the attribution join key. `url` is the permalink, populated when the comment matched a trigger targeting a specific post, otherwise `null`.
- commentobject `{id, text_raw, matched_keyword, trigger_id, trigger_name, source_type}`. `source_type` is `comment` · `live`. The trigger fields (`matched_keyword`, `trigger_id`, `trigger_name`) are `null` unless a comment trigger matched.

**comment.received · trigger matched**

```
{
  "event_id":    "b7c2e1a4f9d063518adef0129876dcba",
  "event_type":  "comment.received",
  "occurred_at": "2026-02-20T08:15:00Z",
  "source": {
    "platform": "instagram",
    "channel":  "meta_dm"
  },
  "conversation": {
    "id": null
  },
  "customer": {
    "platform_user_id":       "17841400123456789",
    "platform_user_name":     null,
    "platform_user_username": "janedoe",
    "is_follower":            true,
    "is_following":           null,
    "follower_count":         null
  },
  "source_post": {
    "id":  "17912345678901234",
    "url": "https://www.instagram.com/p/CtExAmple/"
  },
  "comment": {
    "id":              "17987654321098765",
    "text_raw":        "PRICE",
    "matched_keyword": "price",
    "trigger_id":      42,
    "trigger_name":    "Pricing DM",
    "source_type":     "comment"
  },
  "meta": {
    "version":         "v1",
    "payload_profile": "full",
    "generated_at":   "2026-02-20T08:15:00Z"
  }
}
```

### comment.trigger_fired

Fires after a [comment trigger](https://sumgenius.ai/docs/rest-api#comment-triggers) has acted on a comment. Where [comment.received](#comment-received) fires for every comment and only predicts the matching trigger, this fires once per trigger fire, after the work is done, and says what was actually sent. Use it to count real conversions per trigger or to hand the commenter to your CRM.

- triggerobject `{id, name, matched_keyword}`. The trigger row and the keyword that matched.
- actionsobject `{public_reply_sent, dm_sent, flow_id, fired_at}`. Booleans for what went out; `flow_id` is the linked flow that started, or null.
- source_post · commentobject Same shape as comment.received: the post (id, url) and the comment (id, text_raw, source_type).

No `conversation.id`: a comment is not a DM thread. The DM the trigger sent opens or continues one, and `customer.platform_user_id` is how you join to it.

**comment.trigger_fired**

```
{
  "type": "comment.trigger_fired",
  "timestamp": "2026-09-05T18:20:00Z",
  "data": {
    "event_type": "comment.trigger_fired",
    "platform": "instagram",
    "conversation": { "id": null },
    "customer": { "platform_user_id": "17841400123456789", "platform_user_username": "janedoe" },
    "source_post": { "id": "17912345678901234", "url": "https://www.instagram.com/p/example/" },
    "comment": { "id": "17987654321098765", "text_raw": "Can I get the guide?", "source_type": "comment" },
    "trigger": { "id": 212, "name": "Guide", "matched_keyword": "guide" },
    "actions": { "public_reply_sent": true, "dm_sent": true, "flow_id": null, "fired_at": "2026-09-05T18:20:00Z" },
    "meta": { "version": "v1", "payload_profile": "full", "generated_at": "2026-09-05T18:20:00Z" }
  }
}
```

### comment.handled

Fires each time the [AI Comment Manager](https://sumgenius.ai/docs/rest-api#comments) changes the state of a comment no trigger claimed: a reply was queued for approval, posted, hidden by moderation, approved or rejected by a person, failed, or skipped at the plan cap. One delivery per state change, so a comment that is queued and then approved fires twice.

- handledobject The log row as `GET /comments/{id}` returns it: `{id, status, sentiment, moderation, reply, reviewed_by, reviewed_at}`. `status` is what changed. `handled.id` is what you pass to approve or reject.
- source_post · commentobject The post id, and the comment (id, text_raw). `source_post.url` is null here.

Subscribe to this with `handled.status = pending` to get an approval queue pushed to Slack or a help desk, then approve from there.

**comment.handled**

```
{
  "type": "comment.handled",
  "timestamp": "2026-09-05T18:40:02Z",
  "data": {
    "event_type": "comment.handled",
    "platform": "instagram",
    "conversation": { "id": null },
    "customer": { "platform_user_id": "17841400123456789", "platform_user_username": "janedoe" },
    "source_post": { "id": "17912345678901234", "url": null },
    "comment": { "id": "17987654321098765", "text_raw": "How much is this?", "source_type": "comment" },
    "handled": {
      "id": 57,
      "status": "pending",
      "sentiment": { "label": "neutral", "score": 0.1 },
      "moderation": { "action": "none", "flags": [], "dm_status": "none", "dm_text": null },
      "reply": { "draft": "Hi! Pricing is in the link in our bio.", "sent_text": null, "edited": false, "sent_at": null, "meta_reply_id": null },
      "reviewed_by": null,
      "reviewed_at": null
    },
    "meta": { "version": "v1", "payload_profile": "full", "generated_at": "2026-09-05T18:40:02Z" }
  }
}
```

### message.postback

A customer **tapped a button** in an Instagram or Facebook DM. Fires for every postback tap on the account, whatever created the button, a flow, a comment trigger DM, or a button you sent through the [Send API](https://sumgenius.ai/docs/send-api#private-reply). For API senders this is the round trip: the `postback.payload` is the payload you set when you sent the button.

Same envelope as `message.received`, the `message` block carries the button's title as `text_raw`, plus a `postback` block:

- postbackobject `{payload, title}`. `payload` is the machine value the button was created with; `title` is what the customer saw (may be `null`).

> **Note**
>
> **The tap opens the 24h messaging window.** After this event you can respond with regular [`send_message`](https://sumgenius.ai/docs/send-api#send-message) calls, no private reply needed anymore.

**message.postback**

```
{
  "event_type": "message.postback",
  "source": { "platform": "instagram", "channel": "instagram" },
  "conversation": { "id": 10482 },
  "customer": {
    "platform_user_id": "1869400170313836",
    "username": "jaredr.media"
  },
  "message": {
    "id": "aWdf...",
    "text_raw": "Send me more",
    "attachments": []
  },
  "postback": {
    "payload": "more_guides",
    "title": "Send me more"
  }
}
```

### sms · whatsapp · telegram .message_received

A customer messaged you on [SMS](https://sumgenius.ai/docs/webhook-sms), [WhatsApp](https://sumgenius.ai/docs/webhook-whatsapp), or [Telegram](https://sumgenius.ai/docs/webhook-telegram). Same envelope as every other event, plus one channel sub-block under `message`. The channel pages cover routing identifiers, supported actions, and limits.

- message.smsobject `{to_phone, from_phone, num_segments, num_media, encoding}`. `encoding` is `GSM-7` · `UCS-2`.
- message.whatsappobject `{wa_id, phone_number, business_phone_number_id, profile_name, message_type, context_message_id}`.
- message.telegramobject `{chat_id, chat_type, username, message_type}`. `chat_id` is the recipient identifier for the Send API.

**sms.message_received · full profile**

```
{
  "event_id":    "7f7a80c84bf17c4b5b72974c97365a77",
  "event_type":  "sms.message_received",
  "occurred_at": "2026-04-27T23:22:10Z",
  "source": {
    "platform": "sms",
    "channel":  "twilio_sms"
  },
  "conversation": {
    "id": 119301
  },
  "customer": {
    "platform_user_id": "+15551234567",
    "phone":            "+15551234567"
  },
  "message": {
    "id":          "SMdb4342728f73bca6a83770a705e27b31",
    "text_raw":    "Hi, what time do you open Saturday?",
    "attachments": [],
    "sms": {
      "to_phone":     "+15559876543",
      "from_phone":   "+15551234567",
      "num_segments": 1,
      "num_media":    0,
      "encoding":     "GSM-7"
    }
  },
  "meta": {
    "version":         "v1",
    "payload_profile": "full",
    "generated_at":   "2026-04-27T23:22:10Z"
  }
}
```

**Channel sub-blocks**

```
// sms.message_received
"sms": {
  "to_phone":     "+15559876543",
  "from_phone":   "+15551234567",
  "num_segments": 1,
  "num_media":    0,
  "encoding":     "GSM-7"
}

// whatsapp.message_received
"whatsapp": {
  "wa_id":                    "15551234567",
  "phone_number":             "+15551234567",
  "business_phone_number_id": "108765432109876",
  "profile_name":             "Jane Doe",
  "message_type":             "text",
  "context_message_id":       null
}

// telegram.message_received
"telegram": {
  "chat_id":      "820032950394075",
  "chat_type":    "private",
  "username":     "janedoe",
  "message_type": "text"
}
```

## Lead events

Contact info captured from conversation content, ready for your CRM.

### lead.email_captured · lead.phone_captured

Fires **once per conversation**, the first time an email (or phone) is captured from the conversation content, on any channel. A conversation that captures an email and later a phone produces one of each; the two events are identical except for `event_type`.

The `lead` block `{email, phone, captured_at}` is always included regardless of payload profile: contact info is the point of the event. Fields not yet captured are `null`.

`lead.phone` is a phone collected from message *content*, not the SMS or WhatsApp sender number; on those channels the sender number is already delivered as `customer.platform_user_id` on every event.

**lead.email_captured**

```
{
  "event_id":    "c7a3b5d1e9f2468013579bdf2468ace0",
  "event_type":  "lead.email_captured",
  "occurred_at": "2026-07-23T07:20:00Z",
  "source": {
    "platform": "instagram",
    "channel":  "meta_dm"
  },
  "conversation": {
    "id": 119789
  },
  "customer": {
    "platform_user_id": "992584566622479"
  },
  "lead": {
    "email":       "jane@example.com",
    "phone":       "+15551234567",
    "captured_at": "2026-07-23T07:20:00Z"
  },
  "meta": {
    "version":         "v1",
    "payload_profile": "full",
    "generated_at":   "2026-07-23T07:20:00Z"
  }
}
```

## Appointment events

In-chat bookings made through the [AI booking agent](https://sumgenius.ai/docs/appointment-booking). Calendar syncs do not fire these.

### appointment.booked · .cancelled · .rescheduled

All three carry the same `appointment` block; the `event_type` and the appointment's `status` tell you what happened. `appointment.rescheduled` fires again on each subsequent reschedule, with the new `datetime`. The block is always full regardless of payload profile.

- appointmentobject `{id, confirmation_code, datetime, service_type, duration_minutes, cost, status, timezone, location {city, state}, customer {name, email, phone}}`.
- datetimeUTC ISO-8601 Always UTC. `timezone` is the appointment's own booking timezone; convert back to display the time the customer chose.
- appointment.customerobject The contact details captured during booking. The top-level `customer` remains the messaging identity.

**appointment.booked**

```
{
  "event_id":    "a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "event_type":  "appointment.booked",
  "occurred_at": "2026-07-24T06:30:00Z",
  "source": {
    "platform": "instagram",
    "channel":  "meta_dm"
  },
  "conversation": {
    "id": 187765
  },
  "customer": {
    "platform_user_id":   "820032950394075",
    "platform_user_name": "Jared"
  },
  "appointment": {
    "id":                1234,
    "confirmation_code": "AB12CD",
    "datetime":          "2026-07-30T15:00:00Z",
    "service_type":      "consultation",
    "duration_minutes":  30,
    "cost":              null,
    "status":            "confirmed",
    "timezone":          "America/New_York",
    "location": {
      "city":  "Austin",
      "state": "TX"
    },
    "customer": {
      "name":  "Jane Doe",
      "email": "jane@example.com",
      "phone": "+15551234567"
    }
  },
  "meta": {
    "version":         "v1",
    "payload_profile": "full",
    "generated_at":   "2026-07-24T06:30:00Z"
  }
}
```

## Conversation events

Human handoff.

### conversation.escalated

Fires every time a conversation is handed to a human, triggered by negative sentiment or an explicit hand-off, including repeat escalations on the same conversation. Wire it to Slack or a ticketing system, and match it to a thread via `conversation.id`.

There is no de-escalation event: the AI stays paused on an escalated conversation by design.

- escalationobject `{reason, assigned_to, escalated_at}`. `assigned_to` is the agent the conversation was routed to, or `null` if no agent was available.

**conversation.escalated**

```
{
  "event_id":    "9d4f7a3b1c2e5d6f8091a2b3c4d5e6f7",
  "event_type":  "conversation.escalated",
  "occurred_at": "2026-07-24T05:40:00Z",
  "source": {
    "platform": "instagram",
    "channel":  "meta_dm"
  },
  "conversation": {
    "id": 187765
  },
  "customer": {
    "platform_user_id":       "820032950394075",
    "platform_user_name":     "Jared",
    "platform_user_username": "jaredr.media"
  },
  "escalation": {
    "reason":       "Negative sentiment detected",
    "assigned_to":  "Alex",
    "escalated_at": "2026-07-24T05:40:00Z"
  },
  "meta": {
    "version":         "v1",
    "payload_profile": "full",
    "generated_at":   "2026-07-24T05:40:00Z"
  }
}
```

### conversation.resolved

Fires when a conversation is closed, from the inbox or through [PATCH /conversations/{id}](https://sumgenius.ai/docs/rest-api#update-conversation). Resolving hands the thread back to the AI, so this is also the moment any assignment ends. One delivery per resolution; a thread resolved, reopened and resolved again fires twice.

- resolutionobject `{resolved_at, resolved_by, user}`. `resolved_by` is `manual` for the inbox, `api` for the API, or another internal reason. `user` is the person or API key that did it.

**conversation.resolved**

```
{
  "type": "conversation.resolved",
  "timestamp": "2026-09-05T02:10:00Z",
  "data": {
    "event_type": "conversation.resolved",
    "platform": "instagram",
    "conversation": { "id": 188892, "status": "resolved" },
    "customer": { "platform_user_id": "820032950394075", "platform_user_name": "Jared", "contact_id": 19214 },
    "resolution": {
      "resolved_at": "2026-09-05T02:10:00Z",
      "resolved_by": "api",
      "user": "API key 6"
    },
    "meta": { "version": "v1", "payload_profile": "full", "generated_at": "2026-09-05T02:10:00Z" }
  }
}
```

### conversation.assigned

Fires when who holds a conversation changes: assigned to a team member, claimed by the account owner, or handed back to the AI. Every change fires, so a consumer can mirror the inbox without polling. Escalations that auto-assign an agent still fire [conversation.escalated](#conversation-escalated), not this.

- assignmentobject `{assigned_to, assigned_to_name, kind, source, assigned_at}`. `kind` is `team` (a positive team member id), `owner` (the account owner, negative id), or `none` (handed back to the AI, `assigned_to` null). `source` is `portal`, `api`, or the status reason that cleared it, such as `manual` on a resolve.

**conversation.assigned**

```
{
  "type": "conversation.assigned",
  "timestamp": "2026-09-05T02:10:00Z",
  "data": {
    "event_type": "conversation.assigned",
    "platform": "instagram",
    "conversation": { "id": 188892, "status": "active" },
    "customer": { "platform_user_id": "820032950394075", "platform_user_name": "Jared", "contact_id": 19214 },
    "assignment": {
      "assigned_to": -50,
      "assigned_to_name": "API key 6",
      "kind": "owner",
      "source": "api",
      "assigned_at": "2026-09-05T02:10:00Z"
    },
    "meta": { "version": "v1", "payload_profile": "full", "generated_at": "2026-09-05T02:10:00Z" }
  }
}
```

## Contact events

CRM data changes.

### contact.tag_added · contact.tag_removed

Fires when a tag is put on or taken off a contact. Every path fires it: a person in the inbox or the contact card, the API, a flow's tag action, and automatic source tagging. Adding a tag the contact already has, or removing one it does not have, fires nothing.

- tagobject `{id, name, color, source, changed_at}`. `source` is `portal`, `api`, or `automatic` (a flow or source tagging).

No `message` object. `customer.contact_id` and `customer.tags` carry the contact and its full tag list after the change.

**contact.tag_added**

```
{
  "type": "contact.tag_added",
  "timestamp": "2026-09-05T20:00:00Z",
  "data": {
    "event_type": "contact.tag_added",
    "platform": "instagram",
    "conversation": { "id": 188892 },
    "customer": { "platform_user_id": "17841400123456789", "platform_user_username": "janedoe", "contact_id": 19214, "tags": ["VIP", "Hot lead"] },
    "tag": { "id": 31, "name": "Hot lead", "color": "#EF4444", "source": "api", "changed_at": "2026-09-05T20:00:00Z" },
    "meta": { "version": "v1", "payload_profile": "full", "generated_at": "2026-09-05T20:00:00Z" }
  }
}
```

### contact.stage_changed

Fires when a contact moves to a different pipeline stage, from the contact card or `PATCH /contacts/{id}`. Setting the stage it already has fires nothing.

- stageobject `{old, new, source, changed_at}`. Stages are `new`, `contacted`, `qualified`, `customer`, `lost`. `source` is `portal` or `api`.

**contact.stage_changed**

```
{
  "type": "contact.stage_changed",
  "timestamp": "2026-09-05T20:01:00Z",
  "data": {
    "event_type": "contact.stage_changed",
    "platform": "instagram",
    "conversation": { "id": 188892 },
    "customer": { "platform_user_id": "17841400123456789", "contact_id": 19214, "stage": "qualified" },
    "stage": { "old": "contacted", "new": "qualified", "source": "portal", "changed_at": "2026-09-05T20:01:00Z" },
    "meta": { "version": "v1", "payload_profile": "full", "generated_at": "2026-09-05T20:01:00Z" }
  }
}
```

### contact.updated

Fires when a contact's name, email or phone is edited, in the portal or via the API. One event per save, listing every field that actually changed. Custom fields have their own event, [contact.field_changed](#contact-field-changed); captures made by the AI in chat fire [lead.\*_captured](#lead-captured) instead.

- updatedobject `{fields, source, changed_at, values}`. `fields` is any of `display_name`, `email`, `phone`. `values` is `{field: {old, new}}` and rides on the `full` payload profile only, since email and phone are personal data; minimal-profile destinations get the field names and nothing else.

**contact.updated**

```
{
  "type": "contact.updated",
  "timestamp": "2026-09-05T20:02:00Z",
  "data": {
    "event_type": "contact.updated",
    "platform": "instagram",
    "conversation": { "id": 188892 },
    "customer": { "platform_user_id": "17841400123456789", "contact_id": 19214 },
    "updated": {
      "fields": ["email"],
      "source": "api",
      "changed_at": "2026-09-05T20:02:00Z",
      "values": { "email": { "old": null, "new": "jane@example.com" } }
    },
    "meta": { "version": "v1", "payload_profile": "full", "generated_at": "2026-09-05T20:02:00Z" }
  }
}
```

### contact.field_changed

Fires when a [custom field](https://sumgenius.ai/docs/rest-api#custom-fields) value changes on a contact, whether the change came from the portal or the API. One event per changed field. Wire it to your CRM to mirror ChatGenius data without polling.

Delivery is best-effort at-least-once like every event: treat `GET /v1/contacts` as the reconciliation source of truth.

- field_changedobject `{field_key, label, version, changed_at, source, old_value, new_value}`. `source` is `portal`, `api`, or `flow` (captured by a flow's Collect Input or Set Custom Field action).
- versioninteger A per-contact counter that increments on every field write. Keep the highest seen per contact and ignore lower ones, retries can deliver out of order, and the version tells you which event is stale. It is not a delivery-order guarantee.
- old_value · new_valuefull profile only Omitted on the `minimal` payload profile, field values can carry sensitive data, so minimal-profile destinations get the fact of the change without the values.
- conversation.idnullable `null` for a contact with no conversation (for example one created via `POST /v1/contacts`).

**contact.field_changed**

```
{
  "event_id":    "c3a2b1d0e9f8a7b6c5d4e3f2a1b0c9d8",
  "event_type":  "contact.field_changed",
  "occurred_at": "2026-08-01T18:22:40Z",
  "source": {
    "platform": "instagram",
    "channel":  "meta_dm"
  },
  "conversation": {
    "id": 187765
  },
  "customer": {
    "platform_user_id":       "820032950394075",
    "platform_user_name":     "Maya R.",
    "platform_user_username": "maya.r",
    "contact_id": 128,
    "tags": ["Lead"],
    "stage": "qualified",
    "custom_fields": { "refill_due": "2026-08-14" }
  },
  "field_changed": {
    "field_key":  "refill_due",
    "label":      "Refill due",
    "version":    7,
    "changed_at": "2026-08-01T18:22:40Z",
    "source":     "portal",
    "old_value":  "2026-07-14",
    "new_value":  "2026-08-14"
  },
  "meta": {
    "version":         "v1",
    "payload_profile": "full",
    "generated_at":   "2026-08-01T18:22:40Z"
  }
}
```

### post.scheduled · post.published · post.failed

A [Content Studio post](https://sumgenius.ai/docs/rest-api#posts) changed state. `post.scheduled` fires when a post is scheduled or its time is moved, from the portal or the API. `post.published` fires when the post is live on every target, whether it was published now or by the scheduler. `post.failed` fires when a platform rejected it; `post.error_message` says why and `POST /posts/{id}/retry` tries again.

- postobject The post as `GET /posts/{id}` returns it: status, targets, caption, media, `published_ids` per platform, `error_message`, `retry_count`.

These events have no `customer` and no `conversation`: a post is not about a person. `customer` is null and `conversation.id` is null.

**post.published**

```
{
  "type": "post.published",
  "timestamp": "2026-09-12T16:00:07Z",
  "data": {
    "event_type": "post.published",
    "platform": "instagram",
    "conversation": { "id": null },
    "customer": null,
    "post": {
      "id": 90050, "status": "published", "platform": "instagram", "targets": ["instagram"], "post_type": "reels",
      "caption": "Behind the scenes of the new setup.", "published_at": "2026-09-12T16:00:07+00:00",
      "published_ids": { "instagram_media_id": "17912345678901234", "facebook_post_id": null, "tiktok_video_id": null, "tiktok_publish_id": null },
      "error_message": null, "retry_count": 0
    },
    "meta": { "version": "v1", "payload_profile": "full", "generated_at": "2026-09-12T16:00:07Z" }
  }
}
```

## Flow events

A [flow](https://sumgenius.ai/docs/rest-api#flows) changed state, or a person finished one.

### flow.published · flow.activated · flow.paused · flow.session_completed

`flow.published` fires when a draft becomes the live definition, from the builder or `POST /flows/{id}/publish`; `flow.published_version` is the new version. `flow.activated` fires when the flow goes live and `flow.paused` when it stops; pausing also cancels the sessions that were open. These three carry a `flow` block and no person: `customer` is null and `conversation.id` is null.

`flow.session_completed` is about a person: it fires when someone reaches the end of a flow (`end_reason: reached_end`) or the flow hands them to the AI (`ai_handoff`). It carries the usual `customer` and `conversation` plus a `flow_session` block; the session id matches `GET /flow-sessions/{id}`, which has the path they took. Someone who stops answering does not complete, so this is the count of people who got through, not the count who entered.

- flowobject `{id, name, status, trigger_source, keywords, channels, version, published_version}`. Published events add `went_live`.
- flow_sessionobject `{id, flow_id, flow_name, flow_version, end_reason, started_at, completed_at}`.

**flow.session_completed**

```
{
  "type": "flow.session_completed",
  "timestamp": "2026-09-14T18:22:41Z",
  "data": {
    "event_type": "flow.session_completed",
    "platform": "instagram",
    "conversation": { "id": 4940 },
    "customer": { "platform_user_id": "820032950394075", "platform_user_username": "maya.r", "contact_id": 128 },
    "flow_session": {
      "id": 7731, "flow_id": 184, "flow_name": "Guide request", "flow_version": 2,
      "end_reason": "reached_end",
      "started_at": "2026-09-14T18:20:03+00:00", "completed_at": "2026-09-14T18:22:41+00:00"
    },
    "meta": { "version": "v1", "payload_profile": "full", "generated_at": "2026-09-14T18:22:41Z" }
  }
}
```

## Delivery events

Outbound sends that permanently failed.

### message.send_failed

Fires when an outbound [Send API](https://sumgenius.ai/docs/send-api) request permanently fails: the platform returned a non-retryable error (most commonly an expired Meta messaging window) or retries were exhausted. Your system believed the send succeeded; this event lets it reconcile. Match it back to your original request via `send_failed.idempotency_key`.

- send_failedobject `{action, idempotency_key, request_id, error_code, error_message, http_code, attempts, text_attempted}`.
- error_codeSMS For SMS this is the Twilio error code. `30006` (landline or a carrier that cannot receive SMS) reaches you two ways: from the carrier after the send, and from us before it, once a carrier has already rejected that number and we refuse to pay for the same rejection again. Same code either way, so handle it once.
- text_attemptedfull profile only The message text you tried to send. `null` on the `minimal` profile.

**message.send_failed**

```
{
  "event_id":    "b2f1e0d9c8b7a695847362514f0e1d2c",
  "event_type":  "message.send_failed",
  "occurred_at": "2026-07-23T06:40:00Z",
  "source": {
    "platform": "instagram",
    "channel":  "meta_dm"
  },
  "conversation": {
    "id": 42
  },
  "customer": {
    "platform_user_id": "178414"
  },
  "send_failed": {
    "action":          "send_message",
    "idempotency_key": "your-key-abc-123",
    "request_id":      "req-xyz-789",
    "error_code":      "10",
    "error_message":   "Meta 7-day window expired.",
    "http_code":       400,
    "attempts":        1,
    "text_attempted":  "Hi, following up on your order"
  },
  "meta": {
    "version":         "v1",
    "payload_profile": "full",
    "generated_at":   "2026-07-23T06:40:00Z"
  }
}
```
