Zoom Webhook URL Validation Suddenly Failing on Production (Previously Working)

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:

  1. Zoom sends an endpoint.url_validation event with a plainToken.

  2. Our middleware bypasses signature verification for this event.

  3. The controller generates an encryptedToken using HMAC-SHA256 with the ZOOM_WEBHOOK_SECRET_TOKEN.

  4. The server responds with:

{
  "plainToken": "<received_plain_token>",
  "encryptedToken": "<generated_hash>"
}

  1. 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

  1. Open the Zoom App configuration page.

  2. Configure the production webhook URL.

  3. Click “Validate” for the webhook endpoint.

  4. 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" });

}

};

Hi @Help3 , when validation fails, it usually means the app didn’t return the JSON response in the exact format Zoom expects so the production secret token may be the culprit if it was changed. You said “No significant changes were made to the webhook validation logic,” so I want to confirm what if any changes were made.

Other things to check:

  • WAFs or specialized server firewalls may have recently updated security rules, causing them to block Zoom’s validation user-agent or IP ranges see example from another poster: Validation fails even though response is correct - #4 by iSkeat
  • Zoom requires valid HTTPS endpoints. Check if your production SSL certificate has expired, has an incomplete certificate chain, or dropped support for TLS 1.2+.

Not to my knowledge after reviewing internally and checking the Zoom Status “past incidents” section at the bottom, however can you see this section from our documentation on revalidation and review if it applies to you? In particular, check for email notifications and let me know when they were delivered to your inbox.

I can submit through our service engineering team after you check the above suggestions and we rule them out :slight_smile: