API Endpoint / Zoom API Event
Webhook Endpoint URL Validation (endpoint.url_validation)
Description
We are facing an issue with Zoom webhook URL validation on our production server.
Previously, we were using the same webhook URL successfully and receiving all Zoom webhook events without any issues. However, suddenly Zoom started showing the error:
“URL validation failed. Try again later.”
No significant changes were made to the webhook validation logic. The validation flow is implemented according to Zoom’s documentation:
-
Zoom sends an
endpoint.url_validationevent with aplainToken. -
Our middleware bypasses signature verification for this event.
-
The controller generates an
encryptedTokenusing HMAC-SHA256 with theZOOM_WEBHOOK_SECRET_TOKEN. -
The server responds with:
{
"plainToken": "<received_plain_token>",
"encryptedToken": "<generated_hash>"
}
- Zoom validates the response.
This flow was working previously and continues to work in our local environment, but it is now failing on the live server.
Error
Zoom Marketplace displays:
URL validation failed. Try again later.
Unfortunately, we are not receiving any detailed error message from Zoom that would help identify the root cause.
How To Reproduce
-
Open the Zoom App configuration page.
-
Configure the production webhook URL.
-
Click “Validate” for the webhook endpoint.
-
Zoom returns:
URL validation failed. Try again later.
Environment
-
Production server (Node.js / Express)
-
Webhook endpoint accessible via HTTPS
-
Same validation logic works locally
-
Previously the same production URL was validated successfully
Questions
What are the common reasons that can cause webhook URL validation to suddenly fail when the same endpoint was working before?
Are there any specific headers, SSL/TLS requirements, network restrictions, response format issues, or recent Zoom changes that we should verify?
Is there any way to obtain more detailed validation logs or error information from Zoom for failed URL validation attempts?
Any guidance on troubleshooting this issue would be greatly appreciated.
webhookcontroller.tsx:
import { Request, Response } from “express”;
import { WebhookService } from “./webhook.service”;
import { ZoomUserWebhookHandler } from “../zoom/zoom.webhook”;
import { ZOOM_USER_WEBHOOK_EVENTS } from “../zoom/zoom.types”;
import { logger } from “../../utils/logger”;
import * as crypto from “crypto”;
import * as fs from “fs”;
import * as path from “path”;
//real Zoom webhook events including the URL validation challenge
export const handleZoomWebhook = async (req: Request, res: Response) => {
console.log(JSON.stringify(req.body, null, 2), “req.body”)
const body = req.body ?? {};
const event: string = body.event || “”;
const payload = body.payload ?? {};
console.log(event, “event”)
// Log incoming payload for local testing & real data tracking
try {
const logFilePath = path.join(process.cwd(), "webhook_payloads.log");
const logEntry = \`\\n\[${new Date().toISOString()}\] EVENT: ${event}\\n${JSON.stringify(body, null, 2)}\\n\`;
fs.appendFileSync(logFilePath, logEntry, "utf8");
} catch (err) {
logger.error("Failed to write to webhook_payloads.log:", err);
}
// 1. Zoom URL validation challenge (one-time setup)
if (event === “endpoint.url_validation”) {
console.log("==================== ZOOM URL VALIDATION START ====================");
console.log("\[Zoom Webhook\] Incoming headers:", JSON.stringify(req.headers, null, 2));
console.log("\[Zoom Webhook\] Incoming body:", JSON.stringify(req.body, null, 2));
const plainToken = payload?.plainToken;
console.log("\[Zoom Webhook\] plainToken from payload:", plainToken);
if (!plainToken) {
console.warn("\[Zoom Webhook\] Validation Failed: plainToken is missing!");
logger.warn("Zoom URL validation received without plainToken");
console.log("==================== ZOOM URL VALIDATION END ====================");
return res.status(400).json({ message: "Missing plainToken" });
}
const webhookSecret = process.env.ZOOM_WEBHOOK_SECRET_TOKEN || "";
console.log("\[Zoom Webhook\] Webhook Secret configured:", webhookSecret ? \`YES (Length: ${webhookSecret.length})\` : "NO (Empty)");
if (webhookSecret) {
console.log("\[Zoom Webhook\] Webhook Secret prefix (first 3 chars):", webhookSecret.substring(0, 3));
}
if (!webhookSecret) {
console.error("\[Zoom Webhook\] Validation Failed: Secret not configured in environment variables!");
logger.error("ZOOM_WEBHOOK_SECRET_TOKEN not configured");
console.log("==================== ZOOM URL VALIDATION END ====================");
return res.status(500).json({ message: "Webhook secret not configured" });
}
const hash = crypto
.createHmac("sha256", webhookSecret)
.update(plainToken)
.digest("hex");
console.log("\[Zoom Webhook\] Computed hash (encryptedToken):", hash);
const responsePayload = { plainToken, encryptedToken: hash };
console.log("\[Zoom Webhook\] Responding to Zoom with payload:", JSON.stringify(responsePayload, null, 2));
console.log("==================== ZOOM URL VALIDATION END ====================");
logger.info("Zoom URL validation challenge responded successfully");
return res.status(200).json(responsePayload);
}
// 2. Respond immediately for all other events so Zoom doesn’t retry
res.status(200).json({ message: “Webhook received” });
try {
switch (event) {
case "contact_center.task_created":
await WebhookService.handleInteractionCreated(payload);
break;
case "contact_center.engagement_user_answered":
await WebhookService.handleInteractionConnected(payload);
break;
case "contact_center.engagement_ended":
await WebhookService.handleInteractionEnded(payload);
break;
case "contact_center.engagement_consumer_sent_message":
case "contact_center.engagement_user_sent_message":
await WebhookService.handleChatMessage(event, payload);
break;
case "contact_center.recording_completed":
case "contact_center.recording_transcript_completed":
await WebhookService.handleTranscriptCompleted(payload);
break;
default:
if ((ZOOM_USER_WEBHOOK_EVENTS as readonly string\[\]).includes(event)) {
await ZoomUserWebhookHandler.handleUserLifecycleEvent(event);
} else if (event) {
logger.info(\`Unhandled Zoom webhook event: ${event}\`);
}
}
} catch (err) {
logger.error(\`Webhook handler error for event \[${event}\]:\`, err);
}
};
webhookMiddileware.tsx
import { Request, Response, NextFunction } from “express”;
import * as crypto from “crypto”;
import { logger } from “../utils/logger”;
export const verifyZoomWebhook = (
req: any,
res: Response,
next: NextFunction,
) => {
// Fast path for Zoom URL validation challenge (handled securely in controller)
if (req.body?.event === “endpoint.url_validation”) {
console.log("==================== ZOOM MIDDLEWARE START (VALIDATION ONLY) ====================");
console.log("\[Zoom Middleware\] Request Path:", req.path);
console.log("\[Zoom Middleware\] Incoming Event:", req.body?.event);
console.log("\[Zoom Middleware\] Headers received:", JSON.stringify(req.headers, null, 2));
console.log("\[Zoom Middleware\] URL validation challenge detected -> Bypassing signature verification.");
console.log("==================== ZOOM MIDDLEWARE END (VALIDATION ONLY) ====================");
return next();
}
const webhookSecret = process.env.ZOOM_WEBHOOK_SECRET_TOKEN || “”;
if (!webhookSecret) {
logger.warn(
"ZOOM_WEBHOOK_SECRET_TOKEN not set — skipping all webhook verification",
);
return next();
}
try {
const signature = req.headers\["x-zm-signature"\] as string;
const timestamp = req.headers\["x-zm-request-timestamp"\] as string;
if (!signature || !timestamp) {
return res.status(401).json({ message: "Missing Zoom webhook headers" });
}
// Replay attack prevention: reject if request is older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
return res.status(401).json({ message: "Webhook timestamp too old" });
}
const rawBody = req.rawBody?.toString("utf8") || "";
const message = \`v0:${timestamp}:${rawBody}\`;
const expectedSignature = \`v0=${crypto
.createHmac("sha256", webhookSecret)
.update(message)
.digest("hex")}\`;
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (
signatureBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
) {
logger.warn("Zoom webhook signature mismatch");
return res.status(401).json({ message: "Invalid webhook signature" });
}
next();
} catch (err) {
logger.error("Webhook verification error:", err);
return res.status(500).json({ message: "Webhook verification failed" });
}
};
