*By a Salesforce Architect | April 2026
Introduction
If you've ever tried to pull Zoom Meeting Activity Reports into Salesforce, you've probably hit a frustrating wall. The standard OAuth General App works fine — but the moment you switch to Zoom's Server-to-Server (S2S) OAuth app, things break fast. Unauthorized errors, invalid tokens, and no clear path forward.
This article explains why this happens and walks you through the complete working solution — including secure credential storage and a full Apex implementation.
The Problem: What Is Zoom Server-to-Server OAuth?
Zoom's Server-to-Server OAuth app is designed for backend, machine-to-machine communication. Unlike a regular OAuth app that requires a user to log in and authorize via a browser redirect, S2S OAuth generates tokens programmatically using just your app credentials.
The token endpoint looks like this:
POST https://zoom.us/oauth/token?grant_type=account_credentials&account_id={ACCOUNT_ID}
Authorization: Basic {Base64(CLIENT_ID:CLIENT_SECRET)}
This is a variant of the OAuth 2.0 Client Credentials flow — but with a non-standard grant_type value (account_credentials) that Zoom uses instead of the standard client_credentials.
Why Salesforce Native OAuth Doesn't Work Here
Salesforce's built-in authentication mechanisms — Auth Providers, External Credentials, and Named Credentials — are built around the standard Authorization Code flow. This flow:
- Redirects the user to an external login page
- Gets an authorization code back via callback
- Exchanges that code for a token
Zoom's S2S flow does none of this. There's no redirect, no callback URL, no user interaction. Salesforce simply has no native support for grant_type=account_credentials.
This is why every attempt to use External Credentials or Auth Providers with Zoom S2S ends up in 401 Unauthorized or invalid_token errors — Salesforce is trying to run a flow that Zoom's S2S endpoint doesn't support.
The Solution: Manual Token Management in Apex
Since Salesforce can't auto-manage the token exchange, we handle it ourselves. The architecture is straightforward:
- Store credentials securely in Protected Custom Metadata
- Fetch the token from Zoom's endpoint at runtime using Apex
- Use the token to call the Zoom API
- Whitelist the domains in Remote Site Settings
Step 1: Remote Site Settings
Before any callout can happen, you must whitelist Zoom's domains.
Go to Setup → Remote Site Settings and add:
| Remote Site Name | URL |
|---|---|
| Zoom_Auth | https://zoom.us |
| Zoom_API | https://api.zoom.us |
Step 2: Store Credentials Securely Using Custom Metadata
Never hardcode credentials in Apex. Use Protected Custom Metadata so values are encrypted at rest and not visible to non-admin users.
Create the Custom Metadata Type
- Go to Setup → Custom Metadata Types → New
- Label:
Zoom API Config - API Name:
Zoom_API_Config__mdt
Add Fields
| Field Label | API Name | Type |
|---|---|---|
| Client ID | Client_ID__c |
Text(255) |
| Client Secret | Client_Secret__c |
Text(255) |
| Account ID | Account_ID__c |
Text(255) |
| Base API URL | Base_API_URL__c |
URL |
| Token URL | Token_URL__c |
URL |
Create a Record
- Label:
Default - Client ID: (your Zoom Client ID)
- Client Secret: (your Zoom Client Secret)
- Account ID: (your Zoom Account ID)
- Base API URL:
https://api.zoom.us/v2 - Token URL:
https://zoom.us/oauth/token
Note: Mark the metadata type as Protected to prevent access via SOQL from unmanaged packages.
Step 3: Apex Service Class — Token + API Call
/**
* ZoomS2SService
* Handles Server-to-Server OAuth token generation
* and API calls to Zoom's reporting endpoints.
*
* Author: Salesforce Architect
* Version: 1.0
*/
public class ZoomS2SService {
// ─────────────────────────────────────────────
// Inner class to hold config from Custom Metadata
// ─────────────────────────────────────────────
private class ZoomConfig {
String clientId;
String clientSecret;
String accountId;
String tokenUrl;
String baseApiUrl;
}
// ─────────────────────────────────────────────
// Load config from Protected Custom Metadata
// ─────────────────────────────────────────────
private static ZoomConfig loadConfig() {
Zoom_API_Config__mdt config = [
SELECT Client_ID__c, Client_Secret__c, Account_ID__c,
Token_URL__c, Base_API_URL__c
FROM Zoom_API_Config__mdt
WHERE DeveloperName = 'Default'
LIMIT 1
];
ZoomConfig zc = new ZoomConfig();
zc.clientId = config.Client_ID__c;
zc.clientSecret = config.Client_Secret__c;
zc.accountId = config.Account_ID__c;
zc.tokenUrl = config.Token_URL__c;
zc.baseApiUrl = config.Base_API_URL__c;
return zc;
}
// ─────────────────────────────────────────────
// Step 1: Fetch Access Token from Zoom
// ─────────────────────────────────────────────
public static String getAccessToken() {
ZoomConfig config = loadConfig();
// Build Basic Auth header: Base64(clientId:clientSecret)
String rawCredentials = config.clientId + ':' + config.clientSecret;
String encodedCredentials = EncodingUtil.base64Encode(Blob.valueOf(rawCredentials));
// Build the token request
HttpRequest tokenRequest = new HttpRequest();
tokenRequest.setEndpoint(
config.tokenUrl + '?grant_type=account_credentials&account_id=' + config.accountId
);
tokenRequest.setMethod('POST');
tokenRequest.setHeader('Authorization', 'Basic ' + encodedCredentials);
tokenRequest.setHeader('Content-Type', 'application/x-www-form-urlencoded');
Http http = new Http();
HttpResponse tokenResponse = http.send(tokenRequest);
if (tokenResponse.getStatusCode() != 200) {
throw new CalloutException(
'Failed to retrieve Zoom access token. Status: '
+ tokenResponse.getStatusCode()
+ ' Body: '
+ tokenResponse.getBody()
);
}
// Parse the token response
Map<String, Object> tokenData = (Map<String, Object>)
JSON.deserializeUntyped(tokenResponse.getBody());
return (String) tokenData.get('access_token');
}
// ─────────────────────────────────────────────
// Step 2: Call Zoom Meeting Activities Report API
// ─────────────────────────────────────────────
public static String getMeetingActivityReport(String fromDate, String toDate) {
ZoomConfig config = loadConfig();
String accessToken = getAccessToken();
// Build API request
HttpRequest apiRequest = new HttpRequest();
apiRequest.setEndpoint(
config.baseApiUrl
+ '/report/meeting_activities'
+ '?from=' + fromDate
+ '&to=' + toDate
);
apiRequest.setMethod('GET');
apiRequest.setHeader('Authorization', 'Bearer ' + accessToken);
apiRequest.setHeader('Content-Type', 'application/json');
Http http = new Http();
HttpResponse apiResponse = http.send(apiRequest);
if (apiResponse.getStatusCode() != 200) {
throw new CalloutException(
'Zoom API call failed. Status: '
+ apiResponse.getStatusCode()
+ ' Body: '
+ apiResponse.getBody()
);
}
return apiResponse.getBody();
}
}
Step 4: Parse and Use the Response
/**
* ZoomMeetingActivityParser
* Parses the Zoom Meeting Activity Report response
* and maps it to Salesforce-friendly structures.
*/
public class ZoomMeetingActivityParser {
public class MeetingActivity {
public String meetingId;
public String topic;
public String hostEmail;
public Integer participantCount;
public String startTime;
public String endTime;
}
public static List<MeetingActivity> parse(String jsonResponse) {
List<MeetingActivity> activities = new List<MeetingActivity>();
Map<String, Object> responseMap = (Map<String, Object>)
JSON.deserializeUntyped(jsonResponse);
List<Object> meetings = (List<Object>) responseMap.get('activity_logs');
if (meetings == null) return activities;
for (Object obj : meetings) {
Map<String, Object> meetingData = (Map<String, Object>) obj;
MeetingActivity activity = new MeetingActivity();
activity.meetingId = (String) meetingData.get('meeting_id');
activity.topic = (String) meetingData.get('topic');
activity.hostEmail = (String) meetingData.get('host_email');
activity.participantCount = (Integer) meetingData.get('participants_count');
activity.startTime = (String) meetingData.get('start_time');
activity.endTime = (String) meetingData.get('end_time');
activities.add(activity);
}
return activities;
}
}
Step 5: Putting It All Together — Sample Invocation
/**
* Example: Call from a Scheduled Job, Flow, or LWC Controller
*/
public class ZoomReportController {
@AuraEnabled
public static List<ZoomMeetingActivityParser.MeetingActivity> fetchReport(
String fromDate,
String toDate
) {
try {
// Fetch raw JSON from Zoom API
String rawResponse = ZoomS2SService.getMeetingActivityReport(fromDate, toDate);
// Parse into structured list
List<ZoomMeetingActivityParser.MeetingActivity> activities =
ZoomMeetingActivityParser.parse(rawResponse);
return activities;
} catch (Exception e) {
throw new AuraHandledException('Error fetching Zoom report: ' + e.getMessage());
}
}
}
Sample Call
// In Developer Console or Anonymous Apex
String rawJson = ZoomS2SService.getMeetingActivityReport('2026-04-01', '2026-04-15');
System.debug(rawJson);
Optional: Cache the Token with Platform Cache
Zoom's S2S tokens are valid for 1 hour. To avoid generating a new token on every API call, use Salesforce Platform Cache to store and reuse the token within its validity window.
public static String getAccessTokenCached() {
Cache.OrgPartition orgCache = Cache.Org.getPartition('local.ZoomCache');
String cachedToken = (String) orgCache.get('zoomAccessToken');
if (cachedToken != null) {
return cachedToken;
}
// No cache hit — generate a new token
String freshToken = getAccessToken();
// Cache for 55 minutes (3300 seconds) — slightly under the 60-min expiry
orgCache.put('zoomAccessToken', freshToken, 3300);
return freshToken;
}
Prerequisite: Create a Platform Cache partition named
ZoomCacheunder Setup → Platform Cache.
Architecture Summary
┌──────────────────────────────────────────────────────────┐
│ SALESFORCE ORG │
│ │
│ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ Protected Custom │──────▶│ ZoomS2SService.cls │ │
│ │ Metadata (Creds) │ │ - loadConfig() │ │
│ └─────────────────┘ │ - getAccessToken() │ │
│ │ - getMeetingActivity() │ │
│ ┌─────────────────┐ └────────────┬─────────────┘ │
│ │ Platform Cache │◀───────────────────┘ │
│ │ (Token, 55 min) │ │
│ └─────────────────┘ │
└──────────────────────────────┬───────────────────────────┘
│ HTTPS Callout
┌───────────────▼────────────────┐
│ ZOOM APIs │
│ POST /oauth/token │
│ GET /v2/report/meeting_ │
│ activities │
└─────────────────────────────────┘
Common Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
401 Unauthorized |
Wrong Client ID/Secret encoding | Verify Base64 of clientId:clientSecret with no spaces |
400 Bad Request |
Missing or wrong account_id param |
Confirm Account ID from Zoom App settings |
System.CalloutException: Unauthorized endpoint |
Remote Site missing | Add zoom.us and api.zoom.us to Remote Site Settings |
Scope not granted |
Missing scopes on Zoom app | Add report:read:admin scope in Zoom App settings |
Token expired |
Token older than 60 min | Use Platform Cache or regenerate token per request |
Key Takeaways
- Zoom's S2S OAuth uses a non-standard Client Credentials flow that Salesforce's native auth framework cannot handle automatically.
- The correct approach is to manually manage token generation in Apex using a Basic Auth header.
- Always store credentials in Protected Custom Metadata — never hardcode them.
- Use Platform Cache to avoid unnecessary token regeneration and stay within Zoom's rate limits.
- This same pattern applies to any third-party API that uses a non-standard OAuth variant not natively supported by Salesforce.
Have questions or a different approach that worked for you? Drop a comment below.
Top comments (0)