> ## Documentation Index
> Fetch the complete documentation index at: https://moengage.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Syncing Experience Fragments as Content Blocks

> Sync Adobe Experience Manager Experience Fragments to MoEngage as Content Blocks using a custom AEM workflow process step.

## Introduction

[Adobe Experience Manager (AEM)](https://business.adobe.com/products/experience-manager/adobe-experience-manager.html) is an enterprise content management platform that enables organizations to create, manage, and deliver personalized digital experiences across multiple channels.

## AEM and MoEngage

The integration connects AEM to MoEngage via a custom workflow process, allowing real-time content synchronization from AEM to MoEngage Content Blocks. The integration enables marketing teams to manage content centrally in AEM while leveraging it in MoEngage campaigns for personalized customer engagement.

### Use Cases

Integrating Adobe Experience Manager with MoEngage helps you with the following use cases:

* **Campaign Content Management**: Marketing teams create promotional content in AEM. When published, it automatically syncs to MoEngage Content Blocks, making it immediately available for email campaigns, push notifications, and in-app messages.
* **Personalized Experience Delivery**: Create localized Experience Fragments in AEM with personalization tokens. These automatically sync with MoEngage, utilizing converted tokens to enable personalized campaigns based on user attributes.
* **Dynamic Content Updates**: Update product descriptions, offers, or messaging in AEM. Changes automatically propagate to all active MoEngage campaigns using those Content Blocks, ensuring consistency across channels.
* **Multi-Channel Content Reuse**: Author content once in AEM and use it across multiple MoEngage campaigns (email, push, in-app) without duplication or manual copying.

## Integration

### Approach

Implement a custom integration within AEM and use [custom workflows](https://experienceleague.adobe.com/en/docs/experience-manager-65/content/implementing/developing/extending-aem/extending-workflows/workflows-models#creating-a-new-workflow) to an API call to MoEngage Content Block APIs whenever relevant content is created, updated, or published. This ensures MoEngage has access to the latest AEM content.

### Flow diagram

<img src="https://mintcdn.com/moengage/11KR3mwxT6FlowrU/images/partner_45033003131156.png?fit=max&auto=format&n=11KR3mwxT6FlowrU&q=85&s=2ad8d7acd1c621fb66529a9300cc3c57" alt="" width="1600" height="899" data-path="images/partner_45033003131156.png" />

We will develop a custom AEM Workflow Process Step that connects to the MoEngage APIs.

#### Supported Content Types

The following table outlines the content types that can be synced from AEM to MoEngage:

| Content Type             | Description                                            | MoEngage Sync Type        |
| ------------------------ | ------------------------------------------------------ | ------------------------- |
| Experience Fragments     | Reusable content components with HTML, text, and media | Content Block (HTML)      |
| Content Fragments        | Structured content with predefined models and elements | Content Block (HTML/Text) |
| DAM Assets (.html, .txt) | Text or HTML files stored in Digital Asset Manager     | Content Block (HTML/Text) |

#### Supported Actions

| Actions in AEM | Results in MoEngage                             |
| -------------- | ----------------------------------------------- |
| Publish        | Creates/Updates Content Blocks (status: ACTIVE) |

<Info>
  **Prerequisites**

  Before you begin, ensure you have the following requirements:

  * Active Adobe Experience Manager instance (AEM 6.5 or AEM as a Cloud Service).
  * AEM Author and Publish environments configured.
  * Maven project setup with appropriate permissions to deploy OSGi bundles.
  * MoEngage workspace with API access enabled.
  * MoEngage **Workspace ID** (API Key) and **Data API Secret**.
  * Java development environment with AEM SDK.
  * Understanding of AEM Workflows and OSGi services.
</Info>

### Step 1: Get your MoEngage API Credentials

To find your credentials, perform the following steps:

1. On the left navigation menu in the MoEngage UI, click **Settings** > **Account** > **APIs**.
2. On the APIs page, copy the following credentials:
   1. **Workspace ID** (**earlier app id**): You can use this as API Key (MOE-APPKEY header).
   2. **Campaign API Secret**: You can use this as Basic Authentication. <img src="https://mintcdn.com/moengage/11KR3mwxT6FlowrU/images/partner_44996805286548.png?fit=max&auto=format&n=11KR3mwxT6FlowrU&q=85&s=55c88c0e3c9f996664950ced234bc404" width="2394" height="1370" data-path="images/partner_44996805286548.png" />
3. Find your data center code from your dashboard's URL. For more information, refer [Data Centers in MoEngage](/user-guide/data/key-concepts/data-centers-in-moengage#list-of-data-centers).

### Step 2: Update Maven Project Dependencies

To update Maven project dependencies, perform the following steps:

1. Add the `org.json` library to your AEM project's `core/pom.xml` file to ensure proper JSON payload construction.
2. Locate the `<dependencies>` section in `core/pom.xml`.
3. Add the following dependency:

```xml theme={null}
<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
    <version>20231013</version>
    <scope>compile</scope>
</dependency>
```

**Why org.json?**

*The `org.json` library automatically escapes special characters (quotes, newlines) in HTML content, preventing JSON payload errors. Manual string concatenation can break the MoEngage API call if content contains these characters.*

### Step 3: Setting up the Custom Workflow

You need to create an OSGi service that handles content sync to MoEngage. The service implements the `WorkflowProcess` interface and registers as a workflow step. You can refer to the sample shared below or create one of your own.

#### Sample production ready implementation

This implementation covers the full lifecycle management:

* Handles Experience Fragments with variations.
* Supports Content Fragments and DAM Assets.
* Debug mode with webhook support for testing.
* Improved HTML cleaning and link externalization.

You can just copy this code and add it to a file path - `core/src/main/java/com/[your-company]/integration/workflow/MoEngageContentSyncStep.java`

```java MoEngageContentSyncStep.java wrap theme={null}
package com.moengage.integration.workflow;

import com.adobe.granite.workflow.WorkflowException;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.WorkItem;
import com.adobe.granite.workflow.exec.WorkflowProcess;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.engine.SlingRequestProcessor;
import com.day.cq.contentsync.handler.util.RequestResponseFactory;
import com.day.cq.dam.api.Asset;
import com.day.cq.dam.api.Rendition;
import com.day.cq.wcm.api.Page;
import com.adobe.cq.dam.cfm.ContentFragment;
import com.adobe.cq.dam.cfm.ContentElement;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.json.JSONObject;
import org.json.JSONArray;

@Component(
    service = WorkflowProcess.class,
    property = { "process.label=MoEngage Content Sync" }
)
public class MoEngageContentSyncStep implements WorkflowProcess {

    private static final Logger log = LoggerFactory.getLogger(MoEngageContentSyncStep.class);
    private static final String API_BASE_TEMPLATE = "https://api-%s.moengage.com/v1/external/campaigns/content-blocks";
    private static final int BUFFER_DELAY_MS = 500;

    @Reference private RequestResponseFactory requestResponseFactory;
    @Reference private SlingRequestProcessor requestProcessor;

    @Override
    public void execute(WorkItem workItem, WorkflowSession workflowSession, MetaDataMap args)
            throws WorkflowException {
        ResourceResolver resourceResolver = null;
        try {
            String processArgs = args.get("PROCESS_ARGS", "string");
            Map<String, String> config = parseProcessArgs(processArgs);
            String apiKey = config.get("moengage.api.key");
            String apiSecret = config.get("moengage.api.secret");
            String dataCenter = config.get("moengage.datacenter");
            String publishUrl = config.get("aem.publish.url");
            String targetStatus = config.get("moengage.status");
            boolean debugMode = Boolean.parseBoolean(config.get("moengage.debug"));
            String debugUrl = config.get("moengage.debug.url");

            if (apiKey == null || apiKey.trim().isEmpty()) {
                log.warn("MoEngage API key not configured, skipping");
                return;
            }
            if (apiSecret == null) apiSecret = "";
            if (dataCenter == null || dataCenter.trim().isEmpty()) {
                log.warn("MoEngage datacenter not configured, skipping");
                return;
            }
            if (targetStatus == null) targetStatus = "ACTIVE";
            targetStatus = targetStatus.toUpperCase();

            if (publishUrl != null && publishUrl.endsWith("/")) {
                publishUrl = publishUrl.substring(0, publishUrl.length() - 1);
            }

            String basicAuthHeader = "Basic " + Base64.getEncoder()
                .encodeToString((apiKey.trim() + ":" + apiSecret.trim())
                .getBytes(StandardCharsets.UTF_8));
            String baseUrl = String.format(API_BASE_TEMPLATE, dataCenter.trim());
            String searchUrl = baseUrl + "/search";

            resourceResolver = workflowSession.adaptTo(ResourceResolver.class);
            if (resourceResolver == null) {
                log.error("Could not obtain ResourceResolver");
                return;
            }

            String payloadPath = getCleanPath(workItem.getWorkflowData()
                .getPayload().toString());
            Resource payloadResource = resourceResolver.getResource(payloadPath);
            if (payloadResource == null) {
                log.warn("Resource not found: {}", payloadPath);
                return;
            }

            String initiator = workItem.getWorkflow().getInitiator();

            if (payloadPath.contains("/experience-fragments/")) {
                processExperienceFragments(payloadResource, resourceResolver, publishUrl,
                    baseUrl, searchUrl, apiKey.trim(), basicAuthHeader, targetStatus,
                    initiator, debugMode, debugUrl);
            } else if (payloadPath.contains("/content/dam/")) {
                processDAMContent(payloadResource, resourceResolver, publishUrl,
                    baseUrl, searchUrl, apiKey.trim(), basicAuthHeader, targetStatus,
                    initiator, debugMode, debugUrl);
            } else {
                log.info("Unsupported content type at path: {}", payloadPath);
            }
        } catch (Exception e) {
            log.error("MoEngage Sync Failed", e);
        }
    }

    private void processExperienceFragments(Resource resource, ResourceResolver resolver,
            String publishUrl, String baseUrl, String searchUrl, String apiKey,
            String basicAuthHeader, String targetStatus, String initiator,
            boolean debugMode, String debugUrl) throws Exception {
        List<XFItem> items = collectExperienceFragments(resource, resolver);
        if (items.isEmpty()) {
            log.info("No experience fragments found");
            return;
        }
        for (int i = 0; i < items.size(); i++) {
            XFItem item = items.get(i);
            try {
                processExperienceFragment(item, resolver, publishUrl, baseUrl,
                    searchUrl, apiKey, basicAuthHeader, targetStatus, initiator,
                    debugMode, debugUrl);
                if (i < items.size() - 1) {
                    Thread.sleep(BUFFER_DELAY_MS);
                }
            } catch (Exception e) {
                log.error("Failed to process XF: {}", item.path, e);
            }
        }
    }

    private List<XFItem> collectExperienceFragments(Resource resource,
            ResourceResolver resolver) {
        List<XFItem> items = new ArrayList<>();
        if (resource == null) return items;
        Page page = resource.adaptTo(Page.class);
        if (page != null) {
            Iterator<Page> children = page.listChildren();
            boolean hasChildren = children.hasNext();
            if (hasChildren) {
                children = page.listChildren();
                while (children.hasNext()) {
                    Page variation = children.next();
                    items.add(new XFItem(variation.getPath(), page, variation));
                }
            } else {
                Page parent = page.getParent();
                if (parent != null && parent.listChildren().hasNext()) {
                    items.add(new XFItem(page.getPath(), parent, page));
                }
            }
        } else {
            Iterator<Resource> children = resource.listChildren();
            while (children.hasNext()) {
                items.addAll(collectExperienceFragments(children.next(), resolver));
            }
        }
        return items;
    }

    private void processExperienceFragment(XFItem item, ResourceResolver resolver,
            String publishUrl, String baseUrl, String searchUrl, String apiKey,
            String basicAuthHeader, String targetStatus, String initiator,
            boolean debugMode, String debugUrl) throws IOException {
        String itemName = generatePathBasedName(item.path);
        String rawContent = renderPageHtml(item.variation.getPath(), resolver);
        rawContent = cleanContent(rawContent);
        if (publishUrl != null && !publishUrl.isEmpty()) {
            rawContent = externalizeLinks(rawContent, publishUrl);
        }
        if (rawContent.isEmpty()) {
            log.info("Empty content for XF: {}, skipping", item.path);
            return;
        }
        itemName = sanitizeName(itemName);
        log.info("Processing XF: {} -> {}", item.path, itemName);
        String existingId = searchContentBlockId(searchUrl, apiKey, basicAuthHeader,
            itemName, debugMode, debugUrl);
        JSONObject payload = new JSONObject();
        if (existingId != null) {
            payload.put("id", existingId);
            payload.put("raw_content", rawContent);
            payload.put("updated_by", initiator);
            payload.put("status", targetStatus);
            payload.put("description", "Sync from AEM: " + item.path);
            sendRequest(baseUrl, "PUT", apiKey, basicAuthHeader,
                payload.toString(), debugMode, debugUrl);
            log.info("Updated content block: {}", itemName);
        } else {
            payload.put("name", itemName);
            payload.put("label", itemName);
            payload.put("content_type", "HTML");
            payload.put("raw_content", rawContent);
            payload.put("status", targetStatus);
            payload.put("created_by", initiator);
            payload.put("images_used", new JSONArray());
            payload.put("content_block_used", new JSONArray());
            payload.put("tag_ids", new JSONArray());
            sendRequest(baseUrl, "POST", apiKey, basicAuthHeader,
                payload.toString(), debugMode, debugUrl);
            log.info("Created content block: {}", itemName);
        }
    }

    private void processDAMContent(Resource resource, ResourceResolver resolver,
            String publishUrl, String baseUrl, String searchUrl, String apiKey,
            String basicAuthHeader, String targetStatus, String initiator,
            boolean debugMode, String debugUrl) throws IOException {
        String itemName = generatePathBasedName(resource.getPath());
        String rawContent = "";
        String contentType = "HTML";
        ContentFragment cf = resource.adaptTo(ContentFragment.class);
        if (cf != null) {
            itemName = cf.getName();
            rawContent = extractContentFromFragment(cf);
        } else {
            Asset asset = resource.adaptTo(Asset.class);
            if (asset != null) {
                itemName = asset.getName();
                String mime = asset.getMimeType();
                contentType = (mime != null && mime.contains("text/plain"))
                    ? "Plain Text" : "HTML";
                Rendition original = asset.getOriginal();
                if (original != null) {
                    try (InputStream is = original.getStream()) {
                        rawContent = new BufferedReader(
                            new InputStreamReader(is, StandardCharsets.UTF_8))
                            .lines()
                            .collect(Collectors.joining("\n"));
                    }
                }
            }
        }
        if (rawContent.isEmpty()) {
            log.info("Empty content, skipping: {}", resource.getPath());
            return;
        }
        itemName = sanitizeName(itemName);
        log.info("Processing DAM content: {} -> {}", resource.getPath(), itemName);
        String existingId = searchContentBlockId(searchUrl, apiKey, basicAuthHeader,
            itemName, debugMode, debugUrl);
        JSONObject payload = new JSONObject();
        if (existingId != null) {
            payload.put("id", existingId);
            payload.put("raw_content", rawContent);
            payload.put("updated_by", initiator);
            payload.put("status", targetStatus);
            sendRequest(baseUrl, "PUT", apiKey, basicAuthHeader,
                payload.toString(), debugMode, debugUrl);
            log.info("Updated content block: {}", itemName);
        } else {
            payload.put("name", itemName);
            payload.put("label", itemName);
            payload.put("content_type", contentType);
            payload.put("raw_content", rawContent);
            payload.put("status", targetStatus);
            payload.put("created_by", initiator);
            payload.put("images_used", new JSONArray());
            payload.put("content_block_used", new JSONArray());
            payload.put("tag_ids", new JSONArray());
            sendRequest(baseUrl, "POST", apiKey, basicAuthHeader,
                payload.toString(), debugMode, debugUrl);
            log.info("Created content block: {}", itemName);
        }
    }

    private String extractContentFromFragment(ContentFragment cf) {
        if (cf.hasElement("htmlContent")) {
            return cf.getElement("htmlContent").getContent();
        }
        if (cf.hasElement("master")) {
            return cf.getElement("master").getContent();
        }
        if (cf.hasElement("body")) {
            return cf.getElement("body").getContent();
        }
        StringBuilder sb = new StringBuilder();
        Iterator<ContentElement> elements = cf.getElements();
        while (elements.hasNext()) {
            ContentElement el = elements.next();
            if (!isMetadataField(el.getName())) {
                sb.append(el.getContent()).append("\n");
            }
        }
        return sb.toString().trim();
    }

    private boolean isMetadataField(String name) {
        return name.matches("(?i).*(metadata|title|segment|offerCode).*");
    }

    private String generatePathBasedName(String path) {
        if (path == null || path.isEmpty()) return "content_block";
        String temp = path
            .replace("/content/experience-fragments/", "")
            .replace("/content/dam/", "")
            .replace("/content/", "");
        if (temp.contains("/jcr:content")) {
            temp = temp.substring(0, temp.indexOf("/jcr:content"));
        }
        return temp.replaceAll("/", "_")
                   .replaceAll("-", "_")
                   .replaceAll("[^a-zA-Z0-9_]", "")
                   .replaceAll("_+", "_")
                   .replaceAll("^_|_$", "")
                   .toLowerCase();
    }

    private String sanitizeName(String name) {
        if (name == null || name.isEmpty()) return "content_block";
        return name.replaceAll("[^a-zA-Z0-9_]", "_")
                   .replaceAll("_+", "_")
                   .replaceAll("^_|_$", "")
                   .toLowerCase();
    }

    private String cleanContent(String html) {
        if (html == null || html.isEmpty()) {
            return "";
        }
        StringBuilder result = new StringBuilder();
        Pattern linkPattern = Pattern.compile("<link[^>]*rel=[\"']stylesheet[\"'][^>]*>", Pattern.CASE_INSENSITIVE);
        Matcher linkMatcher = linkPattern.matcher(html);
        while (linkMatcher.find()) {
            result.append(linkMatcher.group()).append("\n");
        }
        Pattern stylePattern = Pattern.compile("<style[^>]*>.*?</style>", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
        Matcher styleMatcher = stylePattern.matcher(html);
        while (styleMatcher.find()) {
            result.append(styleMatcher.group()).append("\n");
        }
        String bodyContent = html;
        Pattern bodyPattern = Pattern.compile("<body[^>]*>(.*?)</body>", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
        Matcher bodyMatcher = bodyPattern.matcher(html);
        if (bodyMatcher.find()) {
            bodyContent = bodyMatcher.group(1);
        } else {
            bodyContent = html
                .replaceAll("(?is)<!DOCTYPE[^>]*>", "")
                .replaceAll("(?is)<html[^>]*>", "")
                .replaceAll("(?is)</html>", "")
                .replaceAll("(?is)<head[^>]*>.*?</head>", "")
                .replaceAll("(?is)<body[^>]*>", "")
                .replaceAll("(?is)</body>", "");
        }
        bodyContent = cleanAEMArtifacts(bodyContent);
        result.append(bodyContent);
        return result.toString().trim();
    }

    private String cleanAEMArtifacts(String content) {
        if (content == null) return "";
        return content
            .replaceAll("(?is)<script[^>]*src=\"/etc\\.clientlibs/[^\"]*\"[^>]*></script>", "")
            .replaceAll("\\s*data-cmp-data-layer=\"[^\"]*\"", "")
            .replaceAll("\\s*data-cmp-clickable", "")
            .replaceAll("(?is)<div[^>]*>\\s*</div>", "")
            .replaceAll("\\n\\s*\\n\\s*\\n", "\n\n")
            .trim();
    }

    private String renderPageHtml(String path, ResourceResolver resolver) {
        try {
            if (requestProcessor != null && requestResponseFactory != null) {
                HttpServletRequest req = requestResponseFactory
                    .createRequest("GET", path + ".html");
                ByteArrayOutputStream out = new ByteArrayOutputStream();
                HttpServletResponse resp = requestResponseFactory.createResponse(out);
                requestProcessor.processRequest(req, resp, resolver);
                return out.toString(StandardCharsets.UTF_8.name());
            }
        } catch (Exception e) {
            log.error("Failed to render: {}", path, e);
        }
        return "";
    }

    private String externalizeLinks(String content, String domain) {
        if (content == null || domain == null) return content;
        return content
            .replaceAll("(src|href)=\"(/content/[^\"]+)\"", "$1=\"" + domain + "$2\"")
            .replaceAll("(src|href)=\"(/etc\\.clientlibs/[^\"]+)\"",
                "$1=\"" + domain + "$2\"");
    }

    private String searchContentBlockId(String url, String apiKey, String auth,
            String name, boolean dbg, String dbgUrl) {
        try {
            JSONObject body = new JSONObject();
            JSONObject filter = new JSONObject();
            filter.put("search_text", name);
            body.put("filters", filter);
            APIResponse resp = sendRequest(url, "POST", apiKey, auth,
                body.toString(), dbg, dbgUrl);
            if (resp.statusCode >= 200 && resp.statusCode < 300) {
                JSONObject json = new JSONObject(resp.body);
                if (json.optInt("count") > 0) {
                    JSONArray data = json.getJSONArray("data");
                    for (int i = 0; i < data.length(); i++) {
                        JSONObject item = data.getJSONObject(i);
                        if (item.getString("name").equals(name)) {
                            return item.getString("id");
                        }
                    }
                }
            }
        } catch (Exception e) {
            log.debug("Search failed: {}", name, e);
        }
        return null;
    }

    private APIResponse sendRequest(String endpoint, String method, String apiKey,
            String auth, String jsonPayload, boolean dbg, String dbgUrl)
            throws IOException {
        if (dbg && dbgUrl != null && !dbgUrl.isEmpty()) {
            try {
                sendDebug(jsonPayload, method, dbgUrl);
            } catch (Exception e) {
                log.debug("Debug send failed", e);
            }
        }
        URL url = new URL(endpoint.trim());
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod(method);
        conn.setRequestProperty("Content-Type", "application/json");
        conn.setRequestProperty("MOE-APPKEY", apiKey);
        conn.setRequestProperty("Authorization", auth);
        conn.setConnectTimeout(30000);
        conn.setReadTimeout(30000);
        conn.setDoOutput(true);
        try (OutputStream os = conn.getOutputStream()) {
            os.write(jsonPayload.getBytes(StandardCharsets.UTF_8));
        }
        int code = conn.getResponseCode();
        String body = readResponse(conn);
        return new APIResponse(code, body);
    }

    private void sendDebug(String payload, String method, String debugUrl) {
        try {
            URL url = new URL(debugUrl + "?method=" + method);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/json");
            conn.setConnectTimeout(5000);
            conn.setReadTimeout(5000);
            conn.setDoOutput(true);
            try (OutputStream os = conn.getOutputStream()) {
                os.write(payload.getBytes(StandardCharsets.UTF_8));
            }
            conn.getResponseCode();
        } catch (Exception e) {
            // Non-critical
        }
    }

    private String readResponse(HttpURLConnection conn) {
        try {
            InputStream is = conn.getResponseCode() >= 400 ?
                conn.getErrorStream() : conn.getInputStream();
            if (is == null) return "";
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(is, StandardCharsets.UTF_8))) {
                return reader.lines().collect(Collectors.joining("\n"));
            }
        } catch (Exception e) {
            return "";
        }
    }

    private String getCleanPath(String path) {
        if (path == null) return "";
        if (path.endsWith("/jcr:content")) {
            return path.substring(0, path.indexOf("/jcr:content"));
        }
        if (path.endsWith("/jcr:content/metadata")) {
            return path.substring(0, path.indexOf("/jcr:content/metadata"));
        }
        return path;
    }

    private Map<String, String> parseProcessArgs(String args) {
        Map<String, String> map = new HashMap<>();
        if (args != null && !args.isEmpty()) {
            for (String pair : args.split(",")) {
                String[] parts = pair.split("=", 2);
                if (parts.length == 2) {
                    map.put(parts[0].trim(), parts[1].trim());
                }
            }
        }
        return map;
    }

    private static class XFItem {
        final String path;
        final Page xfRoot;
        final Page variation;

        XFItem(String path, Page xfRoot, Page variation) {
            this.path = path;
            this.xfRoot = xfRoot;
            this.variation = variation;
        }
    }

    private static class APIResponse {
        final int statusCode;
        final String body;

        APIResponse(int statusCode, String body) {
            this.statusCode = statusCode;
            this.body = body;
        }
    }
}
```

<Info>
  **Troubleshooting**:

  If deployment errors occur, check the `error.log` file in `crx-quickstart/logs/` for detailed error messages.
</Info>

### Step 4: Build and Deploy the OSGi Bundle

After you add the code, to build and deploy your AEM project, perform the following steps:

1. Navigate to your project root directory.
2. Run the Maven build command:
   ```bash theme={null}
   mvn clean install -PautoInstallPackage
   ```
3. Verify the bundle is active in the OSGi Console at `http://<aem-author>:4502/system/console/bundles`.
4. Search for your bundle name and confirm status is **Active**.

### Step 5: Create The Workflow Models

Create the following workflow models to handle publish operation:

#### Workflow 1: Publish Workflow (ACTIVE Status)

To publish workflow (ACTIVE status), perform the following steps:

<img src="https://mintcdn.com/moengage/11KR3mwxT6FlowrU/images/partner_44122980932756.png?fit=max&auto=format&n=11KR3mwxT6FlowrU&q=85&s=6f97330b9329ff0aec4ea278c0034714" alt="" width="2936" height="1668" data-path="images/partner_44122980932756.png" />

1. Navigate to **Tools** > **Workflow** > **Models** in AEM Author.
2. Click **Create** > **Create Model**.
3. Enter workflow details:
   1. **Title:** MoEngage Content Sync
   2. **Name:** moengage-content-sync
4. Open the workflow for editing, delete the default step.
5. Drag the **Process Step** from the sidebar to the workflow canvas.
6. Double-click the Process Step to configure it.
7. In the **Process** dropdown, select **MoEngage Content Sync**.
8. In the **Arguments** field, enter:
   ```text wrap theme={null}
   moengage.api.key=YOUR_WORKSPACE_ID,moengage.api.secret=YOUR_API_SECRET,moengage.datacenter=YOUR_DATACENTRE_VALUE,aem.publish.url=https://your-publish-domain.com,moengage.status=ACTIVE
   ```
9. Click **OK** to save.
10. Click **Sync** to save the workflow model.

#### Configuration Parameters

| Parameter           | Description                                    | Required |
| ------------------- | ---------------------------------------------- | -------- |
| moengage.api.key    | Your MoEngage Workspace ID                     | Yes      |
| moengage.api.secret | Your MoEngage Data API Secret (for Basic Auth) | Yes      |
| moengage.datacenter | MoEngage data center number (e.g., 02, 03, 04) | Yes      |
| aem.publish.url     | Base URL of your AEM Publish instance          | Yes      |
| moengage.status     | Target status: ACTIVE                          | Yes      |
| moengage.debug      | Enable debug mode (true/false)                 | No       |
| moengage.debug.url  | Webhook URL for debugging (e.g., webhook.site) | No       |

<img src="https://mintcdn.com/moengage/11KR3mwxT6FlowrU/images/partner_44122961627540.png?fit=max&auto=format&n=11KR3mwxT6FlowrU&q=85&s=efcf607e007c1569b0ac59f263242d21" alt="" width="2936" height="1636" data-path="images/partner_44122961627540.png" />

### Step 6: Configure Workflow Launchers

Navigate to **Tools → Workflow → Launchers** and create 6 launchers to handle publish, unpublish, and delete operations:

#### Experience Fragments Launchers

| Launcher Name       | Event Type | Nodetype       | Path                                       | Condition                          | Workflow              |
| ------------------- | ---------- | -------------- | ------------------------------------------ | ---------------------------------- | --------------------- |
| moengage-xf-publish | Modified   | cq:PageContent | /content/experience-fragments/\[your-site] | cq:lastReplicationAction==Activate | moengage-content-sync |

#### DAM Assets Launchers

| Launcher Name        | Event Type | Nodetype         | Path                      | Condition                          | Workflow              |
| -------------------- | ---------- | ---------------- | ------------------------- | ---------------------------------- | --------------------- |
| moengage-dam-publish | Modified   | dam:AssetContent | /content/dam/\[your-site] | cq:lastReplicationAction==Activate | moengage-content-sync |

<Warning>
  For all launchers:

  * Set **Run Modes:** author
  * Set **Enabled:** true
  * Replace `[your-site]` with your actual site path
</Warning>

<img src="https://mintcdn.com/moengage/11KR3mwxT6FlowrU/images/partner_44122961627796.png?fit=max&auto=format&n=11KR3mwxT6FlowrU&q=85&s=0bb0c078c7bb78fcf5fafcf0debb1a2c" alt="" width="2878" height="1172" data-path="images/partner_44122961627796.png" />

### Step 7: Test the Integration

##### Enable Debug Mode (Recommended)

For initial testing, add debug parameters to workflow arguments:

```text wrap theme={null}
moengage.debug=true,moengage.debug.url=https://webhook.site/your-unique-id
```

Get a free webhook URL from [webhook.site](https://webhook.site) to inspect payloads in real-time.

1. Navigate to the **Experience Fragment** or **Content Fragment** in AEM Author.
2. Select the page information icon.
3. Click **Start Workflow**.
4. Select **MoEngage Content Sync Workflow**.
5. Click **Start**.

<img src="https://mintcdn.com/moengage/11KR3mwxT6FlowrU/images/partner_44122961627668.png?fit=max&auto=format&n=11KR3mwxT6FlowrU&q=85&s=8c7b3dfb2c6ec80d1fa9e78ef13fa8f4" alt="" width="2936" height="1244" data-path="images/partner_44122961627668.png" />

## Personalization Token Mapping

The workflow automatically converts AEM personalization tokens to MoEngage format:

| AEM Token (Adobe Target Format)     | MoEngage Token                          |
| ----------------------------------- | --------------------------------------- |
| `{{profile.person.name.firstName}}` | `{{UserAttribute['First Name']}}`       |
| `{{profile.person.name.lastName}}`  | `{{UserAttribute['Last Name']}}`        |
| `{{profile.person.name.fullName}}`  | `{{UserAttribute['Name']}}`             |
| `{{identityMap.email.0.id}}`        | `{{UserAttribute['Email (Standard)']}}` |
| `{{#if condition}}`                 | `{% if condition %}`                    |
| `{{else}}`                          | `{% else %}`                            |
| `{{/if}}`                           | `{% endif %}`                           |

## Additional Resources

* [MoEngage Content Block API Documentation](https://developers.moengage.com/hc/en-us/articles/9611518840724-Content-block-APIs)
* [Adobe AEM: OSGi Services Development](https://experienceleague.adobe.com/en/docs/experience-manager-learn/cloud-service/developing/osgi-services/basics)
* [Adobe AEM: Extending Workflows](https://experienceleague.adobe.com/en/docs/experience-manager-65/content/implementing/developing/extending-aem/extending-workflows/workflows-customizing-extending)
* [Adobe AEM: ContentFragment API](https://developer.adobe.com/experience-manager/reference-materials/6-5/javadoc/com/adobe/cq/dam/cfm/ContentFragment.html)

## Support and Customization

<Info>
  **Support**:

  For assistance with deployment, custom content extraction logic, troubleshooting, or advanced configurations, contact **MoEngage Integration Support** or your Customer Success Manager.
</Info>
