Custom Notification Handlers¶
Merchello uses Umbraco's notification system to let you hook into lifecycle events -- product saves, order creation, payment processing, shipment status changes, and much more. This guide shows you how to create custom handlers.
Quick Overview¶
To create a notification handler:
- Create a class implementing
INotificationAsyncHandler<TNotification> - Add the
[NotificationHandlerPriority]attribute to control execution order - Register it with Umbraco's notification system
How Notifications Work¶
Merchello publishes notifications at key points in entity lifecycles. There are two types:
- "Before" notifications (cancelable): Published before an operation. Handlers can modify the entity or cancel the operation. Examples:
OrderSavingNotification,ProductCreatingNotification. - "After" notifications (read-only): Published after an operation completes. Used for side effects like sending emails, syncing to external systems, or logging. Examples:
OrderCreatedNotification,PaymentCreatedNotification.
Service begins operation
-> Publishes "Saving/Creating" notification (cancelable)
-> Handler 1 (priority 100): Validates data
-> Handler 2 (priority 500): Modifies entity
-> Handler 3 (priority 1000): Business logic
-> If not cancelled, performs the operation
-> Publishes "Saved/Created" notification (read-only)
-> Handler 4 (priority 1000): Updates cache
-> Handler 5 (priority 2000): Sends email
-> Handler 6 (priority 2200): Fires webhook
Basic Example¶
using Merchello.Core.Notifications;
using Merchello.Core.Notifications.Order;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Events;
[NotificationHandlerPriority(2000)] // Runs after core business logic
public class OrderCreatedSyncHandler(
ILogger<OrderCreatedSyncHandler> logger)
: INotificationAsyncHandler<OrderCreatedNotification>
{
public async Task HandleAsync(
OrderCreatedNotification notification,
CancellationToken cancellationToken)
{
try
{
// Sync the new order to your ERP system
logger.LogInformation(
"Order {OrderId} created, syncing to ERP",
notification.Entity.Id);
await SyncToErp(notification.Entity, cancellationToken);
}
catch (Exception ex)
{
// Always catch and log -- never rethrow from notification handlers
logger.LogError(ex,
"Failed to sync order {OrderId} to ERP",
notification.Entity.Id);
}
}
}
Registering Your Handler¶
Unlike providers (which are auto-discovered), notification handlers must be explicitly registered against the Umbraco notification pipeline. This is standard Umbraco v17 behavior. See src/Merchello/Startup.cs for how Merchello's own handlers are wired.
Register handlers in your Startup.cs or a composer:
// In AddMerchello or your own startup code
builder.AddNotificationAsyncHandler<OrderCreatedNotification, OrderCreatedSyncHandler>();
Or using an Umbraco composer:
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
public class MyNotificationComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.AddNotificationAsyncHandler<OrderCreatedNotification, OrderCreatedSyncHandler>();
builder.AddNotificationAsyncHandler<PaymentCreatedNotification, PaymentCreatedSyncHandler>();
}
}
Warning: Use constructor injection only for dependencies. Setter injection and service locator are not supported. The handler class is instantiated per-publish by Umbraco's DI container, so any
Scopedservice is safe to inject.
Handler Priorities¶
The [NotificationHandlerPriority] attribute controls execution order. Lower values run first. The default priority is 1000.
| Range | Purpose | Examples |
|---|---|---|
| 100-500 | Validation | Data validation, precondition checks |
| 1000 | Business logic | Core processing (default) |
| 1500-1900 | Post-processing | Cache updates, secondary calculations |
| 2000 | Audit | Audit logging, history recording |
| 2100 | Sending notification emails | |
| 2200 | Webhooks | Firing outbound webhooks |
| 3000 | Protocol | Commerce protocol (UCP) handlers |
[NotificationHandlerPriority(100)] // Runs first -- validation
public class ValidateOrderHandler : INotificationAsyncHandler<OrderSavingNotification> { }
[NotificationHandlerPriority(2000)] // Runs late -- audit
public class AuditOrderHandler : INotificationAsyncHandler<OrderSavedNotification> { }
Canceling Operations¶
"Before" notifications (inheriting from MerchelloCancelableNotification<T>) can be cancelled:
[NotificationHandlerPriority(100)]
public class ValidateProductHandler
: INotificationAsyncHandler<ProductCreatingNotification>
{
public Task HandleAsync(
ProductCreatingNotification notification,
CancellationToken cancellationToken)
{
var product = notification.Entity;
// Validate business rules
if (product.Price < 0)
{
notification.CancelOperation("Product price cannot be negative");
// The create operation will be aborted
}
return Task.CompletedTask;
}
}
Modifying Entities in "Before" Handlers¶
"Before" notifications give you access to the entity before it's saved, so you can modify it:
[NotificationHandlerPriority(500)]
public class EnrichProductHandler
: INotificationAsyncHandler<ProductSavingNotification>
{
public Task HandleAsync(
ProductSavingNotification notification,
CancellationToken cancellationToken)
{
var product = notification.Entity;
// Auto-generate SKU if empty
if (string.IsNullOrWhiteSpace(product.Sku))
{
product.Sku = $"PROD-{Guid.NewGuid():N}"[..12].ToUpperInvariant();
}
return Task.CompletedTask;
}
}
Sharing State Between Handlers¶
The State dictionary on notifications lets you pass data between handlers of the same notification:
// Before handler: capture original state
[NotificationHandlerPriority(100)]
public class CaptureOriginalPriceHandler
: INotificationAsyncHandler<ProductSavingNotification>
{
public Task HandleAsync(ProductSavingNotification notification, CancellationToken ct)
{
notification.State["originalPrice"] = notification.Entity.Price;
return Task.CompletedTask;
}
}
// After handler: compare with original
[NotificationHandlerPriority(2000)]
public class PriceChangeAuditHandler
: INotificationAsyncHandler<ProductSavedNotification>
{
public Task HandleAsync(ProductSavedNotification notification, CancellationToken ct)
{
if (notification.State.TryGetValue("originalPrice", out var originalPrice))
{
var newPrice = notification.Entity.Price;
if ((decimal)originalPrice! != newPrice)
{
// Log the price change
}
}
return Task.CompletedTask;
}
}
Available Notifications¶
Here's a sampling of the notifications you can handle:
Products¶
ProductCreatingNotification/ProductCreatedNotificationProductSavingNotification/ProductSavedNotificationProductDeletingNotification/ProductDeletedNotification
Orders¶
OrderCreatedNotificationOrderSavingNotification/OrderSavedNotificationOrderStatusChangingNotification/OrderStatusChangedNotification
Payments¶
PaymentCreatedNotificationPaymentRefundingNotification/PaymentRefundedNotification
Invoices¶
InvoiceSavingNotification/InvoiceSavedNotificationInvoiceDeletingNotification/InvoiceDeletedNotificationInvoiceCancellingNotification/InvoiceCancelledNotification
Shipments¶
ShipmentCreatingNotification/ShipmentCreatedNotificationShipmentSavingNotification/ShipmentSavedNotificationShipmentStatusChangingNotification/ShipmentStatusChangedNotification
Basket¶
BasketItemAddingNotification/BasketItemAddedNotificationBasketItemRemovingNotification/BasketItemRemovedNotificationBasketItemQuantityChangingNotification/BasketItemQuantityChangedNotificationBasketClearingNotification/BasketClearedNotification
Checkout¶
CheckoutAddressesChangingNotification/CheckoutAddressesChangedNotificationDiscountCodeAppliedNotification/DiscountCodeRemovedNotificationCheckoutRecoveryConvertedNotification
Customers¶
CustomerCreatedNotificationCustomerSavingNotification/CustomerSavedNotificationCustomerDeletingNotification/CustomerDeletedNotification
Order Grouping¶
OrderGroupingModifyingNotification(cancelable)OrderGroupingNotification(read-only)
Inventory¶
LowStockNotification
Discounts¶
DiscountCreatingNotification/DiscountCreatedNotificationDiscountSavingNotification/DiscountSavedNotificationDiscountStatusChangingNotification/DiscountStatusChangedNotification
Fault Tolerance Rules¶
This is critical: Notification handlers must be fault-tolerant.
- Always wrap handler logic in try/catch. An unhandled exception in one handler can break the entire notification chain.
- Log errors but don't rethrow. Let other handlers continue executing.
- Don't assume external services are available. API calls, database queries, and file operations can fail.
public async Task HandleAsync(OrderCreatedNotification notification, CancellationToken ct)
{
try
{
await DoWork(notification.Entity, ct);
}
catch (Exception ex)
{
// Log and continue -- don't break the chain
_logger.LogError(ex, "Handler failed for order {OrderId}", notification.Entity.Id);
}
}
Warning: The only exception to the "don't rethrow" rule is validation handlers in "Before" notifications. If validation fails, use
notification.CancelOperation("reason")instead of throwing.
Reference¶
- Priority attribute:
NotificationHandlerPriorityAttribute.cs(default1000, lower runs first). - Cancelable base class:
MerchelloCancelableNotification.cs(exposesEntity,CancelOperation(reason),CancelReason). - Built-in registrations:
src/Merchello/Startup.cs(search forAddNotificationAsyncHandler). - Real-world handler examples: built-in handlers such as
InvoiceTimelineHandlerandWebhookNotificationHandlerwired fromStartup.cs, plus theMerchello.ActionExamplesproject for action-driven handlers.