LibrarySites.Banner

Sitecore & Salesforce: Another Approach to Extending Experience Profile in Sitecore 9

This blog series on Sitecore & Salesforce is complimentary to a new Salesforce integration feature implemented in the Habitat Home demo website. All code and configuration presented in this blog series is available on GitHub along with the full Sitecore solution for the Habitat Home demo website.

Sitecore & Salesforce - Blog Series Introduction

I recently spent some time building out a comprehensive integration between Sitecore and Salesforce using the following two connectors provided by Sitecore.

The integration to Salesforce focuses on connecting and extracting maximum value from both:

  • Salesforce Sales Cloud (Core CRM product)
  • Salesforce Marketing Cloud

I'll be taking an unconventional approach to this blog series by taking a back-to-front look at the implementation between Sitecore & Salesforce starting at the last implemented piece and working to the beginning.

Another Approach to Extending Experience Profile (xProfile) in Sitecore 9

Just like any other external data that is imported into Sitecore xConnect, once we have contact data synced from Salesforce CRM back to Sitecore it is eligible to be displayed in various areas of Sitecore. In our case we are syncing field data from contacts in Salesforce CRM back to custom facets in Sitecore xConnect (How we achieve this syncing will be the subject of an upcoming blog post in this series!).

In this post we will focus on picking up directly after we have a custom xConnect facet created for Sitecore contacts. For the purpose of this post, the custom xConnect facet data can be of any type and not necessarily related to Salesforce data. Our goal will be to retrieve data stored in a custom xConnect facet and display the data in a custom tab in xProfile. Can it be done? Yes, it can!

What will the finished result look like? Something like this where we have extended the default set of tabs in xProfile and added a "Salesforce" tab.

Let's take a look at our starting point for this blog post. We have successfully added data to a custom xConnect facet. Great! You should see something like this added to the "ContactFacets" table in one of your Sitecore "Collection.Shard" databases. My custom xConnect facet is named "CustomSalesforceContact".

Once again, we're dealing with data imported from Salesforce but it can be any type of data in the custom xConnect facet including extra information you have gathered from within Sitecore.

What is stored in this custom xConnect facet? Well, not much but we're trying to keep things simple as a starting point. This custom facet contains data from a single Salesforce contact field. Here is the JSON stored in this custom xConnect facet.

{  
   "@odata.type":"#Sitecore.HabitatHome.Feature.CRM.CustomCollectionModels.CustomSalesforceContactInformation",
   "WelcomeJourneyStatus":"Journey Step 1 - Entered Journey"
}

Part 1 - The SPEAK Configuration Part

Let's figure out how to display this piece of Salesforce data in xProfile. We will begin by taking a look at the existing set of default tabs in xProfile and deciding which tab presents data in a way that might be useful for our purposes. This will allow us to duplicate an existing xProfile tab to act as a starting point which will make our job drastically easier (Trust me, SPEAK configuration is tricky!).

I don't know the first thing about SPEAK but I was able to duplicate the "Details" tab in xProfile and manipulate its layout properties in order to display our custom xConnect facet data.

The "Details" tab is configured with three columns. To keep things simple I removed the second and third columns and just kept the first on the Duplicated "Salesforce" tab.

It has been years since I've used Sitecore Rocks but you'll need it in order to work with SPEAK. Ugh, yeah. After installation I was surprised to see that Sitecore Rocks did not seem to be working with Sitecore 9. Sitecore community to the rescue! It looks like a "Web.config" update in Sitecore 9 is responsible for the problem and there is a simple fix outlined in the following post: https://www.linkedin.com/pulse/sitecore-rocks-9x-fabio-carello.

Now that we're connected via Sitecore Rocks, we can navigate to the item representing the "Details" tab in xProfile and duplicate it and its subitems. It is located in the Core DB in the following location: /sitecore/client/Applications/ExperienceProfile/Contact/PageSettings/Tabs/Details.

After a review of your duplicated items you'll quickly find that the majority of the layout definition for the xProfile tab is on the subitem named "DetailsPanel" or in my case "SalesforcePanel". You can review the Presentation Details for items in Sitecore Rocks by right clicking on an item and selecting "Tasks -> Design Layout".

You will need to review the layout details and figure things out for yourself at this point but the best piece of advice I can give you is to focus on the "Data Source" column as highlighted in the image above as it helps you understand how everything is pieced together in SPEAK. Make sure you pay special attention to the "SubPageCode" rendering (listed first) which points to an important JavaScript file. I recommend duplicating and pointing to your new JavaScript file now.

Don't forget there are also some important layout updates that need to be made to the top-level "Details" item, or in my case "Salesforce" item, in order to wire up your custom tab.

Let's go ahead and update the JavaScript file you duplicated and pointed to for the "SubPageCode" rendering. You will need to wire up your custom facet data as well as remove any JavaScript that points to renderings you removed from your custom tab. For example, I removed the second and third columns from my custom tab and removed all references to those in the JavaScript file (otherwise you will see browser console errors when trying to load your custom tab).

Your updated JavaScript may look something like this but it will vary depending on your data to be displayed and the layout you choose.

define(["sitecore", "/-/speak/v1/experienceprofile/DataProviderHelper.js", "/-/speak/v1/experienceprofile/CintelUtl.js"], function (sc, providerHelper, cintelUtil) {
    var intelPath = "/intel",
        dataSetProperty = "dataSet";

    var getTypeValue = function (preffered, all) {
        if (preffered.Key) {
            return { Key: preffered.Key, Value: preffered.Value };
        } else if (all.length > 0) {
            return { Key: all[0].Key, Value: all[0].Value };
        }

        return null;
    };

    var app = sc.Definitions.App.extend({
        initialized: function () {
            var transformers = $.map(
                [
                    "default"
                ], function (tableName) {
                    return { urlKey: intelPath + "/" + tableName + "?", headerValue: tableName };
                });

            providerHelper.setupHeaders(transformers);
            providerHelper.addDefaultTransformerKey();

            this.setupContactDetail();
        },

        setEmail: function (textControl, email) {
            if (email && email.indexOf("@") > -1) {
                cintelUtil.setText(textControl, "", true);
                textControl.viewModel.$el.html('<a href="mailto:' + email + '">' + email + '</a>');
            } else {
                cintelUtil.setText(textControl, email, true);
            }
        },

        setupContactDetail: function () {

            providerHelper.initProvider(this.ContactSalesforceDataProvider, "", sc.Contact.baseUrl, this.SalesforceTabMessageBar);
            providerHelper.getData(
                this.ContactSalesforceDataProvider,
                $.proxy(function (jsonData) {
                    this.ContactSalesforceDataProvider.set(dataSetProperty, jsonData);
                    var dataSet = this.ContactSalesforceDataProvider.get(dataSetProperty);
				
                    cintelUtil.setText(this.CustomSalesforceJourneyStatusValue, jsonData.CustomSalesforceJourneyStatus, false);
                }, this)
            );


        }
    });
    return app;
});

Part 2 - The Coding Part

Now that SPEAK is configured to show a custom tab in xProfile, we still need to figure out how to wire up the data to be displayed. It's time to fire up your favorite .NET refection tool and start reviewing Sitecore assemblies to figuring out what we can steal and start extending.

We will need to extend four code files that are presented below.

File #1 - The entry point for xProfile to request data from Sitecore is via an API endpoint so let's begin by extending the default API endpoint so we can add our custom facet data to be returned when calling this endpoint. We will be extending "Sitecore.Cintel.Endpoint.Plumbing.InitializeRoutes" and replacing the default "Get" action for this endoint.

using Sitecore.Cintel.Endpoint.Plumbing;
using Sitecore.Pipelines;
using System.Web.Http;
using System.Web.Routing;

namespace Sitecore.HabitatHome.Feature.CRM.ExperienceProfile
{
    public class ExperienceProfileSalesforceInitializeRoutes : InitializeRoutes
    {
        public override void Process(PipelineArgs args)
        {
            this.RegisterRoutes(RouteTable.Routes, args);
        }

        protected new void RegisterRoutes(RouteCollection routes, PipelineArgs args)
        {
            base.RegisterRoutes(routes, args);
            routes.Remove(routes["cintel_contact_entity"]);
            routes.MapHttpRoute("cintel_contact_entity", "sitecore/api/ao/v1/contacts/{contactId}", (object)new
            {
                controller = "ExperienceProfileSalesforceContact",
                action = "Get"
            });
        }
    }
}

File #2 - The previous code snippet for our extended API endpoint calls a controller named "ExperienceProfileSalesforceContact". Let's go build out this controller and define our custom "Get" method.

using Sitecore.Cintel.Endpoint.Plumbing;
using System;
using System.Web;
using System.Web.Http;
using Sitecore.Cintel.Configuration;
using Sitecore.Cintel.ContactService;
using Sitecore.Cintel.ContactService.Model;
using Sitecore.Cintel.Endpoint.Transfomers;
using Sitecore.Web.Http.Filters;
using System.Net;
using System.Net.Http;
using Sitecore.Cintel;

namespace Sitecore.HabitatHome.Feature.CRM.ExperienceProfile.CustomApiController
{
    [NegotiateLanguageFilter]
    [AuthorizedReportingUserFilter]
    public class ExperienceProfileSalesforceContactController : ApiController
    {
        [HttpGet]
        [ValidateHttpAntiForgeryToken]
        public object Get(Guid contactId)
        {
            try
            {
                return ApplyContactDetailsTransformer((CustomerIntelligenceManager.ContactService as ExperienceProfileSalesforceXdbContactService).Get(contactId));
            }
            catch (ContactNotFoundException ex)
            {
                return (object)this.Request.CreateResponse<string>(HttpStatusCode.NotFound, ex.Message);
            }
        }

        private static object ApplyContactDetailsTransformer(IContact contact)
        {
            Cintel.Commons.ResultSet<IContact> resultSet1 = new Cintel.Commons.ResultSet<IContact>(1, 1);
            resultSet1.Data.Dataset.Add(nameof(contact), contact);
            string header1 = HttpContext.Current.Request.Headers[WellknownIdentifiers.TransfomerClientNameHeader];
            string header2 = HttpContext.Current.Request.Headers[WellknownIdentifiers.TransformerKeyHeader];
            if (string.IsNullOrEmpty(header1) || string.IsNullOrEmpty(header2))
                return (object)contact;
            Cintel.Commons.ResultSet<IContact> resultSet2 = ResultTransformManager.GetContactDetailsTransformer(header1, header2).Transform(resultSet1) as Cintel.Commons.ResultSet<IContact>;
            if (resultSet2 == null)
                return (object)contact;
            return (object)resultSet2.Data.Dataset[nameof(contact)];
        }
    }
}

Now we have our extended API endpoint and controller. We need to extend two more files to define how our custom facet data is delivered to xProfile.

File #3 - We need to extend "Sitecore.Cintel.ContactService.Model.ReadonlyContact" to add our custom facet data to the model that defines what data is sent to xProfile.

using Sitecore.Cintel.ContactService.Model;
using System;
using System.Collections.Generic;
using Sitecore.XConnect;

using System.Data;

namespace Sitecore.HabitatHome.Feature.CRM.ExperienceProfile
{
    [Serializable]
    public class ExperienceProfileSalesforceReadonlyContact : ReadonlyContact
    {
        private string _customSalesforceJourneyStatus;

        public ExperienceProfileSalesforceReadonlyContact(Guid contactId, int classification, List<ContactIdentifier> identifiers, string firstName, string middleName, string surname, string title, string suffix, string nickname, DateTime? birthDate, string gender, string jobTitle, int totalValue, int visitCount, KeyValuePair<string, IAddress> preferredAddress, KeyValuePair<string, IEmailAddress> preferredEmailAddress, KeyValuePair<string, IPhoneNumber> preferredPhoneNumber, IList<KeyValuePair<string, IAddress>> addresses, IList<KeyValuePair<string, IEmailAddress>> emailAddresses, IList<KeyValuePair<string, IPhoneNumber>> phoneNumbers, string customSalesforceJourneyStatus) 
            : base(contactId, classification, identifiers, firstName, middleName, surname, title, suffix, nickname, birthDate, gender, jobTitle, totalValue, visitCount, preferredAddress, preferredEmailAddress, preferredPhoneNumber, addresses, emailAddresses, phoneNumbers)
        {
            this._customSalesforceJourneyStatus = customSalesforceJourneyStatus;
        }

        public string CustomSalesforceJourneyStatus
        {
            get
            {
                return this._customSalesforceJourneyStatus;
            }
            set
            {
                throw new ReadOnlyException("Readonly Contact is readonly");
            }
        }
    }
}

File #4 - Finally, we need to extend "Sitecore.Cintel.ContactService.XdbContactService" to add our custom facet data to be delivered to xProfile.

using Sitecore.Cintel.ContactService;
using System;
using System.Collections.Generic;
using System.Linq;
using Sitecore.Cintel.ContactService.Model;
using Sitecore.Cintel.Diagnostics;
using Sitecore.XConnect;
using Sitecore.XConnect.Collection.Model;
using Sitecore.XConnect.Client;
using Sitecore.XConnect.Client.Configuration;
using Sitecore.HabitatHome.Feature.CRM.CustomCollectionModels;
using Sitecore.DataExchange.Tools.SalesforceConnect.Facets;

namespace Sitecore.HabitatHome.Feature.CRM.ExperienceProfile
{
    public class ExperienceProfileSalesforceXdbContactService : XdbContactService
    {
        public new IContact Get(Guid contactId)
        {
            string[] facets = new string[8]
            {
        "Personal",
        "Addresses",
        "Emails",
        "PhoneNumbers",
        "Classification",
        "EngagementMeasures",
        "CustomSalesforceContact"
            };
            Contact contact = this.GetContact(contactId, facets);
            PersonalInformation facet1 = this.TryGetFacet<PersonalInformation>(contact, "Personal");
            AddressList facet2 = this.TryGetFacet<AddressList>(contact, "Addresses");
            EmailAddressList facet3 = this.TryGetFacet<EmailAddressList>(contact, "Emails");
            PhoneNumberList facet4 = this.TryGetFacet<PhoneNumberList>(contact, "PhoneNumbers");
            Classification facet5 = this.TryGetFacet<Classification>(contact, "Classification");
            EngagementMeasures facet6 = this.TryGetFacet<EngagementMeasures>(contact, "EngagementMeasures");
            CustomSalesforceContactInformation facet7 = this.TryGetFacet<CustomSalesforceContactInformation>(contact, "CustomSalesforceContact");
            return (IContact)this.CreateContact(contact.Id.GetValueOrDefault(), facet5, facet6, facet1, facet3, facet4, facet2, facet7, contact.Identifiers.ToList<ContactIdentifier>());
        }

        public Contact GetContact(Guid contactId, string[] facets)
        {
            using (XConnectClient client = SitecoreXConnectClientConfiguration.GetClient("xconnect/clientconfig"))
            {
                ContactReference contactReference = new ContactReference(contactId);
                Contact contact = facets == null || facets.Length == 0 ? client.Get<Contact>((IEntityReference<Contact>)contactReference, (ExpandOptions)new ContactExpandOptions(Array.Empty<string>())) : client.Get<Contact>((IEntityReference<Contact>)contactReference, (ExpandOptions)new ContactExpandOptions(facets));
                if (contact == null)
                    throw new ContactNotFoundException(string.Format("No Contact with id [{0}] found", (object)contactId));
                return contact;
            }
        }

        public TFacet TryGetFacet<TFacet>(Contact contact, string facetName) where TFacet : Facet
        {
            try
            {
                return contact.GetFacet<TFacet>(facetName);
            }
            catch (Exception ex)
            {
                Logger.Error(string.Format("Could not load facet with name [{0}]", (object)facetName), ex);
            }
            return default(TFacet);
        }

        public ReadonlyContact CreateContact(Guid contactId, Classification classification, EngagementMeasures engagementMeasures, PersonalInformation personalInfo, EmailAddressList emailAddressList, PhoneNumberList phoneNumberList, AddressList addressList, SalesforceContactInformation salseforceContactInformation, CustomSalesforceContactInformation customSalseforceContactInformation, List<ContactIdentifier> identifiers)
        {
            int classification1 = 0;
            int visitCount = 0;
            int totalValue = 0;
            string firstName = string.Empty;
            string middleName = string.Empty;
            string surname = string.Empty;
            string title = string.Empty;
            string suffix = string.Empty;
            string nickname = string.Empty;
            DateTime? birthDate = new DateTime?();
            string gender = string.Empty;
            string jobTitle = string.Empty;
            string customSalesforceJourneyStatus = string.Empty;
            KeyValuePair<string, IAddress> preferredAddress = new KeyValuePair<string, IAddress>();
            KeyValuePair<string, IEmailAddress> preferredEmailAddress = new KeyValuePair<string, IEmailAddress>();
            KeyValuePair<string, IPhoneNumber> preferredPhoneNumber = new KeyValuePair<string, IPhoneNumber>();
            IList<KeyValuePair<string, IAddress>> addresses = (IList<KeyValuePair<string, IAddress>>)new List<KeyValuePair<string, IAddress>>();
            IList<KeyValuePair<string, IEmailAddress>> emailAddresses = (IList<KeyValuePair<string, IEmailAddress>>)new List<KeyValuePair<string, IEmailAddress>>();
            IList<KeyValuePair<string, IPhoneNumber>> phoneNumbers = (IList<KeyValuePair<string, IPhoneNumber>>)new List<KeyValuePair<string, IPhoneNumber>>();
            if (classification != null)
                classification1 = classification.OverrideClassificationLevel > 0 ? classification.OverrideClassificationLevel : classification.ClassificationLevel;
            if (engagementMeasures != null)
            {
                visitCount = engagementMeasures.TotalInteractionCount;
                totalValue = engagementMeasures.TotalValue;
            }
            List<ContactIdentifier> identifiers1 = identifiers != null ? identifiers : new List<ContactIdentifier>();
            if (personalInfo != null)
            {
                firstName = personalInfo.FirstName;
                middleName = personalInfo.MiddleName;
                surname = personalInfo.LastName;
                gender = personalInfo.Gender;
                birthDate = personalInfo.Birthdate;
                jobTitle = personalInfo.JobTitle;
                nickname = personalInfo.Nickname;
                suffix = personalInfo.Suffix;
                title = personalInfo.Title;
            }
            if (emailAddressList != null)
            {
                preferredEmailAddress = emailAddressList.PreferredEmail == null || string.IsNullOrEmpty(emailAddressList.PreferredEmail.SmtpAddress) ? new KeyValuePair<string, IEmailAddress>(string.Empty, (IEmailAddress)null) : new KeyValuePair<string, IEmailAddress>(emailAddressList.PreferredKey, (IEmailAddress)new ReadonlyEmailAddress(emailAddressList.PreferredEmail));
                emailAddresses = (IList<KeyValuePair<string, IEmailAddress>>)emailAddressList.Others.Keys.Select<string, KeyValuePair<string, IEmailAddress>>((Func<string, KeyValuePair<string, IEmailAddress>>)(key => new KeyValuePair<string, IEmailAddress>(key, (IEmailAddress)new ReadonlyEmailAddress(emailAddressList.Others[key])))).OrderBy<KeyValuePair<string, IEmailAddress>, string>((Func<KeyValuePair<string, IEmailAddress>, string>)(kvp => kvp.Key)).ToList<KeyValuePair<string, IEmailAddress>>();
            }
            if (addressList != null)
            {
                preferredAddress = addressList.PreferredAddress == null || string.IsNullOrEmpty(addressList.PreferredKey) ? new KeyValuePair<string, IAddress>(string.Empty, (IAddress)null) : new KeyValuePair<string, IAddress>(addressList.PreferredKey, (IAddress)new ReadonlyAddress(addressList.PreferredAddress));
                addresses = (IList<KeyValuePair<string, IAddress>>)addressList.Others.Keys.Select<string, KeyValuePair<string, IAddress>>((Func<string, KeyValuePair<string, IAddress>>)(key => new KeyValuePair<string, IAddress>(key, (IAddress)new ReadonlyAddress(addressList.Others[key])))).OrderBy<KeyValuePair<string, IAddress>, string>((Func<KeyValuePair<string, IAddress>, string>)(kvp => kvp.Key)).ToList<KeyValuePair<string, IAddress>>();
            }
            if (phoneNumberList != null)
            {
                preferredPhoneNumber = phoneNumberList.PreferredPhoneNumber == null || string.IsNullOrEmpty(phoneNumberList.PreferredKey) ? new KeyValuePair<string, IPhoneNumber>(string.Empty, (IPhoneNumber)null) : new KeyValuePair<string, IPhoneNumber>(phoneNumberList.PreferredKey, (IPhoneNumber)new ReadonlyPhoneNumber(phoneNumberList.PreferredPhoneNumber));
                phoneNumbers = (IList<KeyValuePair<string, IPhoneNumber>>)phoneNumberList.Others.Keys.Select<string, KeyValuePair<string, IPhoneNumber>>((Func<string, KeyValuePair<string, IPhoneNumber>>)(key => new KeyValuePair<string, IPhoneNumber>(key, (IPhoneNumber)new ReadonlyPhoneNumber(phoneNumberList.Others[key])))).OrderBy<KeyValuePair<string, IPhoneNumber>, string>((Func<KeyValuePair<string, IPhoneNumber>, string>)(kvp => kvp.Key)).ToList<KeyValuePair<string, IPhoneNumber>>();
            }
            if (customSalseforceContactInformation != null)
            {
                customSalesforceJourneyStatus = customSalseforceContactInformation.WelcomeJourneyStatus;
            }
            return new ExperienceProfileSalesforceReadonlyContact(contactId, classification1, identifiers1, firstName, middleName, surname, title, suffix, nickname, birthDate, gender, jobTitle, totalValue, visitCount, preferredAddress, preferredEmailAddress, preferredPhoneNumber, addresses, emailAddresses, phoneNumbers, customSalesforceJourneyStatus);
        }
    }
}

And now we need to tie it all together with the proper Sitecore config changes. Here is an example config patch for the extended files we created above.

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"
               xmlns:role="http://www.sitecore.net/xmlconfig/role/"
               xmlns:unicorn="http://www.sitecore.net/xmlconfig/unicorn/"
               xmlns:integrations="http://www.sitecore.net/xmlconfig/integrations/">
    <sitecore>

      <pipelines>
        <initialize>
          <processor patch:instead="processor[@type='Sitecore.Cintel.Endpoint.Plumbing.InitializeRoutes, Sitecore.Cintel']" type="Sitecore.HabitatHome.Feature.CRM.ExperienceProfile.ExperienceProfileSalesforceInitializeRoutes, Sitecore.HabitatHome.Feature.CRM" />
        </initialize>
      </pipelines>
      
      <experienceProfile>
        <providers>
          <contactService patch:instead="contactService[@type='Sitecore.Cintel.ContactService.XdbContactService, Sitecore.Cintel']" type="Sitecore.HabitatHome.Feature.CRM.ExperienceProfile.ExperienceProfileSalesforceXdbContactService, Sitecore.HabitatHome.Feature.CRM" singleInstance="true"/>
        </providers>
      </experienceProfile>
      
    </sitecore>
</configuration>

That's all! Deploy your new code and navigate to your custom xProfile tab and see if the new data magically appears. If you don't see your custom data I recommend watching the browser console and network tabs to catch JavaScript errors or errors returned when hitting the API endpoint.

Now that we're done, let's compare the standard JSON data returned for a xProfile contact compared to our customized JSON data.

Standard JSON data returned for a xProfile contact:

{  
   "contactId":"d78abcfc-34e8-0000-0000-05621e70d905",
   "classification":0,
   "identifier":[  
      {  
         "Source":"Salesforce.ContactId",
         "Identifier":"003f400000bk4kCAAQ",
         "IdentifierType":1,
         "IsValid":true
      },
      {  
         "Source":"extranet",
         "Identifier":"sitecoredemo@sitecore.net",
         "IdentifierType":1,
         "IsValid":true
      },
      {  
         "Source":"xDB.Tracker",
         "Identifier":"ec370a93b97344f8af9725209c3f999c",
         "IdentifierType":0,
         "IsValid":true
      },
      {  
         "Source":"Alias",
         "Identifier":"f9de3ca7-cf1a-4f84-b7a5-bba7032646c4",
         "IdentifierType":0,
         "IsValid":true
      }
   ],
   "firstName":"Firsname",
   "middleName":null,
   "surName":"Lastname",
   "title":null,
   "suffix":null,
   "nickName":null,
   "birthDate":null,
   "gender":null,
   "jobTitle":null,
   "totalValue":400,
   "visitCount":7,
   "preferredAddress":{  
      "Key":"Mailing",
      "Value":{  
         "country":null,
         "stateProvince":null,
         "city":null,
         "postalCode":null,
         "streetLine1":null,
         "streetLine2":null,
         "streetLine3":null,
         "streetLine4":null,
         "location":null
      }
   },
   "preferredEmailAddress":{  
      "Key":"Connect for Salesforce Tenant Branch",
      "Value":{  
         "SmtpAddress":"sitecoredemo@sitecore.net",
         "Validated":false
      }
   },
   "preferredPhoneNumber":{  
      "Key":"Connect for Salesforce Tenant Branch",
      "Value":{  
         "countryCode":"",
         "number":"2223334444",
         "extension":null
      }
   },
   "addresses":[  

   ],
   "emailAddresses":[  

   ],
   "phoneNumbers":[  

   ]
}

Customized JSON data returned for a xProfile contact with our custom facet data:

{  
   "CustomSalesforceJourneyStatus":"Journey Step 1 - Entered Journey",
   "contactId":"d78abcfc-34e8-0000-0000-05621e70d905",
   "classification":0,
   "identifier":[  
      {  
         "Source":"Salesforce.ContactId",
         "Identifier":"003f400000bk4kCAAQ",
         "IdentifierType":1,
         "IsValid":true
      },
      {  
         "Source":"extranet",
         "Identifier":"sitecoredemo@sitecore.net",
         "IdentifierType":1,
         "IsValid":true
      },
      {  
         "Source":"xDB.Tracker",
         "Identifier":"ec370a93b97344f8af9725209c3f999c",
         "IdentifierType":0,
         "IsValid":true
      },
      {  
         "Source":"Alias",
         "Identifier":"f9de3ca7-cf1a-4f84-b7a5-bba7032646c4",
         "IdentifierType":0,
         "IsValid":true
      }
   ],
   "firstName":"Firsname",
   "middleName":null,
   "surName":"Lastname",
   "title":null,
   "suffix":null,
   "nickName":null,
   "birthDate":null,
   "gender":null,
   "jobTitle":null,
   "totalValue":400,
   "visitCount":7,
   "preferredAddress":{  
      "Key":"Mailing",
      "Value":{  
         "country":null,
         "stateProvince":null,
         "city":null,
         "postalCode":null,
         "streetLine1":null,
         "streetLine2":null,
         "streetLine3":null,
         "streetLine4":null,
         "location":null
      }
   },
   "preferredEmailAddress":{  
      "Key":"Connect for Salesforce Tenant Branch",
      "Value":{  
         "SmtpAddress":"sitecoredemo@sitecore.net",
         "Validated":false
      }
   },
   "preferredPhoneNumber":{  
      "Key":"Connect for Salesforce Tenant Branch",
      "Value":{  
         "countryCode":"",
         "number":"2223334444",
         "extension":null
      }
   },
   "addresses":[  

   ],
   "emailAddresses":[  

   ],
   "phoneNumbers":[  

   ]
}

It doesn't look like much but our "CustomSalesforceJourneyStatus" facet property has been added and is displayed in xProfile!

In our simple example, we have added a single piece of string data from our custom xConnect facet to be delivered to xProfile. In order to deliver more data stored in a xConnect facet, you can look at adding a List of data in files #3 and #4 instead of a string. This will allow you to efficiently use this method to deliver more complex data to xProfile.