DEV Community

Siva Kameswara Rao Munipalle
Siva Kameswara Rao Munipalle

Posted on

Integrating Zoom Server-to-Server OAuth with Salesforce: A Complete Guide

*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)}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Redirects the user to an external login page
  2. Gets an authorization code back via callback
  3. 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:

  1. Store credentials securely in Protected Custom Metadata
  2. Fetch the token from Zoom's endpoint at runtime using Apex
  3. Use the token to call the Zoom API
  4. 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

  1. Go to Setup → Custom Metadata Types → New
  2. Label: Zoom API Config
  3. 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Sample Call

// In Developer Console or Anonymous Apex
String rawJson = ZoomS2SService.getMeetingActivityReport('2026-04-01', '2026-04-15');
System.debug(rawJson);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Prerequisite: Create a Platform Cache partition named ZoomCache under 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                │
               └─────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)