Skip to main content

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.

The Email Cases path wires OpenCX into Salesforce Case Management. Use it for email — the channel where every handoff should become a trackable . Chat, SMS, WhatsApp, and phone belong on Live Messaging instead.
Setup takes about 30 minutes. You need a Salesforce System Administrator profile and admin access in OpenCX.

Before you start

Enterprise, Unlimited, or Developer edition. Professional edition requires API access to be enabled separately.
You need to create an External Client App, a Named Credential, an Apex Class, and an Apex Trigger. Standard user roles cannot reach these.
Required to save integration settings in Settings → Integrations.

Setup

1

Create an External Client App in Salesforce

Salesforce Starter edition does not support External Client Apps or Connected Apps. OAuth/API integrations require Enterprise, Unlimited, Developer, or Performance edition.
In Salesforce, go to Setup → Platform Tools → Apps → App Manager and click New External Client App.
  1. Fill in the basic information (App Name, API Name, Contact Email).
  2. Under API (Enable OAuth Settings), check Enable OAuth Settings.
  3. Set the Callback URL to:
    https://api.open.cx/backend/salesforce-case-management/oauth/callback
    
  4. Select scopes: Full access (full) and Perform requests at any time (refresh_token, offline_access).
  5. Check Require Secret for Web Server Flow.
  6. Check Require Secret for Refresh Token Flow.
  7. Click Save.
Copy the Consumer Key and Consumer Secret:
  1. Open the saved app and switch to the Settings tab.
  2. Scroll to App Settings → OAuth Settings and click the Consumer Key and Secret button.
  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.
It may take 2–10 minutes for a new External Client App to activate. If the next step fails immediately, wait and retry.
2

Enter credentials and connect

In your OpenCX dashboard, open Salesforce and select the Email tab.
FieldValue
Client IDThe Consumer Key from your External Client App.
Client SecretThe Consumer Secret from your External Client App.
Login URLhttps://login.salesforce.com for production. Use https://test.salesforce.com for sandbox environments.
Redirect URIAuto-populated. Do not change this value.
Click Save Credentials, then click Connect to Salesforce. 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.
The Webhook URL appears only after the connection succeeds — it does not show up the moment you open the Email tab. Complete the Connect to Salesforce → Allow flow first, then the URL is revealed in the same place for you to copy in the next step.
3

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
FieldValueNotes
Routing NameOpenCX Support (or one label per support queue / region)Display label only. Create one routing address per customer-facing mailbox.
Email Addresssupport@yourcompany.comThe actual address customers email. Must be on a domain you control. Never use a personal inbox in production.
Controlled by Permission SetUncheckedOnly enable if you gate routing access via permission sets.
Email Settings
FieldValueNotes
Save Email HeadersCheckedRequired 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 FromBlankOnly populate for internal-only support (e.g. @yourcompany.com domain allowlist). A populated list silently discards mail from anyone not on it.
Task Settings
FieldValueNotes
Create Task from EmailUncheckedThe EmailMessage record on the Case is the source of truth. Auto-creating a Task on top of it clutters reps’ task lists.
Task StatusIgnored when the checkbox above is off. If you do enable Task creation, use Completed.
Case Settings
FieldValueNotes
Case OwnerA 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 PriorityMediumReasonable default. Override later via Assignment Rules or a Flow.
Case OriginEmailMust be set (required field). Matches what OpenCX writes and enables filtering on email-originated Cases.
Flow Settings
FieldValueNotes
Omni-Channel FlowBlankOnly populate if using Salesforce Omni-Channel. Most orgs starting out route via Assignment Rules instead.
Fallback QueueBlankOnly 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:
    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.
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.
4

Configure the webhook in Salesforce

After connecting, OpenCX displays a Webhook URL. Copy it.
The webhook URL includes a secure token. Treat it like a password — do not share it publicly.
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 — this is an example, pick any name you like. The Name field auto-populates from the label.
  3. Paste the webhook URL into the URL field.
  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.
Create the Apex Class:
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.
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):
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.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.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 sendCaseEmailFromHumanAgentEvents(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_human_agent_reply';

                HttpRequest req = new HttpRequest();
                req.setEndpoint(endpointUrl);
                req.setMethod('POST');
                req.setHeader('Content-Type', 'application/json');
                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.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());
            }
        }
    }
}
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 five triggers in total: three core triggers for new cases, customer email replies, and human-agent emails sent from the Case; one optional trigger for owner reassignment; and one required trigger that actually transmits outbound AI replies to the contact.All five 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: OpenCaseTrigger (example, renameable). sObject: Case.
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: OpenEmailMessageCustomerTrigger (example, renameable). sObject: EmailMessage.
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: OpenEmailMessageHumanAgentTrigger (example, renameable). sObject: EmailMessage.
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.
trigger OpenCxHumanAgentReplyTrigger on EmailMessage (after insert) {
    // 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>();
    for (EmailMessage em : Trigger.new) {
        // Outbound email on a Case from anyone OTHER than OpenCX — i.e.
        // a human agent using Salesforce's Send Email UI.
        if (em.Incoming == false
            && em.ParentId != null
            && em.ParentId.getSObjectType() == Case.SObjectType
            && em.CreatedById != integrationUserId) {
            caseIds.add(em.ParentId);
        }
    }
    if (!caseIds.isEmpty()) {
        OpenSalesforceCaseManagement.sendCaseEmailFromHumanAgentEvents(caseIds);
    }
}
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.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: OpenCaseOwnerChangeTrigger (example, renameable). sObject: Case.
trigger OpenCxOwnerChangeTrigger 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.OwnerId != old.OwnerId && c.OwnerId != UserInfo.getUserId()) {
            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 != UserInfo.getUserId() guard.5. 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.
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.
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 (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> list = attachmentsByEm.get(l.LinkedEntityId);
            if (list == null) {
                list = new List<Messaging.EmailFileAttachment>();
                attachmentsByEm.put(l.LinkedEntityId, list);
            }
            list.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());
            }
        }
    }
}
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.
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 hidden HTML <div> 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):
mail.setOrgWideEmailAddressId('0D2XXXXXXXXXXXX');  // Id of the verified OWA
5

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

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 four methods of OpenSalesforceCaseManagement plus all five triggers (new case, customer reply, human-agent reply, owner change, 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:
@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 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 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();
    }
}
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.
Keep INTEGRATION_USERNAME in sync across all three filesOpenCxOutboundEmailSender, OpenCxHumanAgentReplyTrigger, 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.
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 five triggers will report ≥ 75%.
7

Production readiness checklist

The five 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):
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:
EditionExternal email cap / day
Developer Edition / trial15
Sandboxtypically low, varies
Enterprise / Unlimited / Performance5,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 — a future 6th 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.
8

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.
If any step fails, jump to Troubleshooting.

How handoff lands in Salesforce

When 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.
If the same contact re-engages before the Case is closed, OpenCX appends to the existing Case instead of opening a new one.

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.

AI Email in Salesforce

Per-channel implementation details for email handoff.

Live Messaging

The path for chat, SMS, WhatsApp, and phone channels.

Troubleshooting

OAuth errors, missing Cases, webhook issues.

Handoff settings

Global handoff rules and office hours.