@binraider If you’re still struggling, I asked AI to generate some debug logging helpers that you can put in your site to see if you can get some more information about webhooks. Feel free to use as you see fit:
// =============================================================================
// WebhookDiagnostics.cs
//
// Drop-in diagnostic for an Umbraco 17 site where webhooks have stopped firing
// silently. Add this file to any project in the solution that has a reference
// to Umbraco.Cms.Core and Umbraco.Cms.Infrastructure (typically the web app
// project itself). It will auto-compose at startup — no extra wiring needed.
//
// What it does, all logged at WARNING level so it shows up in CloudWatch:
//
// 1. Logs every ContentPublishedNotification with the runtime server role
// and accessor type. (Tells you whether the role check in
// WebhookEventBase.HandleAsync is the silent killer.)
//
// 2. Decorates IWebhookFiringService and logs every FireAsync call, plus
// whether it completed or threw. (Tells you whether requests are making
// it into umbracoWebhookRequest.)
//
// 3. On application start, dumps the list of registered IWebhookEvent
// aliases. (Catches a stray composer that cleared the event collection.)
//
// 4. Logs every RecurringBackgroundJobIgnoredNotification with the reason
// class. (Tells you if TouchServerJob — the thing that updates the
// runtime server role — is being skipped, e.g. because of MainDom.)
//
// 5. Exposes GET /_diag/webhooks returning the runtime role and pending
// request count, so you can curl it without redeploying.
//
// To remove: just delete this file. There is no other footprint.
// =============================================================================
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Core.Webhooks;
using Umbraco.Cms.Infrastructure.Notifications;
using Umbraco.Cms.Infrastructure.Services.Implement;
namespace WebhookDiagnostics;
// -----------------------------------------------------------------------------
// 1. Composer — wires everything up. Auto-discovered by Umbraco at startup.
// -----------------------------------------------------------------------------
public class WebhookDiagnosticComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
// Notification handlers
builder.AddNotificationAsyncHandler<ContentPublishedNotification, ContentPublishedDiagnosticHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, WebhookEventDumpHandler>();
builder.AddNotificationAsyncHandler<RecurringBackgroundJobIgnoredNotification, JobIgnoredHandler>();
// Decorate IWebhookFiringService.
// We register the original concrete type so we can resolve it as the
// inner; then we replace the IWebhookFiringService registration with
// a factory that builds the decorator. AddUnique<TService, TImpl>()
// would *remove* the original from DI before we got to wrap it, which
// is why we go via the factory overload.
builder.Services.AddTransient<WebhookFiringService>();
builder.Services.AddUnique<IWebhookFiringService>(sp =>
new LoggingWebhookFiringService(
sp.GetRequiredService<WebhookFiringService>(),
sp.GetRequiredService<ILogger<LoggingWebhookFiringService>>()));
}
}
// -----------------------------------------------------------------------------
// 2. ContentPublishedNotification handler.
// Tells you whether the notification is being raised at all, and what the
// runtime server role looks like at the moment of dispatch. The role check
// inside WebhookEventBase.HandleAsync that bails out silently uses exactly
// this same IServerRoleAccessor, so this is the authoritative reading.
// -----------------------------------------------------------------------------
public class ContentPublishedDiagnosticHandler : INotificationAsyncHandler<ContentPublishedNotification>
{
private readonly ILogger<ContentPublishedDiagnosticHandler> _logger;
private readonly IServerRoleAccessor _roleAccessor;
public ContentPublishedDiagnosticHandler(
ILogger<ContentPublishedDiagnosticHandler> logger,
IServerRoleAccessor roleAccessor)
{
_logger = logger;
_roleAccessor = roleAccessor;
}
public Task HandleAsync(ContentPublishedNotification notification, CancellationToken cancellationToken)
{
var role = _roleAccessor.CurrentServerRole;
var willFire = role is ServerRole.Single or ServerRole.SchedulingPublisher;
_logger.LogWarning(
"WEBHOOK_DIAG: ContentPublished received. Role={Role} (WillFireWebhooks={WillFire}), " +
"AccessorType={AccessorType}, PublishedCount={Count}, Machine={Machine}",
role,
willFire,
_roleAccessor.GetType().Name,
notification.PublishedEntities.Count(),
Environment.MachineName);
return Task.CompletedTask;
}
}
// -----------------------------------------------------------------------------
// 3. IWebhookFiringService decorator.
// Logs every attempt to queue a webhook request. If you see "FireAsync
// completed" but no row in umbracoWebhookRequest, something is eating the
// insert. If you don't see FireAsync called at all when content publishes,
// the role check or notification flow is the blocker — not the queueing.
// -----------------------------------------------------------------------------
public class LoggingWebhookFiringService : IWebhookFiringService
{
private readonly IWebhookFiringService _inner;
private readonly ILogger<LoggingWebhookFiringService> _logger;
public LoggingWebhookFiringService(
IWebhookFiringService inner,
ILogger<LoggingWebhookFiringService> logger)
{
_inner = inner;
_logger = logger;
}
public async Task FireAsync(IWebhook webhook, string eventAlias, object? payload, CancellationToken cancellationToken)
{
_logger.LogWarning(
"WEBHOOK_DIAG: FireAsync called. Alias={Alias}, Url={Url}, WebhookKey={Key}, Enabled={Enabled}",
eventAlias, webhook.Url, webhook.Key, webhook.Enabled);
try
{
await _inner.FireAsync(webhook, eventAlias, payload, cancellationToken);
_logger.LogWarning("WEBHOOK_DIAG: FireAsync completed (request should now be in umbracoWebhookRequest). Alias={Alias}", eventAlias);
}
catch (Exception ex)
{
_logger.LogError(ex, "WEBHOOK_DIAG: FireAsync threw. Alias={Alias}", eventAlias);
throw;
}
}
}
// -----------------------------------------------------------------------------
// 4. Startup dump of registered webhook events.
// If your event alias (e.g. "Umbraco.ContentPublish") is not in this list,
// something — usually a composer doing `.WebhookEvents().Clear()` without
// re-adding — has nuked the registration.
// -----------------------------------------------------------------------------
public class WebhookEventDumpHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
{
private readonly WebhookEventCollection _events;
private readonly ILogger<WebhookEventDumpHandler> _logger;
public WebhookEventDumpHandler(WebhookEventCollection events, ILogger<WebhookEventDumpHandler> logger)
{
_events = events;
_logger = logger;
}
public Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken)
{
var aliases = _events.Select(e => e.Alias).OrderBy(a => a).ToArray();
_logger.LogWarning(
"WEBHOOK_DIAG: Application started. Registered webhook events ({Count}): {Aliases}",
aliases.Length,
string.Join(", ", aliases));
return Task.CompletedTask;
}
}
// -----------------------------------------------------------------------------
// 5. RecurringBackgroundJobIgnored handler.
// The webhook *firing* job (WebhookFiring) is a distributed job in v17 and
// won't show up here. But TouchServerJob *is* recurring, and if it's being
// ignored — runtime not at Run, role mismatch, or MainDom — the in-memory
// server role never updates and the queueing path silently dies. So if you
// see TouchServerJob in this log, that's a strong lead.
// -----------------------------------------------------------------------------
public class JobIgnoredHandler : INotificationAsyncHandler<RecurringBackgroundJobIgnoredNotification>
{
private readonly ILogger<JobIgnoredHandler> _logger;
public JobIgnoredHandler(ILogger<JobIgnoredHandler> logger) => _logger = logger;
public Task HandleAsync(RecurringBackgroundJobIgnoredNotification notification, CancellationToken cancellationToken)
{
_logger.LogWarning(
"WEBHOOK_DIAG: Recurring job ignored: {JobType}",
notification.Job.GetType().Name);
return Task.CompletedTask;
}
}
// -----------------------------------------------------------------------------
// 6. Diagnostic endpoint: GET /_diag/webhooks
// Returns the runtime server role and a few useful counters. Hit it with
// curl from inside or outside the container — no redeploy needed once this
// file is shipped.
//
// NOTE: this is unauthenticated by design (it's diagnostic, no secrets).
// Remove the file or add [Authorize] before leaving it in prod.
// -----------------------------------------------------------------------------
[ApiController]
[Route("_diag/webhooks")]
public class WebhookDiagController : ControllerBase
{
private readonly IServerRoleAccessor _roleAccessor;
private readonly WebhookEventCollection _events;
private readonly IWebhookFiringService _firingService;
public WebhookDiagController(
IServerRoleAccessor roleAccessor,
WebhookEventCollection events,
IWebhookFiringService firingService)
{
_roleAccessor = roleAccessor;
_events = events;
_firingService = firingService;
}
[HttpGet]
public IActionResult Get() => Ok(new
{
machine = Environment.MachineName,
utcNow = DateTime.UtcNow,
runtimeRole = _roleAccessor.CurrentServerRole.ToString(),
roleAccessorType = _roleAccessor.GetType().FullName,
webhookFiringServiceType = _firingService.GetType().FullName, // should show LoggingWebhookFiringService
registeredWebhookEventCount = _events.Count(),
registeredWebhookEventAliases = _events.Select(e => e.Alias).OrderBy(a => a).ToArray(),
});
}
Justin