For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/aem/backend/event-listener-and-handler.md.
AEM provides two event systems for reacting to changes in the repository and the platform. Understanding when to use which is critical for building robust, performant integrations.
Modern, Sling-level resource change events (preferred over JCR observation)
All three approaches ultimately receive their events from JCR observation, but the abstraction level and API differ. For most AEM projects, OSGi EventHandler and Sling ResourceChangeListener are the right choices.
The OSGi Event Admin service delivers events on topics (string identifiers). You register an EventHandler that listens to one or more topics, and the framework calls your handleEvent() method whenever an event matching those topics is published.
The ResourceChangeListener is the modern, recommended way to observe resource changes in Sling. It replaces the older OSGi SlingConstants.TOPIC_RESOURCE_* event topics and provides a cleaner API with explicit change types and path restrictions.
package com.myproject.core.listeners;import org.apache.sling.api.resource.observation.ResourceChange;import org.apache.sling.api.resource.observation.ResourceChangeListener;import org.osgi.service.component.annotations.Component;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import java.util.List;@Component( service = ResourceChangeListener.class, immediate = true, property = { // Only listen to paths under /content/myproject ResourceChangeListener.PATHS + "=/content/myproject", // Change types to listen for (ADDED, REMOVED, CHANGED, PROVIDER_ADDED, PROVIDER_REMOVED) ResourceChangeListener.CHANGES + "=ADDED", ResourceChangeListener.CHANGES + "=CHANGED", ResourceChangeListener.CHANGES + "=REMOVED" })public class ContentChangeListener implements ResourceChangeListener { private static final Logger LOG = LoggerFactory.getLogger(ContentChangeListener.class); @Override public void onChange(List<ResourceChange> changes) { for (ResourceChange change : changes) { LOG.info("Resource {} was {} by {}", change.getPath(), change.getType(), change.getUserId()); } }}
Whether the change originated from a different cluster node
getAddedPropertyNames()
Set<String>
Properties that were added
getChangedPropertyNames()
Set<String>
Properties that changed
getRemovedPropertyNames()
Set<String>
Properties that were removed
[!-success] -success
tip
Prefer ResourceChangeListener over the older SlingConstants.TOPIC_RESOURCE_* OSGi events for resource observation. It provides a richer API, better performance, and clearer semantics.
The lowest-level approach. Use this only when you need fine-grained control over JCR events that the Sling APIs don't expose (e.g., filtering by node type, or listening for property-level changes on specific node types).
Combine multiple types using bitwise OR: Event.NODE_ADDED | Event.PROPERTY_CHANGED
[!-warning] -warning
warning
JCR observation requires an open JCR session for the lifetime of the listener. You must manage this session carefully - open it in @Activate and close it in @Deactivate. Use a service user for this session, never an admin session.
A production-quality example that triggers an external endpoint (e.g., a CI/CD pipeline) when content is replicated. Uses OSGi configuration for the endpoint URL and path filtering:
package com.myproject.core.listeners;import com.day.cq.replication.ReplicationAction;import com.day.cq.replication.ReplicationActionType;import org.apache.http.client.config.RequestConfig;import org.apache.http.client.methods.CloseableHttpResponse;import org.apache.http.client.methods.HttpPost;import org.apache.http.entity.StringEntity;import org.apache.http.impl.client.CloseableHttpClient;import org.apache.http.impl.client.HttpClientBuilder;import org.osgi.service.component.annotations.*;import org.osgi.service.component.propertytypes.ServiceDescription;import org.osgi.service.event.Event;import org.osgi.service.event.EventConstants;import org.osgi.service.event.EventHandler;import org.osgi.service.metatype.annotations.*;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import java.io.IOException;import java.nio.charset.StandardCharsets;import java.util.Arrays;@Component( service = EventHandler.class, immediate = true, property = { EventConstants.EVENT_TOPIC + "=" + ReplicationAction.EVENT_TOPIC })@ServiceDescription("Triggers a webhook when content is replicated to publish")@Designate(ocd = ReplicationWebhookHandler.Config.class, factory = true)public class ReplicationWebhookHandler implements EventHandler { private static final Logger LOG = LoggerFactory.getLogger(ReplicationWebhookHandler.class); @ObjectClassDefinition( name = "Replication Webhook Handler", description = "Triggers an external webhook on content replication events" ) @interface Config { @AttributeDefinition( name = "Enabled", description = "Enable or disable this handler", type = AttributeType.BOOLEAN ) boolean enabled() default false; @AttributeDefinition( name = "Webhook URL", description = "The external endpoint to call on replication" ) String webhookUrl() default ""; @AttributeDefinition( name = "Monitored paths", description = "Only trigger for replication events under these paths" ) String[] monitoredPaths() default {"/content/myproject"}; @AttributeDefinition( name = "Connection timeout (ms)", description = "HTTP connection timeout in milliseconds", type = AttributeType.INTEGER ) int connectionTimeout() default 5000; @AttributeDefinition( name = "Socket timeout (ms)", description = "HTTP socket/read timeout in milliseconds", type = AttributeType.INTEGER ) int socketTimeout() default 10000; } private boolean enabled; private String webhookUrl; private String[] monitoredPaths; private CloseableHttpClient httpClient; @Activate @Modified protected void activate(Config config) { enabled = config.enabled(); webhookUrl = config.webhookUrl(); monitoredPaths = config.monitoredPaths(); RequestConfig requestConfig = RequestConfig.custom() .setConnectTimeout(config.connectionTimeout()) .setSocketTimeout(config.socketTimeout()) .build(); httpClient = HttpClientBuilder.create() .setDefaultRequestConfig(requestConfig) .build(); LOG.info("ReplicationWebhookHandler {} (url={}, paths={})", enabled ? "enabled" : "disabled", webhookUrl, Arrays.toString(monitoredPaths)); } @Deactivate protected void deactivate() { if (httpClient != null) { try { httpClient.close(); } catch (IOException e) { LOG.warn("Error closing HTTP client", e); } } } @Override public void handleEvent(Event event) { if (!enabled) { return; } ReplicationAction action = ReplicationAction.fromEvent(event); if (action == null) { return; } String path = action.getPath(); ReplicationActionType type = action.getType(); // Only trigger for Activate/Deactivate on monitored paths if ((type == ReplicationActionType.ACTIVATE || type == ReplicationActionType.DEACTIVATE) && isMonitoredPath(path)) { triggerWebhook(path, type.getName(), action.getUserId()); } } private boolean isMonitoredPath(String path) { if (monitoredPaths == null || path == null) { return false; } return Arrays.stream(monitoredPaths).anyMatch(path::startsWith); } private void triggerWebhook(String path, String action, String userId) { String payload = String.format( "{\"path\":\"%s\",\"action\":\"%s\",\"user\":\"%s\"}", path, action, userId ); HttpPost post = new HttpPost(webhookUrl); post.setHeader("Content-Type", "application/json"); post.setEntity(new StringEntity(payload, StandardCharsets.UTF_8)); try (CloseableHttpResponse response = httpClient.execute(post)) { int status = response.getStatusLine().getStatusCode(); if (status >= 200 && status < 300) { LOG.info("Webhook triggered successfully for {} ({})", path, action); } else { LOG.warn("Webhook returned status {} for {} ({})", status, path, action); } } catch (IOException e) { LOG.error("Failed to trigger webhook for {} ({})", path, action, e); } }}
#Sling ResourceChangeListener with external change detection
Detect changes that originate from other cluster nodes (e.g., replication from Author to Publish):
package com.myproject.core.listeners;import org.apache.sling.api.resource.observation.ExternalResourceChangeListener;import org.apache.sling.api.resource.observation.ResourceChange;import org.apache.sling.api.resource.observation.ResourceChangeListener;import org.osgi.service.component.annotations.Component;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import java.util.List;@Component( service = ResourceChangeListener.class, immediate = true, property = { ResourceChangeListener.PATHS + "=/content/myproject", ResourceChangeListener.CHANGES + "=CHANGED" })public class ExternalChangeListener implements ResourceChangeListener, ExternalResourceChangeListener { private static final Logger LOG = LoggerFactory.getLogger(ExternalChangeListener.class); @Override public void onChange(List<ResourceChange> changes) { for (ResourceChange change : changes) { if (change.isExternal()) { LOG.info("External change detected: {} (from another cluster node)", change.getPath()); // Invalidate local caches, update search index, etc. } else { LOG.debug("Local change: {}", change.getPath()); } } }}
[!-info] -info
info
Implement ExternalResourceChangeListener in addition to ResourceChangeListener to also receive events originating from other cluster nodes. Without it, you only see local changes.
OSGi events are fire-and-forget - if the instance crashes or the handler throws an exception, the event is lost. For operations that must complete (sending an email, updating an external system, writing to the JCR), use Sling Jobs instead.
Retry behaviour is not configured on the consumer - it lives on the queue that processes the topic. Deploy a QueueConfiguration factory config that matches your topic:
handleEvent() runs on the OSGi Event Admin thread pool. Blocking it delays all other event processing in the system:
// ❌ Bad - blocking HTTP call in the event handler thread@Overridepublic void handleEvent(Event event) { httpClient.execute(new HttpGet(url)); // blocks the thread}// ✅ Good - delegate heavy work to a Sling Job@Overridepublic void handleEvent(Event event) { Map<String, Object> props = new HashMap<>(); props.put("path", event.getProperty(SlingConstants.PROPERTY_PATH)); jobManager.addJob("com/myproject/job/HEAVY_WORK", props);}
#Use factory configurations for multi-tenant setups
Mark your handler with factory = true in @Designate to allow multiple instances with different configurations (e.g., different webhook URLs for different sites):