Zoom Webhook URL Validation Failed in ASP.NET Core

Hi everyone,

I’m trying to integrate Zoom webhooks in my ASP.NET Core API project, but I keep getting the error:

URL validation failed. Try again later.

What I Tried

  1. Basic Controller Setup
[ApiController]
[Route("api/[controller]")]
public class ZoomWebhookController : ControllerBase
{
    [HttpPost("HandleZoomEvent")]
    public async Task<IActionResult> HandleZoomEvent()
    {
        try
        {
            Request.EnableBuffering();
            Request.Body.Position = 0;
            using var reader = new StreamReader(Request.Body, Encoding.UTF8);
            var body = await reader.ReadToEndAsync();

            using var jsonDoc = JsonDocument.Parse(body);
            var payload = jsonDoc.RootElement;

            var eventType = payload.GetProperty("event").GetString();

            if (eventType == "endpoint.url_validation")
            {
                var plainToken = payload.GetProperty("payload").GetProperty("plainToken").GetString();

                var encryptedToken = Convert.ToBase64String(
                    SHA256.HashData(
                        Encoding.UTF8.GetBytes(plainToken + "MY_WEBHOOK_SECRET_TOKEN")
                    )
                );

                return Ok(new
                {
                    plainToken,
                    encryptedToken
                });
            }

            return Ok();
        }
        catch (Exception ex)
        {
            return BadRequest(ex.Message);
        }
    }
}

  1. Tried with [FromBody] parameter
[HttpPost("HandleZoomEvent")]
public IActionResult HandleZoomEvent([FromBody] JsonElement payload)
{
    var eventType = payload.GetProperty("event").GetString();
    ...
}

  1. Also tested with dynamic model binding but got errors because Zoom sends plainToken inside payloadplainToken.

Results

  • When I test locally with Postman and send Zoom’s sample payload, it works fine.

  • But when I configure Zoom webhook with my API endpoint, I always get:

URL validation failed. Try again later.

Question

  • What’s the correct way to handle Zoom’s endpoint.url_validation in ASP.NET Core?

  • Am I missing some required response format (maybe casing or JSON structure)?

  • Does Zoom require raw JSON instead of ASP.NET Core’s default camelCase serializer?

Any help would be appreciated



public interface IZoomWebhookAuthenticationHandler
{
    Task<ZoomWebhookAuthenticationResult> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken = default);
    string ComputeUrlValidationResponse(string plainToken);
}

public class ZoomWebhookAuthenticationHandler : IZoomWebhookAuthenticationHandler
{
    private readonly IOptions<ZoomConfiguration> _zoomConfig;
    private readonly ILogger<ZoomWebhookAuthenticationHandler> _logger;
    private readonly string _webhookSecret;

    public ZoomWebhookAuthenticationHandler(IOptions<ZoomConfiguration> zoomConfig, ILogger<ZoomWebhookAuthenticationHandler> logger)
    {
        _zoomConfig = zoomConfig;
        _logger = logger;
        _webhookSecret = _zoomConfig.Value.ZoomWebhookSecret
            ?? throw new InvalidOperationException("ZoomWebhookSecret configuration is missing");
    }

    public async Task<ZoomWebhookAuthenticationResult> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken = default)
    {
        try
        {
            // Enable buffering to read body multiple times
            context.Request.EnableBuffering();

            // Read the request body
            using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true);
            var body = await reader.ReadToEndAsync(cancellationToken);
            context.Request.Body.Position = 0;

            // Extract headers
            if (!context.Request.Headers.TryGetValue("x-zm-request-timestamp", out var timestampHeader))
            {
                _logger.LogWarning("Missing x-zm-request-timestamp header in Zoom webhook request");
                return ZoomWebhookAuthenticationResult.Failed("Missing timestamp header");
            }

            if (!context.Request.Headers.TryGetValue("x-zm-signature", out var signatureHeader))
            {
                _logger.LogWarning("Missing x-zm-signature header in Zoom webhook request");
                return ZoomWebhookAuthenticationResult.Failed("Missing signature header");
            }

            var timestamp = timestampHeader.ToString();
            var signature = signatureHeader.ToString();

            // Validate timestamp (optional: check if timestamp is not too old)
            if (!ValidateTimestamp(timestamp))
            {
                _logger.LogWarning("Invalid timestamp in Zoom webhook request: {Timestamp}", timestamp);
                return ZoomWebhookAuthenticationResult.Failed("Invalid timestamp");
            }

            // Verify signature
            var expectedSignature = ComputeSignature(timestamp, body);
            if (!string.Equals(signature, expectedSignature, StringComparison.Ordinal))
            {
                _logger.LogWarning("Invalid signature in Zoom webhook request. Expected: {Expected}, Received: {Received}",
                    expectedSignature, signature);
                return ZoomWebhookAuthenticationResult.Failed("Invalid signature");
            }

            _logger.LogDebug("Zoom webhook authentication successful for timestamp: {Timestamp}", timestamp);
            return ZoomWebhookAuthenticationResult.Success(body);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error during Zoom webhook authentication");
            return ZoomWebhookAuthenticationResult.Failed("Authentication error");
        }
    }

    private bool ValidateTimestamp(string timestamp)
    {
        if (!long.TryParse(timestamp, out var timestampValue))
        {
            return false;
        }

        var requestTime = DateTimeOffset.FromUnixTimeSeconds(timestampValue);
        var currentTime = DateTimeOffset.UtcNow;
        var timeDifference = Math.Abs((currentTime - requestTime).TotalMinutes);

        // Allow requests within 5 minutes (Zoom's recommended window)
        return timeDifference <= 5;
    }

    private string ComputeSignature(string timestamp, string body)
    {
        var message = $"v0:{timestamp}:{body}";
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_webhookSecret));
        var hash = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant();
        return $"v0={hash}";
    }

    public string ComputeUrlValidationResponse(string plainToken)
    {
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_webhookSecret));
        var encryptedToken = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(plainToken))).ToLowerInvariant();
        return encryptedToken;
    }
}

public class ZoomWebhookAuthenticationResult
{
    public bool IsAuthenticated { get; }
    public string? RequestBody { get; }
    public string? ErrorMessage { get; }

    private ZoomWebhookAuthenticationResult(bool isAuthenticated, string? requestBody = null, string? errorMessage = null)
    {
        IsAuthenticated = isAuthenticated;
        RequestBody = requestBody;
        ErrorMessage = errorMessage;
    }

    public static ZoomWebhookAuthenticationResult Success(string requestBody)
        => new(true, requestBody);

    public static ZoomWebhookAuthenticationResult Failed(string errorMessage)
        => new(false, errorMessage: errorMessage);
}

var encryptedToken = authHandler.ComputeUrlValidationResponse(urlValidationPayload.PlainToken);
var response = new ZoomUrlValidationResponse
{
    PlainToken = urlValidationPayload.PlainToken,
    EncryptedToken = encryptedToken
};

logger.Debug("URL validation successful for token: {Token}", urlValidationPayload.PlainToken);
return Task.FromResult(ZoomWebhookResult.Success(response).ToResult());

pls clean it up