Để kiểm thử Zoom webhook trên localhost, hãy public route POST cục bộ bằng npx portpreview PORT, nhập route HTTPS công khai đó làm endpoint nhận thông báo sự kiện, rồi triển khai challenge endpoint.url_validation của Zoom trước khi bấm Validate. Với các sự kiện thông thường, hãy xác minh x-zm-signature dựa trên request body nguyên bản và timestamp, lưu sự kiện theo cách idempotent, đồng thời trả về 2xx trong vòng ba giây.
Cách subscription sự kiện Zoom gửi webhook đến localhost
Zoom webhook là các thông báo HTTP POST dạng JSON cho những sự kiện đã đăng ký trong Meetings, Webinars, Phone, Team Chat, Rooms và các dịch vụ khác mà ứng dụng của bạn được phép dùng. Danh mục sự kiện và các trường cụ thể phụ thuộc vào loại ứng dụng, sản phẩm đã bật, quyền của tài khoản, scope và phiên bản hiện tại của nền tảng Zoom. Chỉ chọn những sự kiện mà handler của bạn hiểu và dùng schema sự kiện mới nhất hiển thị trong quy trình tạo ứng dụng.
Endpoint phải là HTTPS truy cập công khai, có tên miền đầy đủ, chuỗi chứng chỉ hợp lệ do CA cấp, dùng TLS 1.2 trở lên và hỗ trợ request POST JSON. URL loopback như http://localhost:3000 không đáp ứng được các yêu cầu này. PortPreview cung cấp HTTPS edge công khai và chuyển tiếp request đến tiến trình chạy trên máy local.
Tài liệu Zoom webhook chính thức là nguồn tham chiếu chuẩn cho yêu cầu endpoint, xác thực challenge-response, chữ ký sự kiện, cơ chế phân phối và các bước cấu hình hiện hành.
Tạo route Express giữ nguyên raw body
Chữ ký request của Zoom bao phủ chính xác nội dung body. Hãy giữ lại từng byte trước khi middleware JSON parse rồi serialize lại dữ liệu. Ví dụ dưới đây xử lý cả xác thực URL lẫn xác minh sự kiện thông thường trên cùng một route:
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/zoom', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const rawBody = req.body.toString('utf8');
let event;
try {
event = JSON.parse(rawBody);
} catch {
return res.status(400).json({ error: 'Invalid JSON' });
}
const secret = process.env.ZOOM_WEBHOOK_SECRET_TOKEN;
if (!secret) return res.sendStatus(500);
if (event.event === 'endpoint.url_validation') {
const plainToken = event.payload?.plainToken;
if (typeof plainToken !== 'string') return res.sendStatus(400);
const encryptedToken = crypto
.createHmac('sha256', secret)
.update(plainToken)
.digest('hex');
return res.status(200).json({ plainToken, encryptedToken });
}
const timestamp = req.get('x-zm-request-timestamp') ?? '';
const received = req.get('x-zm-signature') ?? '';
const message = `v0:${timestamp}:${rawBody}`;
const expected = `v0=${crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex')}`;
const a = Buffer.from(received);
const b = Buffer.from(expected);
const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!valid) return res.sendStatus(401);
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(401);
const requestId = req.get('x-zm-request-id');
const deliveryKey = requestId || crypto.createHash('sha256').update(rawBody).digest('hex');
await saveWebhookOnce({ provider: 'zoom', deliveryKey, event });
return res.sendStatus(200);
});
app.listen(3000);
Khoảng thời gian hợp lệ năm phút trong ví dụ này là chính sách bảo mật của ứng dụng, không thay thế việc xác minh HMAC. Hãy chọn mức dung sai phù hợp với độ đồng bộ đồng hồ và thời gian phân phối dự kiến. Chỉ ghi log nhóm nguyên nhân lỗi, tuyệt đối không ghi secret token hay toàn bộ request body.
Khởi chạy tunnel đến localhost
- Chạy ứng dụng và xác nhận route nhận được request POST cục bộ trên port 3000.
- Mở terminal khác rồi chạy
npx portpreview 3000. Thay 3000 bằng port thực tế của ứng dụng. - Nối route vào origin được tạo, ví dụ
https://YOUR-TUNNEL.portpreview.dev/webhooks/zoom. - Giữ ứng dụng và tunnel hoạt động trong lúc xác thực và kiểm thử sự kiện.
Khi tunnel mới có hostname khác, Zoom sẽ coi đó là một endpoint khác. Hãy cập nhật và xác thực URL mới trước khi chờ sự kiện gửi đến. URL phải trỏ thẳng tới POST handler; redirect không phù hợp để phân phối webhook ổn định và Zoom không retry response 3xx.
Thêm event subscription trong Zoom
Trong Zoom App Marketplace, mở ứng dụng đã tạo rồi đi đến khu vực Features hoặc Access theo giao diện tạo ứng dụng hiện tại. Bật Event Subscriptions, thêm subscription, chọn loại sự kiện và receiver, sau đó dán URL HTTPS đầy đủ. Các receiver và sự kiện khả dụng thay đổi theo loại ứng dụng và cấu hình tài khoản. Ứng dụng đã publish có thể phải được review lại khi thay đổi subscription.
Sao chép webhook secret token của ứng dụng vào một biến môi trường cục bộ không được đưa vào Git, chẳng hạn ZOOM_WEBHOOK_SECRET_TOKEN. Đây không phải OAuth client secret, access token hay verification token cũ đã bị ngừng dùng. Khởi động lại local server sau khi thay đổi biến môi trường.
Triển khai xác thực URL endpoint đúng cách
Khi bạn bấm Validate, Zoom gửi một request POST có event là endpoint.url_validation. Payload chứa plainToken. Hãy tính HMAC SHA-256 với webhook secret token làm khóa và plain token làm message, mã hóa digest thành chuỗi hexadecimal chữ thường, rồi trả về JSON gồm cả plainToken không thay đổi và encryptedToken vừa tạo.
const encryptedToken = createHmac('sha256', webhookSecret)
.update(event.payload.plainToken)
.digest('hex');
return {
plainToken: event.payload.plainToken,
encryptedToken
};
Trả về HTTP 200 cùng JSON body trong vòng ba giây. Không hash toàn bộ request xác thực, không dùng OAuth client secret, không mã hóa digest bằng Base64 và không thêm tiền tố v0= vào digest xác thực. Những cách đó thuộc các flow khác. Endpoint chỉ có thể được lưu sau khi xác thực ban đầu thành công.
Tài liệu hiện tại của Zoom cũng mô tả việc tự động xác thực lại sau mỗi 72 giờ. Sau nhiều lần xác thực lại thất bại, ứng dụng sẽ gửi thông báo cho chủ sở hữu; sau sáu lần thất bại liên tiếp, Zoom vô hiệu hóa event subscription và dừng gửi sự kiện. Vì vậy, tunnel phục vụ phát triển đã đóng sẽ khiến lần xác thực sau thất bại. Hãy xóa subscription tạm thời sau khi kiểm thử và luôn duy trì challenge handler trên production.
Xác minh request Zoom webhook thông thường
Xác thực URL chứng minh endpoint biết secret tại thời điểm challenge. Việc xác minh sự kiện thông thường là bước riêng để chứng minh body nhận được khớp với HMAC do Zoom gửi. Đọc x-zm-request-timestamp và tạo chính xác message sau:
v0:{x-zm-request-timestamp}:{raw request body}
Hash message đó bằng HMAC SHA-256 với webhook secret token làm khóa, mã hóa digest thành hexadecimal, thêm tiền tố v0=, rồi so sánh với x-zm-signature bằng phép so sánh constant-time. Body phải là request body nguyên bản. Parse JSON rồi gọi JSON.stringify có thể thay đổi khoảng trắng hoặc định dạng property và làm chữ ký hợp lệ bị sai.
Hãy từ chối chữ ký bị thiếu, sai định dạng, không hợp lệ hoặc quá cũ trước khi chạy business logic. Luôn đồng bộ thời gian hệ thống. Webhook verification token cũ đã bị deprecated và dự kiến ngừng hoạt động vào tháng 6 năm 2025; code mới phải dùng flow HMAC với secret token theo tài liệu Zoom, không dùng phép so sánh Authorization lấy từ tutorial cũ. Xem hướng dẫn xác minh chữ ký webhook để hiểu thêm về raw body và phép so sánh an toàn theo thời gian.
Điều phối từng loại sự kiện riêng của provider
Payload đã xác minh vẫn cần được kiểm tra schema và quyền truy cập. Với sự kiện meeting, meeting ID và UUID có mục đích khác nhau; các cuộc họp lặp lại hoặc định kỳ khiến việc đối chiếu theo UUID đặc biệt quan trọng. Hãy xem các trường payload là riêng cho từng event và tuân theo tài liệu tham chiếu hiện hành của mỗi subscription.
async function processZoomEvent(event) {
switch (event.event) {
case 'meeting.started':
await markMeetingStarted({
uuid: event.payload.object.uuid,
startedAt: event.payload.object.start_time
});
break;
case 'meeting.ended':
await markMeetingEnded({
uuid: event.payload.object.uuid,
endedAt: event.payload.object.end_time
});
break;
default:
await recordUnhandledZoomEvent(event.event);
}
}
Không được coi thứ tự sự kiện đến là transaction log. Độ trễ mạng, retry và xử lý song song có thể tạo ra thứ tự bất ngờ. Lưu timestamp sự kiện từ provider và áp dụng quy tắc chuyển trạng thái đơn điệu khi phù hợp. Loại sự kiện chưa biết phải quan sát được và vẫn được acknowledge sau khi lưu an toàn, thay vì liên tục làm endpoint trả lỗi.
Đáp ứng thời hạn phân phối ba giây
Zoom yêu cầu HTTP 200 hoặc 204 trong vòng ba giây để coi việc phân phối là thành công. Hãy xác minh request, kiểm tra envelope tối thiểu, ghi vào inbox hoặc queue bền vững rồi trả response. Xử lý video, cập nhật CRM, gọi calendar, gửi email và analytics nên được thực hiện trong worker.
Theo tài liệu phân phối thông báo hiện tại của Zoom, lỗi server và lỗi kết nối đủ điều kiện sẽ được retry ba lần: khoảng năm phút sau lần đầu, tiếp theo là 20 phút sau lần retry đó, rồi 60 phút sau lần retry thứ hai. Zoom coi 2xx là thành công; hệ thống không retry redirect 3xx hoặc lỗi client 4xx. Chính sách có thể thay đổi, vì vậy hãy kiểm tra lại trang chính thức trước khi xây dựng cảnh báo vận hành dựa trên các khoảng thời gian chính xác này.
Đảm bảo mọi sự kiện đều idempotent
Một lần retry có thể xảy ra sau timeout không rõ kết quả dù lần xử lý đầu tiên đã commit. Hãy loại bỏ bản trùng trước khi tạo side effect. Header x-zm-request-id có trong cấu trúc request được Zoom ghi nhận, nhưng code vẫn phải hoạt động khi header này không tồn tại ở một số sản phẩm hoặc phiên bản. Dùng header khi có; nếu không, tạo khóa ổn định từ dữ liệu sự kiện bất biến đã xác minh hoặc digest mật mã của raw body đã xác minh. Ràng buộc tính duy nhất tại storage, không chỉ bằng cache trong bộ nhớ.
await db.transaction(async (tx) => {
const claimed = await tx.webhookInbox.insertOnce({
provider: 'zoom',
deliveryKey,
eventType: event.event,
payload: event
});
if (!claimed) return;
await tx.jobs.enqueue({ type: 'process-zoom-event', deliveryKey });
});
Thao tác claim bản ghi inbox và tạo job phải có tính atomic. Nếu worker lỗi, hãy retry job mà không yêu cầu Zoom phân phối lại. Hướng dẫn retry và idempotency trình bày chi tiết về bảng inbox, unique key và ranh giới side effect.
Khắc phục lỗi xác thực và phân phối
Validate báo thất bại
Kiểm tra URL có phải HTTPS công khai, chứa đúng route, không redirect và truy cập được port local đang chạy hay không. Xác nhận response là HTTP 200 JSON có plain token nguyên bản cùng HMAC chữ thường dạng hexadecimal được tính chỉ từ token đó. Đo tổng thời gian response; quá trình xác thực phải hoàn tất trong ba giây.
Mọi sự kiện thông thường đều không qua bước xác minh chữ ký
Xác nhận bạn đã sao chép webhook secret token, không phải OAuth client secret hoặc verification token cũ. Giữ body dưới dạng raw bytes trước middleware JSON, dùng chính xác timestamp header, đặt đủ hai dấu hai chấm trong message v0:timestamp:body và chỉ thêm tiền tố v0= vào chữ ký sự kiện cuối cùng.
Xác thực thành công nhưng không nhận được sự kiện
Đảm bảo subscription đã bật và lưu, đúng loại sự kiện và receiver đã được chọn, đồng thời tài khoản hoặc người dùng thực sự phát sinh các sự kiện đó. Kiểm tra trạng thái xác thực lại và yêu cầu publish ứng dụng. Xác nhận URL tunnel không thay đổi kể từ lúc xác thực.
Handler xử lý thành công nhưng Zoom vẫn retry
Kiểm tra độ trễ và status của response công khai, không chỉ xem log local. Tác vụ chậm có thể vượt quá ba giây dù cuối cùng vẫn hoàn tất. Hãy lưu nhanh, trả về 2xx và xử lý bất đồng bộ. Inbox chống trùng giúp một lần retry không lặp lại side effect.
Sự kiện đã ghi lại không xác minh được khi replay sau đó
Bước kiểm tra độ mới phải từ chối timestamp cũ, còn việc thay đổi JSON sẽ làm HMAC mất hiệu lực. Với kiểm thử end-to-end, hãy tạo một sự kiện mới từ provider. Với kiểm thử business logic, lưu fixture đã parse và loại dữ liệu nhạy cảm, sau đó chỉ trong test harness mới bỏ qua bước xác minh ingress để gọi dispatcher. Hướng dẫn replay webhook giải thích cách tách hai lớp kiểm thử này.
Checklist bảo mật khi kiểm thử Zoom webhook
- Lưu webhook secret token trong file môi trường bị bỏ qua và rotate credential nếu bị lộ.
- Xác minh HMAC dựa trên raw body trước khi tin bất kỳ trường nào trong payload.
- Giới hạn độ lệch timestamp và đồng bộ đồng hồ server để giảm rủi ro replay.
- Kiểm tra loại sự kiện, ngữ cảnh tài khoản, định danh object, content type và kích thước body.
- Ẩn tên người tham gia, địa chỉ email, chủ đề cuộc họp, nội dung chat và dữ liệu bản ghi khỏi log.
- Dùng endpoint hoặc secret riêng cho môi trường development và production nếu cấu hình ứng dụng cho phép.
- Xóa URL công khai và subscription tạm thời khi kết thúc phiên làm việc local.
Mô hình sẵn sàng cho production cũng chính là mô hình đã được kiểm chứng ở local: HTTPS ingress ổn định, challenge handler luôn hoạt động, xác minh HMAC trên raw body, idempotency bền vững, acknowledge trong chưa đầy ba giây và worker độc lập. Để trace request và kiểm tra route nói chung, hãy xem hướng dẫn gỡ lỗi webhook trên localhost.
