Setup takes about 30 minutes. You need a Salesforce System Administrator profile and admin access in OpenCX.
Before you start
Salesforce edition with API access
Salesforce edition with API access
Enterprise, Unlimited, or Developer edition. Professional edition requires API access to be enabled separately.
System Administrator profile in Salesforce
System Administrator profile in Salesforce
You need to create a Connected App, a Named Credential, an Apex Class, and an Apex Trigger. Standard user roles cannot reach these.
Owner or admin in OpenCX
Owner or admin in OpenCX
Required to save integration settings in Settings → Integrations.
Setup
1
Create an External Client App in Salesforce
Salesforce Starter edition does not support Connected Apps or External Client Apps. OAuth/API integrations require Enterprise, Unlimited, Developer, or Performance edition.
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:- Fill in the basics (App Name, API Name, Contact Email) and set Distribution State to Local.
-
Enable OAuth Settings and set the Callback URL to:
-
Select exactly these OAuth scopes:
Do not add others, and do not pick
Full access— the app cannot grant it. - 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.
- 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”.
- 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.
- Save.
Why these three scopes are sufficient — verified against a live org by exercising every operation the integration performs:
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).
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.
- In App Manager (or Setup → External Client Apps), open your app.
- Under API (Enable OAuth Settings), click Manage Consumer Details.
- Salesforce will ask you to verify your identity — typically an email verification code or other MFA challenge.
- After you verify, Salesforce opens a page displaying the Consumer Key and Consumer Secret. Copy both.
2
Enter credentials and connect
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.
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.
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: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:
- 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.
Email Settings
Task Settings
Case Settings
Flow Settings
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:
-
In Gmail for
support@yourcompany.com, open Settings → Forwarding and POP/IMAP → Add a forwarding address. -
Paste the long
...case.salesforce.comaddress. -
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:
Open the newest row and copy the 9-digit code from
TextBody. - Paste the code back into Gmail’s forwarding screen → Verify. The address should now be Confirmed and forwarding is live.
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. It looks like this, with the token as the final path segment:Split it into two parts — you need them separately below:Create a Named Credential in Salesforce: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 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:2. EmailMessage trigger (customer replies) — Name: Without this trigger, OpenCX won’t see customer replies and the AI can’t follow up. Outbound emails (ones OpenCX sends via API) have 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 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 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: What each block does:
- Base URL — everything up to and including
/webhook - Token — the final path segment
Send the token as a header, not in the URL. The endpoint accepts it three ways, in this order of precedence:
x-opencx-token: <token>— recommendedAuthorization: Bearer <token>- the final path segment of the URL — legacy, still accepted so existing setups keep working
- Go to Setup → Security → Named Credentials. On the Named Credentials page, click the dropdown arrow next to New and choose New Legacy.
- Set Label to
OpenCX_Integration. The Name field auto-populates from the label — it must end up exactlyOpenCX_Integration, because the Apex class below references it ascallout:OpenCX_Integration. If you pick a different Name, update everycallout: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. - Paste the base URL into the URL field — without the token segment.
- Under Authentication, set Identity Type to
Anonymousand Authentication Protocol toNo Authentication. - Under Callout Options, check Generate Authorization Header and Allow Merge Fields in HTTP Body.
- Save.
- Go to Setup → Custom Settings → New.
- Label
OpenCX Settings, Object NameOpenCX_Settings, Setting TypeHierarchy, VisibilityProtected. - Save, then New in the Custom Fields section: a Text(255) field named
Webhook_Token. - Click Manage → New at the org-default level and paste the token into
Webhook_Token. Save.
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.
OpenSalesforceCaseManagement is an example — rename freely, but remember to update the triggers below to match):- In Salesforce, open the Developer Console (top-right gear icon → Developer Console).
- From the Developer Console’s top-left menu, go to File → New → Apex Trigger.
- Fill in the Name (examples below — rename to whatever you prefer) and the sObject, then click Submit.
- Paste the snippet into the editor.
- Save with File → Save (or ⌘S on Mac, Ctrl+S on Windows/Linux).
CaseTrigger (example, renameable — the Name must match the name declared in the trigger code). sObject: Case.OpenCxCustomerReplyTrigger (example, renameable). sObject: EmailMessage.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.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.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.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.OpenCxOutboundEmailSender (example, renameable). sObject: EmailMessage.em.ParentId.getSObjectType() == Case.SObjectType— matchesEmailMessagerows whose parent is a Case, without hard-coding the500Id prefix.em.CreatedById != integrationUserId/==— the reason we look up the integration user by Username instead ofUserInfo.getUserId(). Inafter insert,UserInfo.getUserId()always equalsCreatedById, 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/ContentVersionquery — forwards any files OpenCX attached to the outboundEmailMessage(modern Salesforce Files). If OpenCX never attaches files in your configuration, this runs once per trigger with zero rows and is effectively free.CcAddress/BccAddresssplitting — 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 theEmailMessagerecord that triggered this send; we don’t want Salesforce to create a second one.- The
SendEmailResultloop — logsOpenCxOutboundEmailSender 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.
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):5
Confirm email deliverability settings
Before the outbound email trigger can actually transmit, your Salesforce org needs two one-time settings:
- Setup → Email → Deliverability → Access level must be
All email. The trial / sandbox default ofSystem email onlyblocksMessaging.sendEmail()withNO_MASS_MAIL_PERMISSION. - The integration user’s profile must have Send Email enabled (standard on System Administrator).
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 five methods of 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 →
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: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.OpenSalesforceCaseManagementTest. All tests should pass, and coverage on OpenSalesforceCaseManagement + the six triggers will report ≥ 75%.7
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 Without this, outbound AI replies go out from the integration user’s personal Salesforce address. Customers see a weird-looking 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
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):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: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: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 = truewhen the outbound message bounces. OpenCX isn’t notified today — an additional trigger onEmailMessage (after update)should fire acase_email_bouncedwebhook 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 booleanOpenCx_Sent__conEmailMessageand set it in the trigger aftersendEmailsucceeds; short-circuit on the flag at the top of the loop. - Rate limiting.
Messaging.sendEmailcaps 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:
- A Case appears in Salesforce with
Case_From__cset toopencx. - Reply on the Case from Salesforce.
- The reply appears as an agent message on the matching session in your OpenCX Inbox.
How handoff lands in Salesforce
When triggers, OpenCX:- Creates a Salesforce Case with the conversation topic as Subject and the AI summary as Description.
- Sets
Case_From__ctoopencxso your views and reports can filter to AI-escalated cases. - Attaches the full transcript as Case comments.
- Stores the Salesforce Case ID on the OpenCX session for tracing.
- When your rep replies on the Case, the webhook fires and OpenCX delivers the reply to the contact’s inbox.
- 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.
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 ansf_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.OwnerIdfollows: 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.
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
1
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.
2
Reconnect in OpenCX
In Settings → Integrations → Salesforce → Case Management, click Save and Connect again. Your Consumer Key, Secret and Login URL stay as they are.
3
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.
4
Verify
Trigger a handoff (or reply on an existing AI-escalated Case) and confirm the resulting Case comment is authored by the new user.
Requirements for the new user
A user who fails any of these cannot hold the seat: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.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
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.