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

# Email Cases

> Connect Salesforce so email handoffs create Cases your reps work from the console they already use.

export const SalesforceEmailFlow = () => {
  const BEAM_COLOR = "#22c55e";
  const renderBeam = ({id, d, duration = 2.8, delay = 0, reverse = false}) => {
    const values = reverse ? {
      x1: "110%;-10%",
      x2: "120%;0%"
    } : {
      x1: "-10%;110%",
      x2: "0%;120%"
    };
    return <g key={id}>
        <path d={d} stroke="currentColor" strokeOpacity="0.18" strokeWidth="1.5" fill="none" strokeLinecap="round" />
        <path d={d} stroke={`url(#${id})`} strokeWidth="2" fill="none" strokeLinecap="round" />
        <defs>
          <linearGradient id={id} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="0">
            <stop offset="0%" stopColor={BEAM_COLOR} stopOpacity="0" />
            <stop offset="45%" stopColor={BEAM_COLOR} stopOpacity="1" />
            <stop offset="55%" stopColor={BEAM_COLOR} stopOpacity="1" />
            <stop offset="100%" stopColor={BEAM_COLOR} stopOpacity="0" />
            <animate attributeName="x1" values={values.x1} dur={`${duration}s`} begin={`${delay}s`} repeatCount="indefinite" />
            <animate attributeName="x2" values={values.x2} dur={`${duration}s`} begin={`${delay}s`} repeatCount="indefinite" />
          </linearGradient>
        </defs>
      </g>;
  };
  const CUST_X = 70, CASE_X = 290, APEX_X = 520, OCX_X = 770;
  const Y = 160, R = 38;
  const b1 = `M ${CUST_X + R},${Y} L ${CASE_X - R},${Y}`;
  const b2 = `M ${CASE_X + R},${Y} L ${APEX_X - 60},${Y}`;
  const b3 = `M ${APEX_X + 60},${Y} C ${APEX_X + 120},${Y - 20} ${OCX_X - R - 60},${Y - 20} ${OCX_X - R},${Y}`;
  const b3r = `M ${APEX_X + 60},${Y} C ${APEX_X + 120},${Y + 20} ${OCX_X - R - 60},${Y + 20} ${OCX_X - R},${Y}`;
  return <div className="zendesk-flow not-prose my-8 w-full">
      <div className="zendesk-flow-frame relative w-full overflow-hidden rounded-md border border-black/10 bg-white px-4 py-6 dark:border-white/10 dark:bg-zinc-950">
        <div className="zendesk-flow-grid pointer-events-none absolute inset-0" aria-hidden />
        <svg viewBox="0 0 860 300" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" className="relative z-[1] block w-full text-zinc-500 dark:text-zinc-400" role="img" aria-label="Salesforce Email Cases flow: email-to-case creates a Case, four Apex triggers forward events to OpenCX">
          {renderBeam({
    id: "sfe-1",
    d: b1,
    duration: 2.6,
    delay: 0
  })}
          {renderBeam({
    id: "sfe-2",
    d: b2,
    duration: 2.6,
    delay: 0.4
  })}
          {renderBeam({
    id: "sfe-3a",
    d: b3,
    duration: 2.6,
    delay: 0.8
  })}
          {renderBeam({
    id: "sfe-3b",
    d: b3r,
    duration: 2.6,
    delay: 2.0,
    reverse: true
  })}

          <g className="fill-zinc-500 dark:fill-zinc-400" style={{
    font: "500 10.5px ui-sans-serif, system-ui, sans-serif"
  }}>
            <text x={(CUST_X + CASE_X) / 2} y={Y - 18} textAnchor="middle">email</text>
            <text x={(CASE_X + APEX_X - 60) / 2 + 30} y={Y - 18} textAnchor="middle">Email-to-Case</text>
            <text x={(APEX_X + OCX_X) / 2 + 30} y={Y - 42} textAnchor="middle">webhook · Apex</text>
            <text x={(APEX_X + OCX_X) / 2 + 30} y={Y + 54} textAnchor="middle">reply · case update</text>
          </g>

          {}
          <g>
            <circle cx={CUST_X} cy={Y} r={R} className="fill-white stroke-black/10 dark:fill-zinc-900 dark:stroke-white/10" strokeWidth="1" />
            <g transform={`translate(${CUST_X - 14} ${Y - 10})`} className="fill-none stroke-zinc-700 dark:stroke-zinc-200" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <rect x="0" y="0" width="28" height="20" rx="2" />
              <path d="M 0,3 L 14,13 L 28,3" />
            </g>
            <text x={CUST_X} y={Y + R + 22} textAnchor="middle" className="fill-zinc-700 dark:fill-zinc-200" style={{
    font: "600 12px ui-sans-serif, system-ui, sans-serif"
  }}>Customer</text>
          </g>

          {}
          <g>
            <circle cx={CASE_X} cy={Y} r={R} className="fill-white stroke-black/10 dark:fill-zinc-900 dark:stroke-white/10" strokeWidth="1" />
            <foreignObject x={CASE_X - 22} y={Y - 16} width="44" height="32">
              <div className="flex h-full w-full items-center justify-center">
                <img src="/images/integrations/salesforce.svg" alt="" className="h-7 w-7 object-contain" />
              </div>
            </foreignObject>
            <text x={CASE_X} y={Y + R + 22} textAnchor="middle" className="fill-zinc-700 dark:fill-zinc-200" style={{
    font: "600 12px ui-sans-serif, system-ui, sans-serif"
  }}>Case</text>
            <text x={CASE_X} y={Y + R + 36} textAnchor="middle" className="fill-zinc-500 dark:fill-zinc-400" style={{
    font: "400 10.5px ui-sans-serif, system-ui, sans-serif"
  }}>Salesforce</text>
          </g>

          {}
          <g>
            <rect x={APEX_X - 60} y={Y - 54} width="120" height="108" rx="10" className="fill-white stroke-black/10 dark:fill-zinc-900 dark:stroke-white/10" strokeWidth="1" />
            {["Case created", "Customer reply", "Agent reply", "Owner change"].map((label, i) => <g key={`apex-${i}`}>
                <circle cx={APEX_X - 46} cy={Y - 38 + i * 24} r="3.5" fill={BEAM_COLOR} />
                <text x={APEX_X - 36} y={Y - 35 + i * 24} className="fill-zinc-700 dark:fill-zinc-200" style={{
    font: "500 10.5px ui-sans-serif, system-ui, sans-serif"
  }}>{label}</text>
              </g>)}
            <text x={APEX_X} y={Y + 72} textAnchor="middle" className="fill-zinc-700 dark:fill-zinc-200" style={{
    font: "600 11.5px ui-sans-serif, system-ui, sans-serif"
  }}>Apex triggers</text>
            <text x={APEX_X} y={Y + 86} textAnchor="middle" className="fill-zinc-500 dark:fill-zinc-400" style={{
    font: "400 10px ui-sans-serif, system-ui, sans-serif"
  }}>Named Credential POST</text>
          </g>

          {}
          <g>
            <circle cx={OCX_X} cy={Y} r={R + 4} className="fill-none" stroke={BEAM_COLOR} strokeOpacity="0.25" strokeWidth="1.5">
              <animate attributeName="r" values={`${R + 2};${R + 12};${R + 2}`} dur="3.6s" repeatCount="indefinite" />
              <animate attributeName="stroke-opacity" values="0.35;0;0.35" dur="3.6s" repeatCount="indefinite" />
            </circle>
            <circle cx={OCX_X} cy={Y} r={R} className="fill-white stroke-black/10 dark:fill-zinc-900 dark:stroke-white/10" strokeWidth="1" />
            <foreignObject x={OCX_X - 28} y={Y - 14} width="56" height="28">
              <div className="flex h-full w-full items-center justify-center">
                <img src="/logo-light.svg" alt="" className="block h-full w-full object-contain dark:hidden" />
                <img src="/logo.svg" alt="" className="hidden h-full w-full object-contain dark:block" />
              </div>
            </foreignObject>
            <text x={OCX_X} y={Y + R + 22} textAnchor="middle" className="fill-zinc-700 dark:fill-zinc-200" style={{
    font: "600 12px ui-sans-serif, system-ui, sans-serif"
  }}>OpenCX</text>
            <text x={OCX_X} y={Y + R + 36} textAnchor="middle" className="fill-zinc-500 dark:fill-zinc-400" style={{
    font: "400 10.5px ui-sans-serif, system-ui, sans-serif"
  }}>AI agent</text>
          </g>
        </svg>
      </div>
    </div>;
};

The Email Cases path wires OpenCX into Salesforce Case Management. Use it for **email** — the channel where every handoff should become a trackable <Tooltip tip="A unit of work in Salesforce that tracks a single customer issue across comments, assignees, and status.">Case</Tooltip>. Chat, SMS, WhatsApp, and phone belong on [Live Messaging](/integrations/salesforce/live-messaging) instead.

<SalesforceEmailFlow />

<Info>
  Setup takes about 30 minutes. You need a Salesforce System Administrator profile and admin access in OpenCX.
</Info>

## Before you start

<AccordionGroup>
  <Accordion title="Salesforce edition with API access" icon="circle-check">
    Enterprise, Unlimited, or Developer edition. Professional edition requires API access to be enabled separately.
  </Accordion>

  <Accordion title="System Administrator profile in Salesforce" icon="user-shield">
    You need to create a Connected App, a Named Credential, an Apex Class, and an Apex Trigger. Standard user roles cannot reach these.
  </Accordion>

  <Accordion title="Owner or admin in OpenCX" icon="key">
    Required to save integration settings in [Settings → Integrations](https://platform.open.cx/settings/integrations).
  </Accordion>
</AccordionGroup>

## Setup

<Steps>
  <Step title="Create an External Client App in Salesforce">
    <Note>
      Salesforce **Starter** edition does not support Connected Apps or External Client Apps. OAuth/API integrations require **Enterprise**, **Unlimited**, **Developer**, or **Performance** edition.
    </Note>

    **External Client Apps (ECAs) are Salesforce's current-generation connected apps and the recommended way to connect OpenCX.** A classic Connected App also works (see the note at the end of this step). One scope caveat decides everything: an ECA **cannot grant the `Full access (full)` scope**, so an ECA connection must use the least-privilege scope set below. OpenCX requests `full` by default for historical reasons — **before connecting with an ECA, ask OpenCX to switch your workspace to least-privilege scopes**, or the login ends in `OAUTH_APPROVAL_ERROR_GENERIC`.

    **Create the app** in the **same Salesforce org you will log into when connecting** — an ECA is local to its org, and authorizing into any other org fails with `OAUTH_AUTHORIZATION_BLOCKED: Cross-org OAuth flows are not supported for this external client app`.

    Go to **Setup → App Manager → New External Client App**:

    1. Fill in the basics (App Name, API Name, Contact Email) and set **Distribution State** to **Local**.
    2. Enable **OAuth Settings** and set the **Callback URL** to:
       ```
       https://api.open.cx/backend/salesforce-case-management/oauth/callback
       ```
    3. Select exactly these OAuth scopes:

       | Checkbox                            | Emits                             |
       | ----------------------------------- | --------------------------------- |
       | **Manage user data via APIs**       | `api`                             |
       | **Access the identity URL service** | `id`                              |
       | **Perform requests at any time**    | `refresh_token`, `offline_access` |

       Do not add others, and do not pick `Full access` — the app cannot grant it.
    4. Under the app's **OAuth policies**, check **Require Secret for Web Server Flow** and **Require Secret for Refresh Token Flow**. PKCE is required by default on ECAs; OpenCX always sends it.
    5. Set the **Refresh Token Policy** to **Refresh token is valid until revoked**. Any expiring policy kills the integration silently when the timer lapses — the most common cause of "it worked yesterday".
    6. **Strongly recommended:** enable the **Client Credentials Flow** and set the integration user as its **Run As** user. This lets the connection be restored programmatically if the refresh token is ever revoked, without another interactive login.
    7. Save.

    <Note>
      **Why these three scopes are sufficient** — verified against a live org by exercising every operation the integration performs:

      | Scope                                                                | Why it's needed                                                                                                                                                                         |
      | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | **Manage user data via APIs** (`api`)                                | Every SOQL query and every create/update/delete on Case, EmailMessage, CaseComment, Contact, User, RecordType, OrgWideEmailAddress, ContentVersion, ContentDocumentLink and Attachment. |
      | **Access the identity URL service** (`id`)                           | Resolving the integration user, which the draft-reply and attachment paths use to find records they own. Without it those paths fail.                                                   |
      | **Perform requests at any time** (`refresh_token`, `offline_access`) | Refreshing the access token. Without it the integration stops working when the token expires and needs manual re-auth.                                                                  |

      The integration is data-plane only — it makes no Metadata API, Tooling API or SOAP calls — which is why `api` covers it.

      Two consequences worth knowing:

      * **An OAuth scope never grants more than the integration user's own profile allows.** Even with `full`, the token can only reach what that user can reach. Provisioning a dedicated integration user with a minimal profile is the stronger lever, and it works no matter which scope you pick — see the object permissions below.
      * **The narrow set is what makes External Client Apps work.** ECAs cannot grant `full`; on these three scopes they connect and operate fully (verified in production on an ECA org).

      **Minimum object permissions for the integration user:**

      | Object                                         | Permissions                                                                  |
      | ---------------------------------------------- | ---------------------------------------------------------------------------- |
      | Case                                           | Read, Edit                                                                   |
      | EmailMessage                                   | Read, Create, Edit, Delete (delete is required to clear stale assist drafts) |
      | CaseComment                                    | Read, Create                                                                 |
      | ContentVersion, ContentDocumentLink            | Read, Create, Delete                                                         |
      | Attachment                                     | Read, Create                                                                 |
      | Contact, User, RecordType, OrgWideEmailAddress | Read                                                                         |
    </Note>

    <Note>
      **Classic Connected App alternative.** If your org standardizes on classic Connected Apps: newer orgs must first enable creation under **Setup → External Client Apps → External Client App Settings → Allow creation of connected apps**, then **App Manager → New Connected App** with the same callback URL, either the least-privilege scopes above or **Full access (full)** plus **Perform requests at any time**, both **Require Secret** checkboxes, and the same **valid until revoked** refresh token policy.
    </Note>

    **Copy the Consumer Key and Consumer Secret:**

    1. In **App Manager** (or **Setup → External Client Apps**), open your app.
    2. Under **API (Enable OAuth Settings)**, click **Manage Consumer Details**.
    3. Salesforce will ask you to verify your identity — typically an email verification code or other MFA challenge.
    4. After you verify, Salesforce opens a page displaying the **Consumer Key** and **Consumer Secret**. Copy both.

    <Warning>
      It may take 2–10 minutes for a new Connected App to activate — if the next step fails with `invalid_client_id`, wait and retry. And if you ever recreate the app, paste the **new** Consumer Key and Secret into OpenCX before reconnecting; the saved credentials are what get sent to Salesforce.
    </Warning>
  </Step>

  <Step title="Enter credentials and connect">
    <Frame>
      <video autoPlay muted loop playsInline poster="/videos/salesforce-email-cases/full-flow.jpg" style={{ width: '100%', borderRadius: '10px' }}>
        <source src="https://mintcdn.com/openchat/9Uks5Hp3i1Ni32QI/videos/salesforce-email-cases/full-flow.mp4?fit=max&auto=format&n=9Uks5Hp3i1Ni32QI&q=85&s=de5f21e2a2a827cb0f7ae0434254b56f" type="video/mp4" data-path="videos/salesforce-email-cases/full-flow.mp4" />
      </video>
    </Frame>

    In your [OpenCX dashboard](https://platform.open.cx/settings/integrations), open **Salesforce** and choose **Case Management**.

    | Field               | Value                                                                                                      |
    | ------------------- | ---------------------------------------------------------------------------------------------------------- |
    | **Consumer Key**    | The Consumer Key from your Connected App.                                                                  |
    | **Consumer Secret** | The Consumer Secret from your Connected App.                                                               |
    | **Login URL**       | `https://login.salesforce.com` for production. Use `https://test.salesforce.com` for sandbox environments. |
    | **Redirect URI**    | Auto-populated. Do not change this value.                                                                  |

    Click **Save and Connect**. A Salesforce login window opens. Sign in, then on the authorization screen click **Allow** to grant OpenCX access. When you return to OpenCX, the status shows **Connected**.

    <Note>
      The **Webhook URL** appears only after the connection succeeds — it does not show up the moment you open the Case Management form. Complete the **Save and Connect → Allow** flow first, then the URL is revealed in the same place for you to copy in the next step.
    </Note>
  </Step>

  <Step title="Set up Email-to-Case routing and mail forwarding">
    OpenCX sends outbound emails from inside Salesforce (via the Apex trigger further down). For contacts to reach Salesforce in the first place — and for their replies to flow back onto the same Case — you need an Email-to-Case routing address configured and your customer-facing mailbox forwarding into it.

    **1. Enable Email-to-Case.** Go to **Setup → Quick Find → "Email-to-Case" → Email-to-Case Settings** and confirm:

    * **Enable Email-to-Case** — checked.
    * **Enable on-demand service** — checked (this is what makes the Salesforce-generated inbound address work without running a local agent).
    * **Insert email threading token in email subject** — checked.
    * **Insert email threading token in email body** — checked.
    * **Use email headers for threading** — checked.

    Once Email-to-Case is enabled, it can't be disabled, but its settings can be edited. If the feature is already enabled, just verify the checkboxes above.

    **2. Create a Routing Address.** On the same Email-to-Case Settings page, scroll to **Routing Addresses** and click **New**. Fill the form exactly as follows for a production-ready setup:

    **Routing Information**

    | Field                            | Value                                                      | Notes                                                                                                          |
    | -------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
    | **Routing Name**                 | `OpenCX Support` (or one label per support queue / region) | Display label only. Create one routing address per customer-facing mailbox.                                    |
    | **Email Address**                | `support@yourcompany.com`                                  | The actual address customers email. Must be on a domain you control. Never use a personal inbox in production. |
    | **Controlled by Permission Set** | Unchecked                                                  | Only enable if you gate routing access via permission sets.                                                    |

    **Email Settings**

    | Field                  | Value       | Notes                                                                                                                                                       |
    | ---------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | **Save Email Headers** | **Checked** | Required for reliable Lightning Threading — headers are how customer replies get matched back to the correct Case when quoted-body tokens aren't preserved. |
    | **Accept Email From**  | Blank       | Only populate for internal-only support (e.g. `@yourcompany.com` domain allowlist). A populated list silently discards mail from anyone not on it.          |

    **Task Settings**

    | Field                      | Value     | Notes                                                                                                                      |
    | -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------- |
    | **Create Task from Email** | Unchecked | The `EmailMessage` record on the Case is the source of truth. Auto-creating a Task on top of it clutters reps' task lists. |
    | **Task Status**            | —         | Ignored when the checkbox above is off. If you do enable Task creation, use `Completed`.                                   |

    **Case Settings**

    | Field             | Value                                                                       | Notes                                                                                                                                                                                                                     |
    | ----------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | **Case Owner**    | A Queue (change the dropdown from `User` to `Queue`, e.g. `Tier 1 Support`) | A Queue gives the whole team visibility and lets Assignment Rules / Omni-Channel redistribute work. A single User bottlenecks every Case on that person. Leave blank only if Case Assignment Rules own initial ownership. |
    | **Case Priority** | `Medium`                                                                    | Reasonable default. Override later via Assignment Rules or a Flow.                                                                                                                                                        |
    | **Case Origin**   | `Email`                                                                     | Must be set (required field). Matches what OpenCX writes and enables filtering on email-originated Cases.                                                                                                                 |

    **Flow Settings**

    | Field                 | Value | Notes                                                                                                      |
    | --------------------- | ----- | ---------------------------------------------------------------------------------------------------------- |
    | **Omni-Channel Flow** | Blank | Only populate if using Salesforce Omni-Channel. Most orgs starting out route via Assignment Rules instead. |
    | **Fallback Queue**    | Blank | Only used as a backup if the Omni-Channel flow fails to route.                                             |

    Save. Salesforce then emails a verification link to the **Email Address** you entered — open that inbox and click the link. The address must show **Verified** before anything will work.

    Salesforce also auto-generates a long **Email Services Address** at the bottom of the routing record — something like:

    ```
    support@ffbg6d2e0u5d287mxdvcji8a7ebeorhc26atdodxn5bzy3c95.gk-nowtbuar.can96.case.salesforce.com
    ```

    **Copy this long address.** You'll forward into it in the next step. To retrieve it later: **Setup → Email-to-Case → Routing Addresses → \[row] → Email Services Address**.

    **3. Forward your customer-facing mailbox to the Salesforce inbound address.** Email clients don't let you set Salesforce as an MX target directly (unless you control the DNS zone for the customer-facing domain). The reliable path is standard forwarding at the mailbox level. Example for Gmail:

    1. In Gmail for `support@yourcompany.com`, open **Settings → Forwarding and POP/IMAP → Add a forwarding address**.
    2. Paste the long `...case.salesforce.com` address.
    3. Gmail sends a verification email to that address. Because the destination is Salesforce, the verification email lands as a new Case in your org. In Developer Console → Query Editor:

       ```sql theme={"dark"}
       SELECT Id, Subject, TextBody, CreatedDate
       FROM EmailMessage
       WHERE FromAddress = 'forwarding-noreply@google.com'
       ORDER BY CreatedDate DESC
       LIMIT 1
       ```

       Open the newest row and copy the 9-digit code from `TextBody`.
    4. Paste the code back into Gmail's forwarding screen → **Verify**. The address should now be **Confirmed** and forwarding is live.

    Other providers (Outlook, FastMail, custom domains on cPanel, etc.) follow the same shape — forward → verify via the code Salesforce captured. If forwarding stays stuck at "Unverified", no Cases will be created; don't proceed until it's Verified.

    <Note>
      Plain mailbox forwarding can fail anti-spam / DMARC checks on the way into Salesforce. If test emails never turn into Cases after the forward is verified, publish `include:_spf.salesforce.com` in your SPF record, or use SRS-aware forwarding.
    </Note>
  </Step>

  <Step title="Configure the webhook in Salesforce">
    After connecting, OpenCX displays a **Webhook URL**. It looks like this, with the token as the final path segment:

    ```
    https://api.open.cx/backend/salesforce-case/webhook/eyJhbGciOi…
    └──────────── base URL ────────────────────────┘ └── token ──┘
    ```

    Split it into two parts — you need them separately below:

    * **Base URL** — everything up to and including `/webhook`
    * **Token** — the final path segment

    <Warning>
      The token is a credential. Treat it like a password: keep it out of the callout URL, out of Apex source, and out of anything you paste into a ticket or a screenshot.
    </Warning>

    <Note>
      **Send the token as a header, not in the URL.** The endpoint accepts it three ways, in this order of precedence:

      1. `x-opencx-token: <token>` — recommended
      2. `Authorization: Bearer <token>`
      3. the final path segment of the URL — **legacy**, still accepted so existing setups keep working

      Prefer a header. A token in the URL path leaks into Salesforce debug logs, Apex Jobs entries, and anywhere the callout URL is copied.
    </Note>

    **Create a Named Credential** in Salesforce:

    1. Go to **Setup → Security → Named Credentials**. On the Named Credentials page, click the **dropdown arrow next to New** and choose **New Legacy**.
    2. Set **Label** to `OpenCX_Integration`. The **Name** field auto-populates from the label — it must end up exactly `OpenCX_Integration`, because the Apex class below references it as `callout:OpenCX_Integration`. If you pick a different Name, update every `callout:` reference in the Apex class to match — otherwise every webhook callout fails silently (visible only as failed jobs in **Setup → Apex Jobs**) and no sessions are created in OpenCX.
    3. Paste the **base URL** into the **URL** field — *without* the token segment.
    4. Under **Authentication**, set **Identity Type** to `Anonymous` and **Authentication Protocol** to `No Authentication`.
    5. Under **Callout Options**, check **Generate Authorization Header** and **Allow Merge Fields in HTTP Body**.
    6. Save.

    **Store the token so Apex can read it without hardcoding it.** Legacy Named Credentials have no custom-header field, so the header is set in Apex — but the value should not live in the class body. Create a protected Custom Setting to hold it:

    1. Go to **Setup → Custom Settings → New**.
    2. **Label** `OpenCX Settings`, **Object Name** `OpenCX_Settings`, **Setting Type** `Hierarchy`, **Visibility** `Protected`.
    3. Save, then **New** in the Custom Fields section: a **Text(255)** field named `Webhook_Token`.
    4. Click **Manage → New** at the org-default level and paste the token into `Webhook_Token`. Save.

    A protected hierarchy Custom Setting is not readable from outside the org's namespace and does not appear in the callout URL, so the token stays out of debug logs and out of source control.

    <Tip>
      If your org uses the newer **External Credential** model instead of legacy Named Credentials, you can attach a **Custom Header** (`x-opencx-token`) directly to the credential and skip the Custom Setting — Salesforce then stores and injects the token itself, and the Apex below no longer needs the `setHeader` line.
    </Tip>

    **Create the Apex Class:**

    <Note>
      Every class and trigger name in the rest of this step is an **example** — you may keep the suggested names or pick your own. Names don't affect behavior, but if you rename the Apex class, update every trigger's reference to match.
    </Note>

    Go to **Setup → Apex Classes** and click **New**. Alternatively, open the **Developer Console** (top-right gear icon → **Developer Console**), then go to **File → New → Apex Class**. Either path works.

    Paste the following (the class name `OpenSalesforceCaseManagement` is an example — rename freely, but remember to update the triggers below to match):

    ```apex theme={"dark"}
    public class OpenSalesforceCaseManagement {
        @future(callout=true)
        public static void sendCaseEvents(List<Id> caseIds) {
            Http http = new Http();

            for (Id caseId : caseIds) {
                try {
                    String endpointUrl = 'callout:OpenCX_Integration?caseId=' + EncodingUtil.urlEncode(caseId, 'UTF-8');

                    HttpRequest req = new HttpRequest();
                    req.setEndpoint(endpointUrl);
                    req.setMethod('POST');
                    req.setHeader('Content-Type', 'application/json');
                    req.setHeader('x-opencx-token', OpenCX_Settings__c.getOrgDefaults().Webhook_Token__c);
                    req.setBody('');

                    HttpResponse res = http.send(req);
                    System.debug('Sent webhook for Case ' + caseId + ', response: ' + res.getBody());
                } catch (Exception e) {
                    System.debug('Failed to send webhook for Case ' + caseId + ': ' + e.getMessage());
                }
            }
        }

        @future(callout=true)
        public static void sendCaseReplyEvents(List<Id> caseIds) {
            Http http = new Http();
            for (Id caseId : caseIds) {
                try {
                    String endpointUrl = 'callout:OpenCX_Integration?caseId=' + EncodingUtil.urlEncode(caseId, 'UTF-8') + '&type=new_user_reply';

                    HttpRequest req = new HttpRequest();
                    req.setEndpoint(endpointUrl);
                    req.setMethod('POST');
                    req.setHeader('Content-Type', 'application/json');
                    req.setHeader('x-opencx-token', OpenCX_Settings__c.getOrgDefaults().Webhook_Token__c);
                    req.setBody('');

                    HttpResponse res = http.send(req);
                    System.debug('Sent webhook for Case ' + caseId + ', response: ' + res.getBody());
                } catch (Exception e) {
                    System.debug('Failed to send webhook for Case ' + caseId + ': ' + e.getMessage());
                }
            }
        }

        public static void sendCaseEmailFromHumanAgentEvents(List<Id> caseIds) {
            sendCaseEmailFromHumanAgentEventsWithIds(caseIds, new List<Id>());
        }

        // emailIds[i] (optional, parallel to caseIds) is the sent EmailMessage Id —
        // passed as commentId so OpenCX mirrors the actual email body. Used by the
        // draft-sent branch of OpenCxHumanAgentReplyTrigger.
        @future(callout=true)
        public static void sendCaseEmailFromHumanAgentEventsWithIds(List<Id> caseIds, List<Id> emailIds) {
            Http http = new Http();
            for (Integer i = 0; i < caseIds.size(); i++) {
                Id caseId = caseIds[i];
                try {
                    String endpointUrl = 'callout:OpenCX_Integration?caseId=' + EncodingUtil.urlEncode(caseId, 'UTF-8') + '&type=new_human_agent_reply';
                    if (i < emailIds.size() && emailIds[i] != null) {
                        endpointUrl += '&commentId=' + EncodingUtil.urlEncode(emailIds[i], 'UTF-8');
                    }

                    HttpRequest req = new HttpRequest();
                    req.setEndpoint(endpointUrl);
                    req.setMethod('POST');
                    req.setHeader('Content-Type', 'application/json');
                    req.setHeader('x-opencx-token', OpenCX_Settings__c.getOrgDefaults().Webhook_Token__c);
                    req.setBody('');

                    HttpResponse res = http.send(req);
                    System.debug('Sent webhook for Case Human Agent Email ' + caseId + ', response: ' + res.getBody());
                } catch (Exception e) {
                    System.debug('Failed to send webhook for Case Human Agent Email ' + caseId + ': ' + e.getMessage());
                }
            }
        }

        @future(callout=true)
        public static void sendCaseOwnerChangedEvents(List<Id> caseIds) {
            Http http = new Http();
            for (Id caseId : caseIds) {
                try {
                    String endpointUrl = 'callout:OpenCX_Integration?caseId=' + EncodingUtil.urlEncode(caseId, 'UTF-8') + '&type=owner_changed';

                    HttpRequest req = new HttpRequest();
                    req.setEndpoint(endpointUrl);
                    req.setMethod('POST');
                    req.setHeader('Content-Type', 'application/json');
                    req.setHeader('x-opencx-token', OpenCX_Settings__c.getOrgDefaults().Webhook_Token__c);
                    req.setBody('');

                    HttpResponse res = http.send(req);
                    System.debug('Sent webhook for Case Owner change ' + caseId + ', response: ' + res.getBody());
                } catch (Exception e) {
                    System.debug('Failed to send webhook for Case Owner change ' + caseId + ': ' + e.getMessage());
                }
            }
        }

        @future(callout=true)
        public static void sendCaseClosedEvents(List<Id> caseIds) {
            Http http = new Http();
            for (Id caseId : caseIds) {
                try {
                    String endpointUrl = 'callout:OpenCX_Integration?caseId=' + EncodingUtil.urlEncode(caseId, 'UTF-8') + '&type=case_closed';

                    HttpRequest req = new HttpRequest();
                    req.setEndpoint(endpointUrl);
                    req.setMethod('POST');
                    req.setHeader('Content-Type', 'application/json');
                    req.setHeader('x-opencx-token', OpenCX_Settings__c.getOrgDefaults().Webhook_Token__c);
                    req.setBody('');

                    HttpResponse res = http.send(req);
                    System.debug('Sent webhook for Case close ' + caseId + ', response: ' + res.getBody());
                } catch (Exception e) {
                    System.debug('Failed to send webhook for Case close ' + caseId + ': ' + e.getMessage());
                }
            }
        }

    }
    ```

    **Save the class:** click **Save** at the bottom of the Salesforce UI, or in the Developer Console use **File → Save** (⌘S on Mac, Ctrl+S on Windows/Linux).

    **Create the Apex Triggers:**

    OpenCX needs six triggers in total: three core triggers for new cases, customer email replies, and human-agent emails sent from the Case; two optional triggers for owner reassignment and case close; and one required trigger that actually transmits outbound AI replies to the contact.

    All six triggers are authored the same way. To open the trigger editor:

    1. In Salesforce, open the **Developer Console** (top-right gear icon → **Developer Console**).
    2. From the Developer Console's top-left menu, go to **File → New → Apex Trigger**.
    3. Fill in the **Name** (examples below — rename to whatever you prefer) and the **sObject**, then click **Submit**.
    4. Paste the snippet into the editor.
    5. Save with **File → Save** (or ⌘S on Mac, Ctrl+S on Windows/Linux).

    **1. Case trigger (new cases)** — Name: `CaseTrigger` (example, renameable — the Name must match the name declared in the trigger code). sObject: `Case`.

    ```apex theme={"dark"}
    trigger CaseTrigger on Case (after insert) {
        List<Id> caseIds = new List<Id>();
        for (Case c : Trigger.new) {
            caseIds.add(c.Id);
        }
        OpenSalesforceCaseManagement.sendCaseEvents(caseIds);
    }
    ```

    **2. EmailMessage trigger (customer replies)** — Name: `OpenCxCustomerReplyTrigger` (example, renameable). sObject: `EmailMessage`.

    ```apex theme={"dark"}
    trigger OpenCxCustomerReplyTrigger on EmailMessage (after insert) {
        List<Id> caseIds = new List<Id>();
        for (EmailMessage em : Trigger.new) {
            // Customer reply = Incoming, attached to a Case (Ids starting with 500).
            if (em.Incoming == true
                && em.ParentId != null
                && String.valueOf(em.ParentId).startsWith('500')) {
                caseIds.add(em.ParentId);
            }
        }
        if (!caseIds.isEmpty()) {
            OpenSalesforceCaseManagement.sendCaseReplyEvents(caseIds);
        }
    }
    ```

    Without this trigger, OpenCX won't see customer replies and the AI can't follow up. Outbound emails (ones OpenCX sends via API) have `Incoming=false` and are skipped, so no self-loop.

    **3. EmailMessage trigger (human agent sends email from Salesforce)** — optional but recommended. Name: `OpenCxHumanAgentReplyTrigger` (example, renameable). sObject: `EmailMessage`.

    <Warning>
      Set `INTEGRATION_USERNAME` (first line of the trigger) to the exact **Username** of the user OpenCX authenticated as during OAuth. In `after insert`, `UserInfo.getUserId()` always equals `CreatedById`, so comparing the two would never discriminate between OpenCX and a human agent. We must compare against the integration user's actual Id, resolved via their Username.
    </Warning>

    ```apex theme={"dark"}
    trigger OpenCxHumanAgentReplyTrigger on EmailMessage (after insert, after update) {
        // REQUIRED: the exact Username of the user OpenCX OAuth'd as.
        final String INTEGRATION_USERNAME = 'opencx-integration@yourcompany.com';

        List<User> matches = [SELECT Id FROM User WHERE Username = :INTEGRATION_USERNAME LIMIT 1];
        if (matches.isEmpty()) return;
        Id integrationUserId = matches[0].Id;

        List<Id> caseIds = new List<Id>();
        List<Id> emailIds = new List<Id>();
        for (EmailMessage em : Trigger.new) {
            if (em.ParentId == null || em.ParentId.getSObjectType() != Case.SObjectType) continue;
            if (Trigger.isInsert) {
                // Outbound email on a Case from anyone OTHER than OpenCX — i.e.
                // a human agent using Salesforce's Send Email UI. Skip drafts
                // (Status '5'): saving a draft is not a reply — the update
                // branch below fires when it is actually sent.
                if (em.Incoming == false && em.Status != '5' && em.CreatedById != integrationUserId) {
                    caseIds.add(em.ParentId);
                    emailIds.add(em.Id);
                }
            } else {
                // Draft → sent transition. Assist-mode drafts are CREATED by the
                // integration user, so a human sending one is an UPDATE of that
                // row (Status leaves '5') — the insert branch never sees it.
                EmailMessage oldEm = Trigger.oldMap.get(em.Id);
                if (oldEm.Status == '5' && em.Status != '5' && em.Incoming == false) {
                    caseIds.add(em.ParentId);
                    emailIds.add(em.Id);
                }
            }
        }
        if (!caseIds.isEmpty()) {
            OpenSalesforceCaseManagement.sendCaseEmailFromHumanAgentEventsWithIds(caseIds, emailIds);
        }
    }
    ```

    Use this if your agents sometimes reply directly in Salesforce via the Case's **Send Email** action — OpenCX will mark the session as handed off and log the agent's reply in the chat history. The `after update` branch covers agents sending an OpenCX-suggested **draft** (assist mode's "post suggested replies as email drafts" toggle): sending a draft updates the existing EmailMessage rather than inserting a new one.

    **4. Case trigger (owner change)** — optional but recommended. When a rep manually reassigns the Case (taking ownership away from the OpenCX integration user), the AI should stop replying. Name: `OpenCxOwnerChangeTrigger` (example, renameable). sObject: `Case`.

    <Note>
      Set `INTEGRATION_USERNAME` (second line of the trigger) to the exact **Username** OpenCX authenticated as during OAuth — the same value used by triggers 3, 6 and 7. OpenCX's own owner writes (takeover, assignee sync) run as that user and are skipped here. Leaving it wrong is safe but noisier: OpenCX's write echoes back and OpenCX de-duplicates it.
    </Note>

    ```apex theme={"dark"}
    trigger OpenCxOwnerChangeTrigger on Case (after update) {
        // REQUIRED: the exact Username OpenCX OAuth'd as — same value as triggers 3, 6, 7.
        String INTEGRATION_USERNAME = 'opencx-integration@yourcompany.com';
        if (UserInfo.getUserName() == INTEGRATION_USERNAME) return;

        List<Id> caseIds = new List<Id>();
        for (Case c : Trigger.new) {
            Case old = Trigger.oldMap.get(c.Id);
            if (old == null) continue;
            if (c.OwnerId != old.OwnerId) {
                caseIds.add(c.Id);
            }
        }
        if (!caseIds.isEmpty()) {
            OpenSalesforceCaseManagement.sendCaseOwnerChangedEvents(caseIds);
        }
    }
    ```

    When this fires, OpenCX marks the session as handed-off and pauses autonomous AI replies. OpenCX's own ownership changes (during takeover / handoff) skip via the `INTEGRATION_USERNAME` check; a rep accepting or taking the Case fires normally.

    **5. Case trigger (case closed)** — optional but recommended. When a rep closes the Case in Salesforce, OpenCX resolves the matching session. Name: `OpenCxCaseClosedTrigger` (example, renameable). sObject: `Case`.

    ```apex theme={"dark"}
    trigger OpenCxCaseClosedTrigger on Case (after update) {
        List<Id> caseIds = new List<Id>();
        for (Case c : Trigger.new) {
            Case old = Trigger.oldMap.get(c.Id);
            if (old == null) continue;
            if (c.IsClosed && !old.IsClosed) {
                caseIds.add(c.Id);
            }
        }
        if (!caseIds.isEmpty()) {
            OpenSalesforceCaseManagement.sendCaseClosedEvents(caseIds);
        }
    }
    ```

    Without this trigger, sessions stay open in OpenCX after their Case is closed in Salesforce.

    **6. EmailMessage trigger (outbound email delivery)** — **required**. This is the trigger that actually transmits OpenCX's AI replies to the contact, injects Salesforce's threading token so customer replies attach to the same Case, and redirects replies back to your Email-to-Case routing address. Name: `OpenCxOutboundEmailSender` (example, renameable). sObject: `EmailMessage`.

    <Warning>
      Before saving, set both constants at the top of the trigger:

      * `INTEGRATION_USERNAME` — the exact **Username** of the user OpenCX OAuth'd as. Find it in **Setup → Users → Users** (the `Username` column, looks like an email). This must match exactly or the trigger silently skips every outbound EmailMessage.
      * `REPLY_TO_ADDRESS` — your customer-facing forwarding address, e.g. `support@yourcompany.com`. Must be registered as a **Verified** Routing Address in **Setup → Email-to-Case → Routing Addresses**.
    </Warning>

    ```apex theme={"dark"}
    trigger OpenCxOutboundEmailSender on EmailMessage (after insert) {
        // ==== CONFIGURE THESE TWO VALUES ====
        final String INTEGRATION_USERNAME = 'opencx-integration@yourcompany.com';
        final String REPLY_TO_ADDRESS     = 'support@yourcompany.com';
        // ====================================

        List<User> userMatches = [SELECT Id FROM User WHERE Username = :INTEGRATION_USERNAME LIMIT 1];
        if (userMatches.isEmpty()) {
            System.debug(LoggingLevel.ERROR,
                'OpenCxOutboundEmailSender: integration user not found: ' + INTEGRATION_USERNAME);
            return;
        }
        Id integrationUserId = userMatches[0].Id;

        // Step 1 — filter to OpenCX's own outbound EmailMessages on a Case with a recipient.
        List<EmailMessage> toSend = new List<EmailMessage>();
        Set<Id> emIds = new Set<Id>();
        Set<Id> caseIds = new Set<Id>();
        for (EmailMessage em : Trigger.new) {
            if (em.Incoming != false) continue;
            if (em.ParentId == null) continue;
            if (em.ParentId.getSObjectType() != Case.SObjectType) continue;
            if (em.CreatedById != integrationUserId) continue;
            if (em.Status == '5') continue; // draft — never auto-send (assist-mode draft replies)
            if (String.isBlank(em.ToAddress)) continue;
            toSend.add(em);
            emIds.add(em.Id);
            caseIds.add(em.ParentId);
        }
        if (toSend.isEmpty()) return;

        // Step 2a — find the most recent prior EmailMessage on each Case so we
        // can set In-Reply-To / References headers for client-side threading.
        Map<Id, String> prevMsgIdByCase = new Map<Id, String>();
        for (EmailMessage prev : [
            SELECT ParentId, MessageIdentifier, MessageDate
            FROM EmailMessage
            WHERE ParentId IN :caseIds
              AND Id NOT IN :emIds
              AND MessageIdentifier != null
            ORDER BY MessageDate DESC
        ]) {
            if (!prevMsgIdByCase.containsKey(prev.ParentId)) {
                prevMsgIdByCase.put(prev.ParentId, prev.MessageIdentifier);
            }
        }

        // Step 2b — bulk-load any attachments on these EmailMessages so we can
        // forward them. Supports modern files (ContentDocumentLink) only.
        Map<Id, List<Messaging.EmailFileAttachment>> attachmentsByEm =
            new Map<Id, List<Messaging.EmailFileAttachment>>();
        List<ContentDocumentLink> links = [
            SELECT LinkedEntityId, ContentDocumentId
            FROM ContentDocumentLink
            WHERE LinkedEntityId IN :emIds
        ];
        if (!links.isEmpty()) {
            Set<Id> docIds = new Set<Id>();
            for (ContentDocumentLink l : links) docIds.add(l.ContentDocumentId);
            Map<Id, ContentVersion> versionByDoc = new Map<Id, ContentVersion>();
            for (ContentVersion cv : [
                SELECT Id, ContentDocumentId, Title, FileExtension, VersionData
                FROM ContentVersion
                WHERE ContentDocumentId IN :docIds AND IsLatest = true
            ]) {
                versionByDoc.put(cv.ContentDocumentId, cv);
            }
            for (ContentDocumentLink l : links) {
                ContentVersion cv = versionByDoc.get(l.ContentDocumentId);
                if (cv == null) continue;
                String name = (cv.Title != null ? cv.Title : 'attachment');
                if (!String.isBlank(cv.FileExtension)) name += '.' + cv.FileExtension;
                Messaging.EmailFileAttachment att = new Messaging.EmailFileAttachment();
                att.setFileName(name);
                att.setBody(cv.VersionData);
                List<Messaging.EmailFileAttachment> attList = attachmentsByEm.get(l.LinkedEntityId);
                if (attList == null) {
                    attList = new List<Messaging.EmailFileAttachment>();
                    attachmentsByEm.put(l.LinkedEntityId, attList);
                }
                attList.add(att);
            }
        }

        // Step 3 — build each SingleEmailMessage.
        List<Messaging.SingleEmailMessage> emails = new List<Messaging.SingleEmailMessage>();
        for (EmailMessage em : toSend) {
            // Split ToAddress (required) / CcAddress / BccAddress on ',' or ';'.
            List<String> toAddresses = new List<String>();
            for (String raw : em.ToAddress.split('[,;]')) {
                String a = raw == null ? null : raw.trim();
                if (!String.isBlank(a)) toAddresses.add(a);
            }
            if (toAddresses.isEmpty()) continue;

            List<String> ccAddresses = new List<String>();
            if (!String.isBlank(em.CcAddress)) {
                for (String raw : em.CcAddress.split('[,;]')) {
                    String a = raw == null ? null : raw.trim();
                    if (!String.isBlank(a)) ccAddresses.add(a);
                }
            }

            List<String> bccAddresses = new List<String>();
            if (!String.isBlank(em.BccAddress)) {
                for (String raw : em.BccAddress.split('[,;]')) {
                    String a = raw == null ? null : raw.trim();
                    if (!String.isBlank(a)) bccAddresses.add(a);
                }
            }

            // Lightning-Threading token — rendered as 1px white-on-white so it
            // is invisible in the inbox, BUT without display:none / visibility
            // hidden, which Gmail and Outlook strip from quoted replies. This
            // styling survives the quote and lands back in Salesforce, which
            // matches it against the Case via Email-to-Case threading.
            String threadToken = EmailMessages.getFormattedThreadingToken(em.ParentId);
            String htmlBody = (em.HtmlBody != null ? em.HtmlBody : '')
                + '<span style="color:#ffffff;font-size:1px;line-height:0">'
                + threadToken + '</span>';

            Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
            mail.setToAddresses(toAddresses);
            if (!ccAddresses.isEmpty())  mail.setCcAddresses(ccAddresses);
            if (!bccAddresses.isEmpty()) mail.setBccAddresses(bccAddresses);
            mail.setReplyTo(REPLY_TO_ADDRESS);
            mail.setSubject(em.Subject);
            mail.setHtmlBody(htmlBody);
            if (!String.isBlank(em.TextBody)) mail.setPlainTextBody(em.TextBody);
            mail.setWhatId(em.ParentId);
            mail.setSaveAsActivity(false);

            // Header-based threading — when the customer hits Reply, their
            // client includes In-Reply-To / References pointing at this
            // message. Salesforce's "Use email headers for threading" setting
            // (enabled in E2C Settings) matches those to the Case. Normalise
            // the stored Message-ID to <...> wrapping; an unwrapped value
            // would be silently rejected.
            String prevMsgId = prevMsgIdByCase.get(em.ParentId);
            if (String.isNotBlank(prevMsgId)) {
                String stripped = prevMsgId.trim().removeStart('<').removeEnd('>').trim();
                if (String.isNotBlank(stripped)) {
                    String bracketed = '<' + stripped + '>';
                    mail.setInReplyTo(bracketed);
                    mail.setReferences(bracketed);
                }
            }

            List<Messaging.EmailFileAttachment> atts = attachmentsByEm.get(em.Id);
            if (atts != null && !atts.isEmpty()) mail.setFileAttachments(atts);

            emails.add(mail);
        }

        if (!emails.isEmpty()) {
            List<Messaging.SendEmailResult> results = Messaging.sendEmail(emails, false);
            for (Messaging.SendEmailResult r : results) {
                if (r.isSuccess()) continue;
                for (Messaging.SendEmailError err : r.getErrors()) {
                    System.debug(LoggingLevel.ERROR,
                        'OpenCxOutboundEmailSender sendEmail failed: '
                        + err.getStatusCode() + ' — ' + err.getMessage());
                }
            }
        }
    }
    ```

    <Warning>
      Without this trigger, OpenCX's AI replies are logged to the Case Feed as `EmailMessage` records but **never actually delivered** to the contact — a plain `EmailMessage` insert is a data row, not an SMTP send. The reply will show as "sent" in the OpenCX dashboard and appear in the Salesforce Case Feed, but the contact's inbox will stay empty.
    </Warning>

    **What each block does:**

    * `em.ParentId.getSObjectType() == Case.SObjectType` — matches `EmailMessage` rows whose parent is a Case, without hard-coding the `500` Id prefix.
    * `em.CreatedById != integrationUserId` / `==` — the reason we look up the integration user by Username instead of `UserInfo.getUserId()`. In `after insert`, `UserInfo.getUserId()` always equals `CreatedById`, so a naive equality check would always be true and the trigger would fire for every outbound EmailMessage — including human-agent sends, causing double-delivery.
    * `ContentDocumentLink` / `ContentVersion` query — forwards any files OpenCX attached to the outbound `EmailMessage` (modern Salesforce Files). If OpenCX never attaches files in your configuration, this runs once per trigger with zero rows and is effectively free.
    * `CcAddress` / `BccAddress` splitting — preserves cc / bcc recipients if OpenCX ever populates them. Split tolerates commas, semicolons, trailing whitespace, and empty segments.
    * `EmailMessages.getFormattedThreadingToken(em.ParentId)` — generates Salesforce's Lightning-Threading token. Embedded in a **visually hidden HTML `<span>`** so it never renders visibly, but still survives quoted replies from HTML-capable email clients. Text-only clients fall back to "Use email headers for threading".
    * `mail.setReplyTo(REPLY_TO_ADDRESS)` — routes customer replies back to your forwarding/routing address so Salesforce can ingest them. Without this, replies land in the integration user's personal Salesforce inbox and never reach Email-to-Case.
    * `mail.setSaveAsActivity(false)` — OpenCX already wrote the `EmailMessage` record that triggered this send; we don't want Salesforce to create a second one.
    * The `SendEmailResult` loop — logs `OpenCxOutboundEmailSender sendEmail failed:` to the Debug Log when a send is rejected (daily limit exceeded, deliverability off, malformed recipient, etc.). Surface these in **Setup → Debug Logs** when diagnosing delivery issues.

    **Org-Wide Email Address (recommended for production):** by default the email is sent from the integration user's personal Salesforce address. To send from a branded address like `support@yourcompany.com`, create an Organization-Wide Email Address in **Setup → Email → Organization-Wide Addresses**, verify it, then add this one line before `emails.add(mail)`:

    ```apex theme={"dark"}
    mail.setOrgWideEmailAddressId('0D2XXXXXXXXXXXX');  // Id of the verified OWA
    ```

    <Warning>
      **Upgrading from the retired `OpenCxCaseUpdatedTrigger`?** Earlier versions
      of this guide listed an optional 7th trigger — an `after update` on Case
      posting `?type=case_updated` on every save — to keep session attributes
      fresh. OpenCX now does that itself: it checks Salesforce every two minutes
      for Cases that changed and refreshes them in bulk, with nothing installed
      in your org.

      If you deployed that trigger, **delete it**, along with the
      `sendCaseUpdatedEvents` method on `OpenSalesforceCaseManagement` and its
      test method. Left in place it keeps firing one `@future` execution and one
      callout per Case save — against your org's async-Apex allocation and your
      webhook rate limit — for an event OpenCX now rejects. Nothing else changes;
      triggers 1–6 stay exactly as they are.
    </Warning>
  </Step>

  <Step title="Confirm email deliverability settings">
    Before the outbound email trigger can actually transmit, your Salesforce org needs two one-time settings:

    1. **Setup → Email → Deliverability → Access level** must be `All email`. The trial / sandbox default of `System email only` blocks `Messaging.sendEmail()` with `NO_MASS_MAIL_PERMISSION`.
    2. The integration user's profile must have **Send Email** enabled (standard on **System Administrator**).

    Skip this step at your own risk — the trigger will silently throw on every AI reply and nothing will leave Salesforce.
  </Step>

  <Step title="Create the Apex Test Class">
    Salesforce requires **≥ 75% test coverage** on every Apex class and trigger before you can deploy to production. This test class covers all five methods of `OpenSalesforceCaseManagement` plus all six triggers (new case, customer reply, human-agent reply, owner change, case close, outbound email delivery).

    Go to **Setup → Apex Classes** and click **New**, or open the **Developer Console** and go to **File → New → Apex Class**. The class name `OpenSalesforceCaseManagementTest` below is an example — you can rename it freely. Paste:

    ```apex theme={"dark"}
    @isTest
    private class OpenSalesforceCaseManagementTest {
        /**
         * Mocks every OpenCX webhook callout with a generic 200 response so
         * tests don't hit the real backend and don't depend on Named Credentials.
         */
        private class OpenCxCalloutMock implements HttpCalloutMock {
            public HttpResponse respond(HttpRequest req) {
                HttpResponse res = new HttpResponse();
                res.setStatusCode(200);
                res.setHeader('Content-Type', 'application/json');
                res.setBody('{"success":true}');
                return res;
            }
        }

        @isTest
        static void sendCaseEvents_sendsWebhook() {
            Case c = new Case(Subject = 'Test', Status = 'New', Origin = 'Email');
            insert c;

            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            OpenSalesforceCaseManagement.sendCaseEvents(new List<Id>{ c.Id });
            Test.stopTest();
        }

        @isTest
        static void sendCaseReplyEvents_sendsWebhook() {
            Case c = new Case(Subject = 'Test', Status = 'New', Origin = 'Email');
            insert c;

            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            OpenSalesforceCaseManagement.sendCaseReplyEvents(new List<Id>{ c.Id });
            Test.stopTest();
        }

        @isTest
        static void sendCaseEmailFromHumanAgentEvents_sendsWebhook() {
            Case c = new Case(Subject = 'Test', Status = 'New', Origin = 'Email');
            insert c;

            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            OpenSalesforceCaseManagement.sendCaseEmailFromHumanAgentEvents(new List<Id>{ c.Id });
            Test.stopTest();
        }

        @isTest
        static void sendCaseOwnerChangedEvents_sendsWebhook() {
            Case c = new Case(Subject = 'Test', Status = 'New', Origin = 'Email');
            insert c;

            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            OpenSalesforceCaseManagement.sendCaseOwnerChangedEvents(new List<Id>{ c.Id });
            Test.stopTest();
        }

        @isTest
        static void sendCaseClosedEvents_sendsWebhook() {
            Case c = new Case(Subject = 'Test', Status = 'New', Origin = 'Email');
            insert c;

            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            OpenSalesforceCaseManagement.sendCaseClosedEvents(new List<Id>{ c.Id });
            Test.stopTest();
        }


        @isTest
        static void caseTrigger_firesOnInsert() {
            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            insert new Case(Subject = 'From trigger', Status = 'New', Origin = 'Email');
            Test.stopTest();
        }

        @isTest
        static void customerReplyTrigger_firesOnIncomingEmail() {
            Case c = new Case(Subject = 'Test', Status = 'New', Origin = 'Email');
            insert c;

            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            insert new EmailMessage(
                ParentId = c.Id,
                Incoming = true,
                Subject = 'Customer reply',
                TextBody = 'Hello',
                Status = '0'  // New
            );
            Test.stopTest();
        }

        @isTest
        static void humanAgentReplyTrigger_firesOnOutgoingEmail() {
            // Must match INTEGRATION_USERNAME in OpenCxHumanAgentReplyTrigger.
            // If that user doesn't exist in the test org, the trigger early-returns
            // and this test passes without exercising the full body.
            final String INTEGRATION_USERNAME = 'opencx-integration@yourcompany.com';
            List<User> integrationUsers = [
                SELECT Id FROM User WHERE Username = :INTEGRATION_USERNAME LIMIT 1
            ];
            if (integrationUsers.isEmpty()) return;

            // Run as a NON-integration user so the `CreatedById != integrationUserId`
            // guard treats the insert as a human-agent action.
            User runner = [
                SELECT Id FROM User
                WHERE IsActive = true AND Id != :integrationUsers[0].Id
                LIMIT 1
            ];
            Case c = new Case(Subject = 'Test', Status = 'New', Origin = 'Email');
            insert c;

            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            System.runAs(runner) {
                insert new EmailMessage(
                    ParentId = c.Id,
                    Incoming = false,
                    Subject = 'Agent reply',
                    TextBody = 'Hi there',
                    Status = '3'  // Sent
                );
            }
            Test.stopTest();
        }

        @isTest
        static void ownerChangeTrigger_firesOnOwnerUpdate() {
            Case c = new Case(Subject = 'Test', Status = 'New', Origin = 'Email');
            insert c;

            List<User> candidates = [
                SELECT Id FROM User
                WHERE IsActive = true AND Id != :UserInfo.getUserId()
                LIMIT 1
            ];
            if (candidates.isEmpty()) return;

            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            c.OwnerId = candidates[0].Id;
            update c;
            Test.stopTest();
        }

        @isTest
        static void caseClosedTrigger_firesOnClose() {
            Case c = new Case(Subject = 'Test', Status = 'New', Origin = 'Email');
            insert c;

            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            c.Status = 'Closed';
            update c;
            Test.stopTest();
        }


        @isTest
        static void outboundEmailSender_queuesEmail() {
            // Must match INTEGRATION_USERNAME in OpenCxOutboundEmailSender. If that
            // user doesn't exist in the test org, the trigger early-returns and
            // this test passes without exercising the full body.
            final String INTEGRATION_USERNAME = 'opencx-integration@yourcompany.com';
            List<User> integrationUsers = [
                SELECT Id FROM User WHERE Username = :INTEGRATION_USERNAME LIMIT 1
            ];
            if (integrationUsers.isEmpty()) return;
            User integrationUser = integrationUsers[0];

            Case c = new Case(Subject = 'Test', Status = 'New', Origin = 'Email');
            insert c;

            Test.setMock(HttpCalloutMock.class, new OpenCxCalloutMock());
            Test.startTest();
            // Run the EmailMessage insert AS the integration user so CreatedById
            // matches integrationUserId inside the trigger.
            System.runAs(integrationUser) {
                insert new EmailMessage(
                    ParentId  = c.Id,
                    Incoming  = false,
                    Subject   = 'AI reply',
                    TextBody  = 'Hi there',
                    HtmlBody  = '<p>Hi there</p>',
                    ToAddress = 'customer@example.com',
                    Status    = '3'  // Sent
                );
            }
            // Assert BEFORE stopTest — Test.stopTest() restores governor counters
            // to their pre-startTest state, so Limits.getEmailInvocations() would
            // read 0 if checked after.
            System.assertEquals(
                1,
                Limits.getEmailInvocations(),
                'Expected OpenCxOutboundEmailSender to queue one email'
            );
            Test.stopTest();
        }
    }
    ```

    <Note>
      Salesforce's `HttpCalloutMock` intercepts the `@future` webhook callouts so these tests don't need a live Named Credential or network access. `Test.startTest()` / `Test.stopTest()` flushes the queued `@future` methods synchronously.
    </Note>

    <Warning>
      **Keep `INTEGRATION_USERNAME` in sync across all four files** — `OpenCxOutboundEmailSender`, `OpenCxHumanAgentReplyTrigger`, `OpenCxOwnerChangeTrigger`, and `OpenSalesforceCaseManagementTest`. If they drift, the triggers stop firing for real traffic **and** the test silently skips the main assertion path (the `if (integrationUsers.isEmpty()) return` guard), which can mask the breakage behind a green test run.
    </Warning>

    Save with **Save** at the bottom, or in the Developer Console use **File → Save** (⌘S on Mac, Ctrl+S on Windows/Linux).

    After saving, run the tests: **Setup → Apex Test Execution → Select Tests → `OpenSalesforceCaseManagementTest`**. All tests should pass, and coverage on `OpenSalesforceCaseManagement` + the six triggers will report ≥ 75%.
  </Step>

  <Step title="Production readiness checklist">
    The six triggers + test class are enough to make the integration *function*. For a production deployment that won't surprise you in week two, also complete the following:

    **1. Dedicated integration user.** Don't OAuth as a named human. In **Setup → Users → Users**, clone the System Administrator profile and create a user named `OpenCX Integration`. OAuth as that user. When real humans leave the company and their logins are disabled, the integration keeps working.

    **2. Organization-Wide Email Address.** In **Setup → Email → Organization-Wide Addresses**, add `support@yourcompany.com` and verify it (Salesforce emails a verification link — click it from the target inbox). Then add one line to `OpenCxOutboundEmailSender` before `emails.add(mail)`:

    ```apex theme={"dark"}
    mail.setOrgWideEmailAddressId('0D2XXXXXXXXXXXX');  // Id of the verified OWA
    ```

    Without this, outbound AI replies go out from the integration user's personal Salesforce address. Customers see a weird-looking `From:` and spam filters flag it because the sender domain doesn't match your brand.

    **3. SPF on your sending domain.** Publish an SPF record on `yourcompany.com` that includes Salesforce:

    ```
    v=spf1 include:_spf.salesforce.com -all
    ```

    Without it, your outbound AI replies land in spam, and inbound forwarded mail to Salesforce can be rejected at the DMARC layer.

    **4. DKIM signing.** In **Setup → Email → DKIM Keys → Create New Key**, generate a key for your sending domain and publish the two CNAME records Salesforce gives you. Unsigned outbound mail gets aggressively spam-filtered by Gmail and Workspace.

    **5. Case Assignment Rules.** In **Setup → Case → Case Assignment Rules**, define rules that route incoming Cases to the right Queue based on sender domain, routing address, or keywords. This is what makes the "Case Owner = Queue" choice on the Routing Address actually pay off — Queues without rules are just static inboxes.

    **6. Email Deliverability.** **Setup → Email → Deliverability → Access level** must be `All email` (not `System email only`). Sandbox / trial defaults block all outbound — your Dev Edition tests will fail here first.

    **7. Daily email limits.** `Messaging.sendEmail` to external addresses is capped per org per GMT-day:

    | Edition                              | External email cap / day |
    | ------------------------------------ | ------------------------ |
    | Developer Edition / trial            | 15                       |
    | Sandbox                              | typically low, varies    |
    | Enterprise / Unlimited / Performance | 5,000                    |

    If production volume exceeds 5,000/day outbound, replace `Messaging.sendEmail` with an HTTP callout to an external ESP (SendGrid, Postmark, Mailgun) via a Named Credential — the trigger structure stays the same, only the sending primitive changes. Most support workloads stay well under the cap and don't need this.

    **8. Monitoring.** Turn on **Setup → Debug Logs** for the integration user before any production cutover. The `OpenCxOutboundEmailSender sendEmail failed:` lines captured here are your first signal when deliverability, limits, or deliverability settings regress.

    **9. Strengthen the test class with a behavioural assertion.** The supplied test class covers the trigger code paths (enough for Salesforce's ≥ 75% coverage requirement) but the `outboundEmailSender_queuesEmail` and `humanAgentReplyTrigger_firesOnOutgoingEmail` tests gate their assertions on the integration user existing in the test org. In a fresh sandbox that's fine; in a regression-sensitive production deploy, add a `@testSetup` that creates a dummy user matching `INTEGRATION_USERNAME` so the full send path is always exercised. Without this, refactors to the trigger body could regress silently while the test stays green.

    **10. Known gaps to track.** These are intentionally out of scope for the baseline trigger but worth queueing as follow-up work:

    * **Bounce handling.** Salesforce sets `EmailMessage.IsBounced = true` when the outbound message bounces. OpenCX isn't notified today — an additional trigger on `EmailMessage (after update)` should fire a `case_email_bounced` webhook so the dashboard flags the failed delivery.
    * **Idempotency.** If Salesforce re-fires the trigger for the same `EmailMessage` (bulk load, recovery), the customer receives duplicate copies. Add a custom boolean `OpenCx_Sent__c` on `EmailMessage` and set it in the trigger after `sendEmail` succeeds; short-circuit on the flag at the top of the loop.
    * **Rate limiting.** `Messaging.sendEmail` caps at 5,000/day in Enterprise. At higher volumes, swap the send primitive for an HTTP callout to an external ESP (SendGrid / Postmark / Mailgun) via a Named Credential — the trigger's filter/build logic stays the same.
  </Step>

  <Step title="Verify end to end">
    Send a test email to an address connected to OpenCX. Escalate the conversation (or ask to speak with a human). Within a few seconds:

    1. A Case appears in Salesforce with `Case_From__c` set to `opencx`.
    2. Reply on the Case from Salesforce.
    3. The reply appears as an agent message on the matching session in your [OpenCX Inbox](https://platform.open.cx/inbox).

    If any step fails, jump to [Troubleshooting](/integrations/salesforce/troubleshooting).
  </Step>
</Steps>

## How handoff lands in Salesforce

```mermaid theme={"dark"}
flowchart LR
  A[Contact emails] --> B{AI can resolve?}
  B -- Yes --> C[AI replies by email]
  B -- No --> D[Handoff]
  D --> E[Salesforce Case created]
  E --> F[Rep replies on the Case]
  F --> G[Reply syncs to contact inbox]
```

When <Tooltip tip="The decision point where the AI escalates a conversation to a human agent." cta="Handoff settings" href="/handoff/introduction">handoff</Tooltip> triggers, OpenCX:

1. Creates a Salesforce Case with the conversation topic as **Subject** and the AI summary as **Description**.
2. Sets `Case_From__c` to `opencx` so your views and reports can filter to AI-escalated cases.
3. Attaches the full transcript as Case comments.
4. Stores the Salesforce Case ID on the OpenCX session for tracing.
5. When your rep replies on the Case, the webhook fires and OpenCX delivers the reply to the contact's inbox.
6. When the Case is closed, the OpenCX session resolves.

<Note>
  If the same contact re-engages before the Case is closed, OpenCX appends to the existing Case instead of opening a new one.
</Note>

## Keeping the Case and the session in sync

Beyond replies, OpenCX keeps the Case and its OpenCX session consistent in both directions. Everything below is opt-in and applies to the Email Cases (v2) integration only.

**Case fields → session attributes.** Every Case field OpenCX can read is mirrored onto the session as an `sf_case_<Field>` attribute (Contact fields as `sf_contact_<Field>`, configured related objects as `sf_<object>_<Field>`, e.g. `sf_reservation_Status__c`). The snapshot refreshes on every event OpenCX handles, **plus every two minutes** for any Case that changed in Salesforce since the last check — so a field a rep edits directly (Status, a reason picklist, anything) reaches OpenCX within a couple of minutes without a trigger, and the AI never reasons over a stale Case. That sweep is automatic — there is nothing to install for it. It reads the changed Cases themselves in bulk, up to 200 per API call rather than one call per edit, then refreshes each linked Case's Contact and configured related records exactly as the other events already do. A field cleared in Salesforce is removed from the session too. Each refresh that changes something fires the **Session Updated** workflow trigger with the keys that moved (`changedFields`) and, per change, the previous and new value (`changes`), so an agentic workflow can react to a Salesforce change — for example "when `sf_case_Status` changes to `Escalated`, notify the team". Values the AI may see are still governed by the AI Context filters; changes to those allowed fields also appear on the session timeline as a **Salesforce fields updated** entry that shows each field's previous and new value, who made the change in Salesforce (the Case's *Last Modified By*), and when.

**Session attributes → Case fields.** When a session attribute that names an updateable Case field changes on the OpenCX side — a workflow's **Update Session Attributes** step writing `sf_case_Priority`, or an agent editing `Priority` in the inbox — the Case is updated. Picklist values must match one of the field's active options (by value or label); anything the field cannot accept is dropped and logged rather than failing the update. `OwnerId` is never written this way — ownership follows assignee sync below.

**Agent replies carry the agent's identity.** A reply or internal note a rep writes in Salesforce shows up in the inbox under that rep: the comment's author is matched to the linked member (the same email-based link assignee sync uses), so their OpenCX name and profile picture render on the message — like HubSpot and Zendesk replies do. An author without a linked member shows their Salesforce display name.

**Members ↔ Salesforce users.** Members are matched to Salesforce Users by email. To bring your Salesforce agents in as members and link them in one go, open **Settings → Members → Invite Members** and choose **Add members from Salesforce** (Email-to-Case v2 orgs only; requires the assignee sync switch below to be on): it fetches the org's active Salesforce users, links existing members with the same email, and creates members for agents who have no account yet — with the roles selected in the same dialog. Roles are required to create members: a membership with no role has no permissions, so with none selected the run only links agents who already have an OpenCX account and reports every new agent as skipped. No invitation email is sent; they sign in with the same email. The user OpenCX is connected as, API-only users, users without an email, and an email shared by several users are skipped and listed with a reason. Linked members carry a **Salesforce** badge on the Members page. Links are also made lazily the first time an owner or assignee change matches by email.

The same run brings your **Case Queues** in as teams — the other kind of Case owner: each Queue that can own a Case gets a support-enabled OpenCX team named after it (an existing team with that name is reused) and is registered on that team under **Escalation teams**, so a Queue-owned Case has somewhere to route. Queues already registered on a team are left alone; a Queue whose name is already taken by another registration is skipped and listed.

**Case Owner ↔ session assignee.** One switch under **Settings → Ticketing → Salesforce assignee sync** (it sets the same two organization settings HubSpot and Zendesk use) turns this on in both directions:

* **Salesforce → OpenCX** — when a rep takes the Case in Salesforce, the session is handed off (as always) *and* assigned to the linked OpenCX teammate. Giving the Case back to the integration user hands the session back to the AI. Reassignments between reps follow along.
* **OpenCX → Salesforce** — when the session's assignee changes in OpenCX, `Case.OwnerId` follows: a teammate → their Salesforce User, the AI → the integration user, unassigned → the Queue registered on the session's team when it sits in one, else the owner the Case had before OpenCX took it. Teammates without a linked Salesforce User are skipped, never cleared.

A Case has **one** owner — a User *or* a Queue, never both — so while the switch is on the session follows the same rule: it is either a linked member's or its team's. Assigning a member (or the AI) in OpenCX takes the session out of its team and the Case out of its Queue; moving a session to a team unassigns it and hands the Case to that team's Queue; a Case handed to a Queue in Salesforce leaves the session unassigned in the mapped team. The inbox only offers members linked to a Salesforce user and teams linked to a Queue (anyone else could not own the Case — add them from Salesforce first), and teams are not auto-distributed: a Queue-owned Case waits for a rep to take it. None of this applies while the switch is off, or to any other ticketing system.

**Queues ↔ teams.** A Salesforce Queue registered under **Escalation teams** can point at an OpenCX team (`group_id`) — **Add members from Salesforce** does this for every Case Queue, and the API accepts `group_id` for manual registrations. With assignee sync on, a Case assigned to that Queue moves the session into that team (unassigned — see above), and a session left unassigned in that team hands the Case to the Queue; the same registration is what the AI's escalate-case tool uses to route to the Queue.

## Changing the integration user (the AI's Salesforce seat)

Everything OpenCX writes to Salesforce — Case updates, Case comments, draft replies — is authored by **the Salesforce user who completed the OAuth connection**. There is no separate setting for it: the seat *is* whoever clicked **Allow**. OpenCX resolves that user from the token at runtime.

This also caps what the integration can do. An OAuth scope never grants more than the connecting user's own profile allows, so the seat's permissions — not the scope list — are the real limit.

### Switching to a different user

<Steps>
  <Step title="Sign in to Salesforce as the user you want the integration to run as">
    Use a private/incognito window, or log out of Salesforce first. The connect flow reuses whatever Salesforce session is already active in your browser — if you skip this, you will silently re-authorize the **same** seat and see no error.
  </Step>

  <Step title="Reconnect in OpenCX">
    In [Settings → Integrations](https://platform.open.cx/settings/integrations) → **Salesforce** → **Case Management**, click **Save and Connect** again. Your Consumer Key, Secret and Login URL stay as they are.
  </Step>

  <Step title="Approve">
    If prompted, sign in as the new user, then click **Allow**. New tokens replace the old ones. From that moment every Case update, Case comment and draft reply is authored by the new user.
  </Step>

  <Step title="Verify">
    Trigger a handoff (or reply on an existing AI-escalated Case) and confirm the resulting Case comment is authored by the new user.
  </Step>
</Steps>

### Requirements for the new user

A user who fails any of these cannot hold the seat:

| Requirement                                             | Why                                                                                                                                                  |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Can log in through a browser**                        | OpenCX uses the OAuth authorization-code flow, which requires reaching the Salesforce approval screen interactively.                                 |
| **Belongs to the org that owns the app**                | Authorizing into a different org fails with `OAUTH_AUTHORIZATION_BLOCKED`. See [troubleshooting](/integrations/salesforce/troubleshooting).          |
| **Authorized for the app**                              | If the app's permitted-users policy is **Admin approved users are pre-authorized**, assign the user's profile or a permission set to the app first.  |
| **Has the object permissions below**                    | Missing permissions surface later as failed Case writes, not as a connection error.                                                                  |
| **App's refresh token policy is *valid until revoked*** | Otherwise the connection dies when the access token expires — usually within hours. See [troubleshooting](/integrations/salesforce/troubleshooting). |

<Warning>
  **A Salesforce Integration License user (API Only) cannot be used.** That license blocks interactive login, and the authorization-code flow requires it. OpenCX does not support the JWT bearer or client-credentials flows that exist for API-only users. Use a dedicated **regular** user with a minimal profile instead — see the object permissions in [Setup](#setup).
</Warning>

<Note>
  Prefer a dedicated named user (for example `opencx-integration@yourcompany.com`) over a person's account. When an employee leaves and their user is deactivated, the integration stops with them.
</Note>

## Disconnecting

In OpenCX, open the Salesforce integration and click **Disconnect**. Then in Salesforce, delete the Named Credential (`OpenCX_Integration`) and the Apex Trigger/Class you created. Cases created while the integration was active remain untouched.

***

## Related Documentation

<CardGroup cols={2}>
  <Card title="AI Email in Salesforce" icon="envelope" href="/integrations/salesforce/channels/email">
    Per-channel implementation details for email handoff.
  </Card>

  <Card title="Live Messaging" icon="messages" href="/integrations/salesforce/live-messaging">
    The path for chat, SMS, WhatsApp, and phone channels.
  </Card>

  <Card title="Troubleshooting" icon="wrench" href="/integrations/salesforce/troubleshooting">
    OAuth errors, missing Cases, webhook issues.
  </Card>

  <Card title="Handoff settings" icon="user-group" href="/handoff/introduction">
    Global handoff rules and office hours.
  </Card>
</CardGroup>
