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

# Event Types

> Reference for every webhook event OpenCX emits — phone calls, chat sessions, handoffs, CSAT — with payload shapes and delivery guarantees.

## Delivery, signatures & retries

Every event is delivered as an HTTP `POST` with a JSON body using the standard envelope `{ event, organization_id, data }`. Endpoints are managed in **Settings → Webhooks** in the dashboard, which also gives you a self-serve portal with full delivery history, per-attempt response codes, and manual replay.

**Headers.** Each delivery carries three headers:

| Header           | Purpose                                                                                                              |
| ---------------- | -------------------------------------------------------------------------------------------------------------------- |
| `svix-id`        | Unique message ID. **Stable across retries and manual redeliveries** — dedupe on it and your consumer is idempotent. |
| `svix-timestamp` | Delivery timestamp (Unix seconds). Reject deliveries older than your tolerance window to prevent replay.             |
| `svix-signature` | HMAC-SHA256 signature of `{id}.{timestamp}.{payload}` using your per-endpoint signing secret (`whsec_…`).            |

**Signature verification.** Every endpoint has its own signing secret, available from the webhooks portal. Verify the signature on every delivery before trusting the payload; reject anything that fails verification or falls outside your timestamp tolerance.

**Retries.** A delivery is considered failed on any non-2xx response or timeout. Failed deliveries are retried automatically with exponential backoff over several hours; after the automatic schedule is exhausted, manual replay remains available from the portal. Return a 2xx as fast as possible — enqueue the event and process it asynchronously rather than doing work inline.

**Idempotency.** Dedupe on the `svix-id` header. Phone-call events additionally carry a producer-side idempotency key of the form `<event_type>.<session_id>` — each phone event type fires at most once per session.

**Ordering is not guaranteed.** Retries mean events can arrive out of order; use the `timestamp` field in the payload, not arrival order.

***

`conversation.started`

This event is triggered when a new conversation is started, regardless of the channel.

```typescript theme={"dark"}
{
  event: 'conversation.started';
  organization_id: string;
  data: {
    conversation_id: string;
    assignee:
    | {
      type: 'AI';
      name: string;
    }
    | {
      id: string;
      type: 'human';
      name: string;
      avatar_url: string;
    };
    contact: {
      id?: string;
      name?: string;
      enriched_data?: {
        address?: string | null;
        phone_number?: string;
        email?: string;
      };
      custom_data?: object;
    };
    channel: 'web' | 'email' | 'phone';
    first_message: string;
    timestamp: string;
  };
}
```

***

`conversation.closed`

This event is triggered when a conversation is closed, a conversation can be marked as resolved or not resolved, and can be closed by a human or an AI.

`closed_by` identifies what triggered the close, while `assignee` identifies who the conversation was assigned to at close time. They are independent: a workflow or AI close on a conversation assigned to a human agent still carries that agent's details in `assignee`, so you can attribute the resolution to the assigned agent even when they didn't close it themselves.

```typescript theme={"dark"}
{
    event: "conversation.closed";
    organization_id: string;
    data: {
        conversation_id?: string;
        timestamp: string;
        contact_id?: string;
        contact?: {
          id: string;
          name?: string;
          email?: string;
          phone_number?: string;
          custom_data?: object;
        };
        human_resolution_status: "resolved" | "not_resolved" | null;
        ai_resolution_status: string | null;
        ai_billing_tier: string | null;
        closed_by:
        | {
            type: "AI";
            name: string;
        }
        | {
            id: number;
            type: "human";
            email: string;
        }
        | {
            type: "workflow";
            id: string;
            workflow_run_id: string;
        }
        | {
            type: "contact";
            id: string;
        };
        // Who the conversation was assigned to when it was closed.
        // `alias` / `alias_avatar_url` carry the agent's per-org masked
        // identity when one is configured, null otherwise.
        assignee:
        | {
            type: "AI";
            name: string;
        }
        | {
            type: "human";
            name: string;
            email: string;
            avatar_url: string;
            alias: string | null;
            alias_avatar_url: string | null;
        }
        | {
            type: "unassigned";
        };
        is_from_third_party: boolean;
        third_party_name: "dynamics365" | "freshchat" | "freshdesk" | "gorgias" | "hubspot" | "intercom" | "open" | "salesforce" | "twilio_flex" | "zendesk" | "zendesk_v2" | null;
        summary: string | null | undefined;
        contact_reason: string | null | undefined;
        user_sentiment: string | null | undefined;
        language: string | null | undefined;
    }[];
}
```

***

`conversation.handoff_to_human`

This event is triggered when a conversation is handed off to a human agent. Note that a handoff event can happen for multiple reasons; for example, a human agent might decide to take over the conversation, or the AI might decide to hand off the conversation to a human agent.

```typescript theme={"dark"}
{
    event: "conversation.handoff_to_human";
    organization_id: string;
    data: {
        conversation_id: string | null | undefined;
        timestamp: string;
        contact?: {
          id: string;
          name?: string;
          email?: string;
          phone_number?: string;
          custom_data?: object;
        };
        reason:
        | "ai_decided_to_handoff_the_conversation"
        | "ai_is_forbidden_from_handling_this_topic"
        | "human_agent_decided_to_take_over"
        | "human_agent_decided_forced_handoff"
        | "ai_decided_to_auto_handoff_the_issue";
        summary: string;
        contact_reason?: string;
        user_sentiment?: string;
        was_handedoff_to_third_party?: boolean;
        during_work_hours?: boolean;
        third_party_name?: "dynamics365" | "freshchat" | "freshdesk" | "gorgias" | "hubspot" | "intercom" | "open" | "salesforce" | "twilio_flex" | "zendesk" | "zendesk_v2" | null;
        message_count: number;
        language: string;
        integration_metadata?: {
           sunshine?: {
             sunshine_conversation_id: string;
           };
        };
    }[];
}
```

`conversation.contact_response`

When a contact sends a message to your inbox.

```typescript theme={"dark"}
{
  event: 'conversation.contact_response';
  organization_id: string;
  data: {
    conversation_id: string;
    timestamp: string;
    response: string;
    channel: 'web' | 'email' | 'phone_voice' | 'slack' | 'sms' | 'whatsapp' | 'instagram' | 'messenger' | 'api' | 'web_voice';
    custom_data?: Record<string, unknown> | null | undefined;
  }
}
```

`conversation.ai_response`

When an AI responds to a contact message,

```typescript theme={"dark"}
{
  event: 'conversation.ai_response';
  organization_id: string;
  data: {
    conversation_id: string;
    timestamp: string;
    response: string;
    contact: {
      id: string;
      name?: string;
      email?: string;
      phone_number?: string;
      custom_data?: object;
    };
    channel: 'web' | 'email' | 'phone_voice' | 'slack' | 'sms' | 'whatsapp' | 'instagram' | 'messenger' | 'api' | 'web_voice';
  }
}
```

***

`conversation.agent_response`

When a human agent responds from the Open inbox,

```typescript theme={"dark"}
{
  event: 'conversation.agent_response';
  organization_id: string;
  data: {
    conversation_id: string;
    timestamp: string;
    customer_id?: string;
    contact?: {
      id: string;
      name?: string;
      email?: string;
      phone_number?: string;
      custom_data?: object;
    };
    response: string;
    channel: 'web' | 'email' | 'phone_voice' | 'slack' | 'sms' | 'whatsapp' | 'instagram' | 'messenger' | 'api' | 'web_voice';
    assignee:
    | {
      type: 'AI';
      name: string;
    }
    | {
      type: 'human';
      name: string;
      email: string;
      avatar_url: string;
      // Per-org agent alias, when one is set. Use these to surface a masked
      // identity instead of the agent's real `name`/`avatar_url`. Always
      // present; both are `null` when the agent has no alias in this org.
      alias: string | null;
      alias_avatar_url: string | null;
    }
    | {
      type: 'unassigned';
    };
  }
}
```

`conversation.third_party_agent_response`

When a third-party software agent responds to a previously handed-off conversation, Open will notify you. In some configurations, Open might also initiate the handoff to a third-party agent, such as Zendesk or Intercom. Even while the third-party agent handles the conversation, Open continues to monitor and send events to your webhooks.

```typescript theme={"dark"}
{
  event: 'conversation.third_party_agent_response';
  organization_id: string;
  data: {
    conversation_id: string | null | undefined;
    timestamp: string;
    contact?: {
      id: string;
      name?: string;
      email?: string;
      phone_number?: string;
      custom_data?: object;
    };
    third_party_name: 'dynamics365' | 'freshchat' | 'freshdesk' | 'gorgias' | 'hubspot' | 'intercom' | 'open' | 'salesforce' | 'twilio_flex' | 'zendesk' | 'zendesk_v2';
    response: string;
    message_count: number;
    language: string;
    channel: 'web' | 'email' | 'phone_voice' | 'slack' | 'sms' | 'whatsapp' | 'instagram' | 'messenger' | 'api' | 'web_voice';
    agent: {
      name?: string;
    };
    attachments?: Array<{ mime_type: string; url: string; file_name?: string }>;
  }
}
```

***

`conversation.handoff_to_third_party_failed`

Triggered when a handoff to a third-party platform (e.g. Zendesk, Intercom) fails.

```typescript theme={"dark"}
{
  event: 'conversation.handoff_to_third_party_failed';
  organization_id: string;
  data: {
    conversation_id: string;
    timestamp: string;
    third_party_name: 'dynamics365' | 'freshchat' | 'freshdesk' | 'gorgias' | 'hubspot' | 'intercom' | 'open' | 'salesforce' | 'twilio_flex' | 'zendesk' | 'zendesk_v2';
    error: string;
    channel: 'web' | 'email' | 'phone_voice' | 'slack' | 'sms' | 'whatsapp' | 'instagram' | 'messenger' | 'api' | 'web_voice';
    language: string | null;
    custom_data?: object | null;
    contact?: {
      id: string;
      name?: string;
      email?: string;
      phone_number?: string;
      custom_data?: object;
    };
  }
}
```

***

`workflow.run.succeeded`

Triggered when a workflow run completes successfully.

```typescript theme={"dark"}
{
  event: 'workflow.run.succeeded';
  organization_id: string;
  data: {
    run_id: string;
    workflow_id: string;
    trigger_cause: 'automatic' | 'manual' | 'scheduled';
    triggered_by: number | null;
    is_test_run: boolean;
  }
}
```

***

`workflow.run.failed`

Triggered when a workflow run fails.

```typescript theme={"dark"}
{
  event: 'workflow.run.failed';
  organization_id: string;
  data: {
    run_id: string;
    workflow_id: string;
    workflow_name: string;
    session_id: string | null;
    trigger_cause: 'automatic' | 'manual' | 'scheduled';
    triggered_by: number | null;
    is_test_run: boolean;
    error: {
      message: string;
      name: string;
    };
  }
}
```

***

`workflow.dispatch_failed`

Triggered when a phone call could not hand one of its lifecycle stages to the workflow engine at all — so the workflows listening on those triggers never ran, and no `workflow.run.failed` can follow (there is no run). This is the only signal for that failure mode; a stage whose workflows did run but failed produces `workflow.run.failed` instead, never both.

| Field           | Description                                                                                                                                                                                                  |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `stage`         | Which point in the call failed to dispatch: `call_started`, `pre_transfer`, `post_transfer`, `pre_call_finished`, `call_finished`, or `workflow_tool` (an AI-triggered workflow the agent invoked mid-call). |
| `trigger_types` | The workflow trigger types that would have fired at that stage (e.g. `["phone-call-finished"]`).                                                                                                             |
| `error`         | Why the dispatch failed, in plain text.                                                                                                                                                                      |

```typescript theme={"dark"}
{
  event: 'workflow.dispatch_failed';
  organization_id: string;
  data: {
    session_id: string;
    stage: 'call_started' | 'pre_transfer' | 'post_transfer' | 'pre_call_finished' | 'call_finished' | 'workflow_tool';
    trigger_types: string[];
    error: string;
    timestamp: string;
  }
}
```

***

`csat.requested`

Triggered when a CSAT survey is sent to a contact (via email, SMS, WhatsApp, Slack, or shown in the web widget).

```typescript theme={"dark"}
{
  event: 'csat.requested';
  organization_id: string;
  data: {
    session_id: string;
    contact_id: string | null;
    channel: 'web' | 'email' | 'phone_voice' | 'slack' | 'sms' | 'whatsapp' | 'instagram' | 'messenger' | 'api' | 'web_voice';
    delivery_method: string;
    timestamp: string;
  }
}
```

***

`csat.score_submitted`

Triggered when a customer submits or updates a CSAT score for a conversation.

```typescript theme={"dark"}
{
  event: 'csat.score_submitted';
  organization_id: string;
  data: {
    csat_id: string;
    session_id: string;
    contact_id: string | null;
    score: number; // 1–5
    comment: string | null;
    channel: string;
    timestamp: string;
  }
}
```

***

## Phone Call Events

These events are fired for phone calls — both calls handled by AI phone agents and human-only direct-ring calls (see `is_human_call`). They apply to both inbound and outbound calls. When a phone call starts, a `conversation.started` event is co-fired alongside `phone_call.started`. Phone calls do **not** emit `conversation.closed` — its chat-centric shape (`closed_by`, `is_from_third_party`, `contact_reason`, …) doesn't map cleanly to a phone interaction. Listen on `phone_call.ended` instead, which carries the equivalent resolution and context fields below.

***

`phone_call.started`

Triggered when a phone call is initiated — either an inbound call is received or an outbound call is placed.
Also co-fires a `conversation.started` event.

```typescript theme={"dark"}
{
  event: 'phone_call.started';
  organization_id: string;
  data: {
    session_id: string;
    phone_agent_id: string | null;   // null for human-only (direct-ring) calls
    phone_agent_name: string | null; // null for human-only (direct-ring) calls
    is_human_call: boolean;          // true when no AI agent was on the call
    direction: 'inbound' | 'outbound';
    from_phone_number: string | null;
    to_phone_number: string | null;
    contact?: {
      id: string;
      name?: string;
      email?: string;
      phone_number?: string;
      custom_data?: object;
    };
    timestamp: string;
  }
}
```

***

`phone_call.answered`

Triggered when the remote party picks up the call (SIP participant joined).

```typescript theme={"dark"}
{
  event: 'phone_call.answered';
  organization_id: string;
  data: {
    session_id: string;
    phone_agent_id: string | null;   // null for human-only (direct-ring) calls
    phone_agent_name: string | null; // null for human-only (direct-ring) calls
    is_human_call: boolean;          // true when no AI agent was on the call
    direction: 'inbound' | 'outbound';
    from_phone_number: string | null;
    to_phone_number: string | null;
    contact?: {
      id: string;
      name?: string;
      email?: string;
      phone_number?: string;
      custom_data?: object;
    };
    timestamp: string;
  }
}
```

***

`phone_call.ended`

Triggered when a phone call ends normally. This event carries all end-of-call context — resolution status, who hung up, the resolved contact, the agent's configured languages, the call summary, and the session's `custom_data` (SIP X-headers + call metadata) — so listeners do not need a separate `conversation.closed` event for phone calls.

It is sent **after** the session's post-call workflows (the `Call Finished` trigger) have run, so anything those workflows merge into the session's custom data is on the payload — an end-of-call analysis, a classification, a score. Expect it a few seconds after the hangup, once those workflows finish.

| Field                     | Description                                                                                                                                                                                                                                                                                                                                                                                          |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `call_ended_by`           | Who hung up: `user` (contact), `agent` (AI agent, including transfers), `system` (e.g. inactivity timeout), or `null` when unknown (e.g. outbound calls that never connected).                                                                                                                                                                                                                       |
| `ai_resolution_status`    | Mirrors the persisted `chat_sessions.ai_closure_type`: `resolved` for completed calls, `handed_off` for transfers, or `null` for unanswered/failed calls.                                                                                                                                                                                                                                            |
| `human_resolution_status` | Always `null` for phone calls — there is no human-resolve path on a call.                                                                                                                                                                                                                                                                                                                            |
| `contact_id`              | The resolved contact's id, or `null` if no contact was matched.                                                                                                                                                                                                                                                                                                                                      |
| `contact`                 | Full contact record (id, name, email, phone, custom\_data) when `contact_id` is set.                                                                                                                                                                                                                                                                                                                 |
| `language`                | The phone agent's configured language list (e.g. `["en"]`, `["es","en"]`), or `null` if no agent is attached.                                                                                                                                                                                                                                                                                        |
| `summary`                 | The call summary as stored on the session when the event is sent — the generated summary when one was produced, otherwise a short placeholder.                                                                                                                                                                                                                                                       |
| `custom_data`             | The session's `custom_data` jsonb after the post-call workflows: incoming SIP X-headers (with the `X-` prefix stripped), values from the `X-OPENCX-SESSION-CUSTOM-DATA` header, call metadata (`duration_seconds`, `participant_count`, `is_outbound`, `recording_available`, `agent_version`, `call_status`, optional `knowledge_snapshot`), plus whatever the `Call Finished` workflows merged in. |

```typescript theme={"dark"}
{
  event: 'phone_call.ended';
  organization_id: string;
  data: {
    session_id: string;
    phone_agent_id: string | null;   // null for human-only (direct-ring) calls
    phone_agent_name: string | null; // null for human-only (direct-ring) calls
    is_human_call: boolean;          // true when no AI agent was on the call
    direction: 'inbound' | 'outbound';
    from_phone_number: string | null;
    to_phone_number: string | null;
    contact?: {
      id: string;
      name?: string;
      email?: string;
      phone_number?: string;
      custom_data?: object;
    };
    timestamp: string;
    duration_seconds: number | null;
    outcome: 'completed' | 'unanswered' | 'transferred' | 'failed';
    recording_url: string | null;
    summary: string | null;
    call_ended_by: 'user' | 'agent' | 'system' | null;
    ai_resolution_status: 'resolved' | 'handed_off' | 'assumed_resolved' | null;
    human_resolution_status: null;
    contact_id: string | null;
    language: string[] | null;
    custom_data: object | null;
  }
}
```

***

`phone_call.transferred`

Triggered when the AI agent transfers a call to another destination (phone number, SIP URI, or graceful hangup).

```typescript theme={"dark"}
{
  event: 'phone_call.transferred';
  organization_id: string;
  data: {
    session_id: string;
    phone_agent_id: string | null;   // null for human-only (direct-ring) calls
    phone_agent_name: string | null; // null for human-only (direct-ring) calls
    is_human_call: boolean;          // true when no AI agent was on the call
    direction: 'inbound' | 'outbound';
    from_phone_number: string | null;
    to_phone_number: string | null;
    contact?: {
      id: string;
      name?: string;
      email?: string;
      phone_number?: string;
      custom_data?: object;
    };
    timestamp: string;
    destination: {
      id: string;
      name: string;
      type: 'phone' | 'sip' | 'team' | 'hangup';
    };
    reason: string | null;
    // Present only when the transfer was triggered by the platform's automatic
    // failover on a non-recoverable mid-call error. Branch on presence only.
    failover?: {
      reason: 'provider_failure';
    };
  }
}
```

***

`phone_call.failed`

Triggered when a phone call fails to connect. The `failure_category` field indicates the type of failure.

| Category      | Description                                |
| ------------- | ------------------------------------------ |
| `no_answer`   | Contact didn't pick up                     |
| `rejected`    | Call was actively rejected                 |
| `busy`        | Line was busy                              |
| `trunk_error` | SIP trunk failure                          |
| `failed`      | Generic failure (timeout, SIP error, etc.) |

```typescript theme={"dark"}
{
  event: 'phone_call.failed';
  organization_id: string;
  data: {
    session_id: string;
    phone_agent_id: string | null;   // null for human-only (direct-ring) calls
    phone_agent_name: string | null; // null for human-only (direct-ring) calls
    is_human_call: boolean;          // true when no AI agent was on the call
    direction: 'inbound' | 'outbound';
    from_phone_number: string | null;
    to_phone_number: string | null;
    contact?: {
      id: string;
      name?: string;
      email?: string;
      phone_number?: string;
      custom_data?: object;
    };
    timestamp: string;
    failure_reason: string;
    failure_category: 'no_answer' | 'rejected' | 'busy' | 'trunk_error' | 'failed';
  }
}
```
