We installed Sales Cloud for Slack for a client, pointed it at their top opportunities, and within a week the sales lead came back with a request the managed app simply could not do: post a celebratory message to a shared channel the moment a deal hits Closed Won, formatted with the account name, amount, and owner in a specific Block Kit layout, and tag the manager. The standard app posts its own template on its own schedule, and there is no knob for a custom format on a custom trigger. So we built the Salesforce Slack integration in Apex. This post is the path we took: a Named Credential for the bot token, an Opportunity trigger, and an async callout that respects Salesforce governor limits.
Managed apps first: know what you are giving up
Before writing a line of Apex, it is worth being honest about what the managed packages already give you, because re-implementing them by hand is a waste.
Sales Cloud for Slack (the listing is "Slack Sales Elevate" on AppExchange) gives you Deal Rooms that auto-create a channel per high-value opportunity, record-based actions so a rep can edit a field or log a call from inside Slack, and a /salesforce search command. A Vietnamese commercial bank we worked with enabled it for a 50-person SME sales team as a pilot before deciding on a wider rollout.
Service Cloud for Slack is the same idea aimed at cases: Swarming spins up a channel with the case owner plus escalation experts, the bot DMs agents on assignment and SLA warnings, and there is a Knowledge search command. A retail bank used Swarming for severity-1 payment incidents, auto-creating a channel and pulling in L2 and L3 the moment a case opened.
Both apps need the matching Cloud license plus Slack Pro seats per user, and both sync on a one-to-two-minute delay rather than in real time. They cover the standard 80 percent. You drop to custom Apex when you hit one of these walls:
- The app does not support custom objects. If you want a channel per
Loan_Application__c, you are building it. - The app does not let you define a custom Block Kit notification template with your own action buttons.
- The app does not fire from background context like a batch job or a Flow running outside the UI.
Our Closed Won celebration with a bespoke format landed squarely in that 20 percent.
Named Credential and External Credential for the bot token
The single most important decision is where the Slack bot token lives. We have inherited orgs that stashed it in a Custom Setting, which means anyone with Modify All Data can read it straight out of the database. Use a Named Credential backed by an External Credential instead. The token gets masked, it never comes back through SOQL or the REST API, and Apex references the endpoint by name rather than hardcoding a URL.
The setup is four steps.
First, create the External Credential. Under Named Credentials -> External Credentials -> New, set the label to "Slack Bot OAuth" and the name to Slack_Bot_OAuth. Because Slack uses a Bearer token, pick the Custom authentication protocol with a Named Principal, name the principal slack_bot, and add a custom header: Authorization with the value Bearer {!$Credential.Slack_Bot_OAuth.BotToken}.
Second, grant access through a permission set. Create one called "Slack Callout", give it External Credential Principal Access to the slack_bot principal, and assign it to the running user. We strongly recommend a dedicated integration user here rather than a person, so the callout is not tied to someone who might leave.
Third, create the Named Credential itself: label "Slack API", name Slack_API, URL https://slack.com/api, and link it to the Slack_Bot_OAuth external credential.
Fourth, load the actual token. Go to the External Credential principal slack_bot, edit it, and set the authentication parameter BotToken to the xoxb-... value from your Slack app. That value is now encrypted at rest and injected into the header at callout time.
The trigger collects IDs and nothing else
The trigger does one job: notice which opportunities just moved into Closed Won and hand their IDs to an async worker. It does not call out.
trigger OpportunitySlackNotify on Opportunity (after update) {
List<Id> wonIds = new List<Id>();
for (Opportunity opp : Trigger.new) {
Opportunity old = Trigger.oldMap.get(opp.Id);
if (opp.StageName == 'Closed Won' && old.StageName != 'Closed Won') {
wonIds.add(opp.Id);
}
}
if (!wonIds.isEmpty()) {
System.enqueueJob(new SlackNotifyQueueable(wonIds));
}
}
The old.StageName != 'Closed Won' guard matters more than it looks. Without it, every later update to an already-won opportunity re-fires the post. The reason we never call Slack directly in the trigger comes down to three things. A synchronous callout inside a trigger burns a very tight callout budget and blocks the rest of the after-update chain of workflows, flows, and other triggers. Worse, if the network hiccups, the failed callout rolls back the entire transaction and the rep sees a cryptic error when all they did was close a deal. Enqueuing a Queueable moves the work off the critical path and gives us a place to retry.
The Queueable callout
The worker implements Queueable and, critically, Database.AllowsCallouts. Forget that second interface and the callout fails at runtime.
public class SlackNotifyQueueable implements Queueable, Database.AllowsCallouts {
private List<Id> oppIds;
public SlackNotifyQueueable(List<Id> oppIds) {
this.oppIds = oppIds;
}
public void execute(QueueableContext ctx) {
List<Opportunity> opps = [
SELECT Id, Name, Amount, Owner.Name, Account.Name, CloseDate
FROM Opportunity WHERE Id IN :oppIds
];
for (Opportunity opp : opps) {
postToSlack(opp);
}
}
private void postToSlack(Opportunity opp) {
Map<String, Object> payload = new Map<String, Object>{
'channel' => 'C0123ABCD',
'text' => 'Deal Closed Won: ' + opp.Name,
'blocks' => buildBlocks(opp)
};
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Slack_API/chat.postMessage');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json; charset=utf-8');
req.setBody(JSON.serialize(payload));
req.setTimeout(5000);
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() != 200) {
System.debug(LoggingLevel.ERROR, 'Slack call failed: ' + res.getBody());
return;
}
Map<String, Object> body =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
if (Boolean.TRUE.equals(body.get('ok')) == false) {
System.debug(LoggingLevel.ERROR, 'Slack ok=false: ' + body.get('error'));
}
}
private List<Object> buildBlocks(Opportunity opp) {
return new List<Object>{
new Map<String, Object>{
'type' => 'header',
'text' => new Map<String, Object>{
'type' => 'plain_text',
'text' => 'Closed Won ' + opp.Account.Name
}
},
new Map<String, Object>{
'type' => 'section',
'fields' => new List<Object>{
new Map<String, Object>{'type' => 'mrkdwn', 'text' => '*Amount:*\n' + opp.Amount},
new Map<String, Object>{'type' => 'mrkdwn', 'text' => '*Owner:*\n' + opp.Owner.Name}
}
}
};
}
}
The endpoint callout:Slack_API/chat.postMessage is where the Named Credential pays off. Salesforce resolves the base URL and injects the Authorization header for us, so the token never appears in code. The single biggest trap in this method is the response check. Slack returns HTTP 200 for almost everything, including failures, so a status-code-only check happily reports success while your message went nowhere. You have to deserialize the body and confirm ok is true. We have seen a production integration silently drop every message for a week because the channel ID was wrong and the code only looked at the status code, which was a cheerful 200 with {"ok":false,"error":"channel_not_found"} underneath.
Why this has to be async: the governor limits
Apex caps callouts at 100 per transaction, and synchronous Apex runs against a hard CPU and elapsed-time budget. An after-update trigger can fire with up to 200 records in a batch. Loop 200 synchronous Slack calls and you hit the callout ceiling and the time limit long before you finish. Moving to a Queueable does not make the per-transaction limit disappear, because one Queueable run is still one transaction. So we chunk the IDs into batches of about 50 and enqueue a job per chunk.
trigger OpportunitySlackNotify on Opportunity (after update) {
List<Id> wonIds = collectWonIds();
for (Integer i = 0; i < wonIds.size(); i += 50) {
Integer end = Math.min(i + 50, wonIds.size());
List<Id> chunk = new List<Id>();
for (Integer j = i; j < end; j++) chunk.add(wonIds[j]);
System.enqueueJob(new SlackNotifyQueueable(chunk));
}
}
The per-call timeout matters just as much as the count. If Slack is slow at five seconds a call, fifty calls at five seconds each blows the time budget on its own. Setting req.setTimeout(5000) caps each call and lets you skip a slow one and push it to a retry queue rather than letting one laggy request take down the whole job. Test it with an HttpCalloutMock returning {"ok":true,...} so your callout has coverage without hitting Slack from a test context.
Rotating the token and the signing secret
Treat the bot token as a credential with a lifecycle, not a constant you paste once. Slack recommends rotating it roughly every six months, and the procedure has one rule that bites people. Generate the new token in the Slack app's OAuth settings, update the BotToken parameter on the Slack_Bot_OAuth principal, and verify a trigger run in a sandbox that has pulled the new credential. Then wait. Do not revoke the old token immediately, because any Queueable still sitting in the flex queue is running with the old token and will fail the moment you pull it. Give it a 24-hour grace period so pending jobs drain.
Two more security notes from the field. Never log the full HttpRequest headers to debug, because that writes Bearer xoxb-... straight into a Salesforce debug log that anyone with the right access can read; log the status code and body instead. And if you ever fall back to an incoming webhook rather than the Web API, remember the webhook URL itself is the secret. Commit it to git and it is leaked. Slack does not support two active webhooks for one configuration, so rotating one means creating the new webhook, updating the Named Credential, and deleting the old one with a tight deploy window to avoid a gap.
Takeaway
Reach for the managed Slack apps first, because they cover the standard playbook for free. The moment you need a custom format, a custom object, or a background trigger, drop to Apex with a Named Credential for the token and a chunked Queueable for the callout, and never trust an HTTP 200 from Slack without checking the body. That combination is bulk-safe, secure, and survives token rotation, which is everything you want from an integration that posts to a channel the whole company watches.
If you want a Salesforce Slack integration built right the first time, or a second pair of eyes on one that is silently dropping messages, get in touch or see how we work on our services page.








