Setting the file. One moment.
Subchapter 114.38
use-cases/marketplace-publishing.mdMarkdown9 KBView on GitHub
Build and publish apps on the Zoom App Marketplace for multiple customers.
References
App TypesThis guide covers building multi-tenant applications for the Zoom Marketplace, handling multiple customer accounts, and the app review process.
| App Type | Visibility | Use Case |
|---|---|---|
| Account-level (Private) | Your org only | Internal tools |
| User-managed (Public) | Individual users | User-facing apps |
| Admin-managed (Public) | Org admins install | Enterprise tools |
For Marketplace publishing, you’ll create Public apps.
Store per-customer OAuth tokens and settings:
CREATE TABLE zoom_installations (
id SERIAL PRIMARY KEY,
account_id VARCHAR(255) UNIQUE NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
token_expires_at TIMESTAMP NOT NULL,
installed_at TIMESTAMP DEFAULT NOW(),
settings JSONB DEFAULT '{}'
);
CREATE INDEX idx_zoom_account ON zoom_installations(account_id);// Store tokens after OAuth callback
async function handleOAuthCallback(code, state) {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code);
// Get account info
const accountInfo = await getAccountInfo(tokens.access_token);
// Store or update installation
await db.query(`
INSERT INTO zoom_installations
(account_id, access_token, refresh_token, token_expires_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (account_id)
DO UPDATE SET
access_token = $2,
refresh_token = $3,
token_expires_at = $4
`, [
accountInfo.account_id,
tokens.access_token,
tokens.refresh_token,
new Date(Date.now() + tokens.expires_in * 1000)
]);
return accountInfo.account_id;
}async function getValidToken(accountId) {
const installation = await db.query(
'SELECT * FROM zoom_installations WHERE account_id = $1',
[accountId]
);
if (!installation) {
throw new Error('Account not installed');
}
// Check if token needs refresh
if (new Date(installation.token_expires_at) < new Date()) {
const newTokens = await refreshTokens(installation.refresh_token);
await db.query(`
UPDATE zoom_installations
SET access_token = $1, refresh_token = $2, token_expires_at = $3
WHERE account_id = $4
`, [
newTokens.access_token,
newTokens.refresh_token,
new Date(Date.now() + newTokens.expires_in * 1000),
accountId
]);
return newTokens.access_token;
}
return installation.access_token;
}app.post('/webhook', async (req, res) => {
// Verify signature first
if (!verifyWebhookSignature(req)) {
return res.status(401).send('Invalid signature');
}
const { event, payload } = req.body;
const accountId = payload.account_id;
// Check if this account has installed our app
const installation = await getInstallation(accountId);
if (!installation) {
console.log(`Webhook for unknown account: ${accountId}`);
return res.status(200).send(); // Still return 200
}
// Process event for this customer
await processEventForCustomer(accountId, event, payload);
res.status(200).send();
});
async function processEventForCustomer(accountId, event, payload) {
switch (event) {
case 'meeting.started':
await handleMeetingStarted(accountId, payload);
break;
case 'recording.completed':
await handleRecordingCompleted(accountId, payload);
break;
}
}When a customer uninstalls your app:
app.post('/webhook', async (req, res) => {
const { event, payload } = req.body;
if (event === 'app_deauthorized') {
const accountId = payload.account_id;
// Clean up customer data
await db.query('DELETE FROM zoom_installations WHERE account_id = $1', [accountId]);
// Optional: Delete customer data per compliance requirements
await cleanupCustomerData(accountId);
console.log(`App deauthorized for account: ${accountId}`);
}
res.status(200).send();
});class ZoomAPIClient {
constructor(accountId) {
this.accountId = accountId;
}
async request(method, endpoint, data = null) {
const token = await getValidToken(this.accountId);
const response = await axios({
method,
url: `https://api.zoom.us/v2${endpoint}`,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
data
});
return response.data;
}
async createMeeting(userId, meetingData) {
return this.request('POST', `/users/${userId}/meetings`, meetingData);
}
async getUsers() {
return this.request('GET', '/users');
}
}
// Usage
const client = new ZoomAPIClient('customer_account_id');
const meeting = await client.createMeeting('me', { topic: 'Team Sync' });Request minimal scopes needed:
// Good - specific scopes
const scopes = [
'meeting:read',
'meeting:write',
'user:read'
];
// Bad - overly broad
const scopes = [
'account:read:admin',
'account:write:admin'
];Provide clear descriptions for each scope in Marketplace:
| Scope | User-Facing Description |
|---|---|
meeting:read | View your meetings |
meeting:write | Create and update meetings on your behalf |
recording:read | Access your meeting recordings |
app_deauthorized// Test OAuth flow
async function testOAuthFlow() {
// 1. Generate auth URL
const authUrl = generateAuthUrl();
console.log('Auth URL:', authUrl);
// 2. Complete OAuth manually in browser
// 3. Verify token storage
// 4. Test API calls
const client = new ZoomAPIClient(testAccountId);
const users = await client.getUsers();
console.log('Users:', users);
// 5. Test webhook handling
await simulateWebhook('meeting.started', testPayload);
}
// Test deauthorization
async function testDeauthorization() {
// Simulate deauth webhook
await simulateWebhook('app_deauthorized', {
account_id: testAccountId
});
// Verify cleanup
const installation = await getInstallation(testAccountId);
console.assert(installation === null, 'Installation should be deleted');
}// Determine storage region based on user location
async function getStorageRegion(accountId) {
const accountInfo = await zoomClient.getAccountInfo(accountId);
// Map Zoom data center to storage region
const regionMap = {
'US': 'us-east-1',
'EU': 'eu-west-1',
'AU': 'ap-southeast-2',
'IN': 'ap-south-1'
};
return regionMap[accountInfo.data_residency_region] || 'us-east-1';
}
// Store data in correct region
async function storeData(accountId, data) {
const region = await getStorageRegion(accountId);
const regionalStorage = getStorageClient(region);
await regionalStorage.put(data);
}Implement per-customer rate limiting:
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const apiLimiter = rateLimit({
store: new RedisStore({
client: redisClient,
prefix: 'rl:'
}),
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute per customer
keyGenerator: (req) => {
// Rate limit per customer account
return req.headers['x-account-id'] || req.ip;
}
});
app.use('/api/', apiLimiter);