Muhmmad
(Muhmmad)
1
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
- 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);
}
}
}
- Tried with
[FromBody]
parameter
[HttpPost("HandleZoomEvent")]
public IActionResult HandleZoomEvent([FromBody] JsonElement payload)
{
var eventType = payload.GetProperty("event").GetString();
...
}
- Also tested with dynamic model binding but got errors because Zoom sends
plainToken
inside payload
→ plainToken
.
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