From Shantinath Patil: Pre-filling values on the Appointment Review Screen

So you want to preload some values on the final Review screen provided by Scheduler?

Check this out…

Let’s say you want to prefill the email & phone details.

  1. Set the Email and Phone in the first assignment stage:
image (22).png

2. Assign Service Appointment record variable in the review screen. In an Outbound flow template that input is blank and that’s why making changes only mentioned in step 1 will not work.

image (23).png

That’s it!

image (24).png

Note this method works for the Outbound flow & Inbound flow and is restricted to the following fields:

  • “AdditionalInformation”
  • “AppointmentType”
  • “Comments”
  • “ParentRecordId”
  • “ServiceTerritoryId”
  • “Street”
  • “City”
  • “State”
  • “Country”
  • “PostalCode”
  • “SchedStartTime”
  • “SchedEndTime”
  • “WorkTypeId”
  • “Id”
  • “Description”
  • “Subject”
  • “Phone”
  • “Email”
  • “ContactId”
  • “IsAnonymousBooking”

To prefill other fields or for other flows (eg Guest Flow), you need to do it build your own screen & logic:

Also check out other cool changes that you could with the review screen here: Review Appointment screen: Quick Flow Customisations – Salesforce Scheduler

Summer 21 Feature: Specify the Hours that a Resource can work on a particular Topic

Below is how Operating Hours or Working hours for a Service Resource are determined in Scheduler today.

As you can see the Scheduler’s algorithm considers the intersection of operating hours set at various levels. The Operating Hours set at Service Territory is mandatory but at other levels is optional and an intersection is considered only if the respective operating hour has been added. Typically customers use the Operating Hours set at the Service Territory & Service Territory Member level. Read more about it here

In Summer 21, a feature provides setting up these working hours (or Operating hours are a more granular level.) It allows you to define the Operating Hours that a Service Territory Member can work for a Work Type Group. This helps solve the following needs.

So how does it work?

Now lets take an example of a scenario I would like to model and how the setup would work in Salesforce Scheduler


The Market St Branch, San Francisco (Service Territory) is open from 9am to 5pm on Monday to Friday. All appointments are for 60 mins (for simplicity.)

Misha Clayton working as a banker (Service Resource) in this branch has the skills to manage the following topics (Work Type Groups): Investment Banking, Business Banking & General Banking.

Below is her schedule

  • Monday
    • General Banking & Business Banking topics from 10am to 12pm
    • General Banking & Investment Banking topics from 1pm to 3pm
  • Tuesday
    • Business Banking & Investment Banking topic from 10am to 4pm
    • No General Banking appointments
  • Wednesday
    • All topics from 9am to 5pm
  • Thursday
    • Only General Banking from 9am to 5pm
  • Friday
    • Banker does not take any customer appointments on any topics


First we will setup the Market St Branch working hours by adding the Operating Hours on respective Service Territory

Now we setup Misha’s schedule for the Market St Branch by going into the respective Service Territory Member record and adding the needed Operating Hours

Since Friday doesn’t have any hours setup in this Operating Hours record, Friday hours will not show up for Misha in the Market St Branch.

So lets see what the time slots show up when a customer selects Misha for the Market St Branch for different Work Type Groups

  1. Business Banking

2. General Banking

3. Investment Banking

Similarly, I could have set up Dr John (who has the skills to practice multiple specialities but only practices one speciality in each clinic.)

From Chris Albanese: Summer 21 Feature: Get Resources and Available Time Slots Through New Apex Methods

Salesforce Scheduler introduced a new feature in the Summer ’21 Release which allows developers to easily make custom time slot screen flow components that interact with external systems. The new Apex methods call the Get Appointment Candidates and Get Appointment Slots APIs. This capability helps you easily get all the service resources and available time slots or get available slots for a resource.

With this new feature developers have the choice of using the Scheduler REST APIs and the Scheduler Apex methods. The REST APIs are a powerful tool that allows you to develop custom user interfaces and applications that run on your own websites. The Apex methods offer a great option for developers building custom UI components and processes that are run by users that are already logged into Salesforce, such as running a Lightning Flow launched from a button on the Account Screen or viewing a page inside of a Salesforce Experience site.

Check this out below.

Calling the Scheduler Get Appointment Candidates API with Apex

Here’s a little concept illustrator to show you how easy it is to call the API using Apex.

Let’s say you want to ask Scheduler to return the next time slot available for a Work Type Group and Territory. The user is a call center user, talking to a customer and the customer says “give me your next available appointment please”.

Solution Architecture

  • A simple Flow embedded on the account page
  • The flow calls an @invocableMethod which uses the new API
  • The method returns the 1st time slot found
  • The flow creates a service appointment using the time slot returned

User Experience

The top left of the 2 screen shots below illustrate how the call center user can ask the scheduler to return the next available time slots and create a service appointment.


Screen Flow

As you can see in the screen shot below, the flow is pretty simple. It takes in the account id from the Account Page. It prompts the user for Work Type Group, Territory and Earliest Start Time. It passes these parameters into the Apex Action. If a slot is returned, it creates a service appointment, if not, an informational message is displayed.


Apex Class

As you probably already know, you can call Apex Methods from a Lightning Flow using the Action component. There are lots of great articles and tips on building @invocableMethods, which I won’t go into here. For this example, the method I created takes in the parameters, calls the Scheduler Apex Method named lxscheduler.SchedulerResources.getAppointmentCandidates, and returns the first result found, or returns nothing if no slots are found.

Receiving the Input Parameters from the Flow

In the example code below, to accept the input parameters from the flow, I created a class called payloadIn. It contains variables for Work Type Group Id, Service Territory Id, Scheduling Policy Id, Account Id and Start Date Time. We use these values to call the Scheduler Method. Variables like Account Id are not mandatory, I’ve just included it in my example.

Setting the Input Parameters in the Apex Method

The Get Appointment Candidates API provides an easy to use class to set the parameters that you want to pass to the Get Appointment Candidates API, lxscheduler.GetAppointmentCandidatesInput. As you can see in the example code, I simply set the parameters to the values passed in from the flow. I’ve hard coded the EndTime to be the Start Time plus 3 days. You can of course change this to be more dynamic.

Calling the Apex Method

It is super simple and is represented by this line in the example code:

String response = lxscheduler.SchedulerResources.getAppointmentCandidates(input);

Note, that no authentication code, connected apps, named credentials and other items are required to call the Apex Method. Since the running user is already authenticated, they can call the Scheduler API, just like they can call any other Apex Method they have access to.

Hooray! This is what is so awesome about this new API and makes code based development for Scheduler so much easier for these types of use cases.

Parsing the Results – they’re different from the REST API results

The method returns the results as a JSON string. I won’t go into the ins and outs of JSON and you can certainly find out more with a quick web search, but what you need to know is that the results from the lxscheduler.SchedulerResources.getAppointmentCandidates call are slightly different from the REST API call.

The REST API call for getAppointmentCandidates returns this format:

"candidates" : [ {
"endTime" : "2019-01-23T19:15:00.000+0000",
"resources" : [ "0HnB0000000D2DsKAK" ],
"startTime" : "2019-01-23T16:15:00.000+0000",
"territoryId" : "0HhB0000000TO9WKAW"
}, {
"endTime" : "2019-01-23T19:30:00.000+0000",
"resources" : [ "0HnB0000000D2DsKAK" ],
"startTime" : "2019-01-23T16:30:00.000+0000",
"territoryId" : "0HhB0000000TO9WKAW"
}, {
"endTime" : "2019-01-23T19:45:00.000+0000",
"resources" : [ "0HnB0000000D2DsKAK" ],
"startTime" : "2019-01-23T16:45:00.000+0000",
"territoryId" : "0HhB0000000TO9WKAW"

The Apex Method for getAppointmentCandidates returns this format:

"endTime" : "2019-01-23T19:15:00.000+0000",
"resources" : [ "0HnB0000000D2DsKAK" ],
"startTime" : "2019-01-23T16:15:00.000+0000",
"territoryId" : "0HhB0000000TO9WKAW"
}, {
"endTime" : "2019-01-23T19:30:00.000+0000",
"resources" : [ "0HnB0000000D2DsKAK" ],
"startTime" : "2019-01-23T16:30:00.000+0000",
"territoryId" : "0HhB0000000TO9WKAW"
}, {
"endTime" : "2019-01-23T19:45:00.000+0000",
"resources" : [ "0HnB0000000D2DsKAK" ],
"startTime" : "2019-01-23T16:45:00.000+0000",
"territoryId" : "0HhB0000000TO9WKAW"

Did you notice the difference? The outer wrapper for “candidates: [ …]” is missing from the results, so you need to make sure you deserialize the results using the proper format.

Here’s the class I used to deserialize the results:

public class schedulerCandidates {

public datetime startTime;
public datetime endTime;
public List<String> resources;
public String territoryId;

public static list<schedulerCandidates> parse(String json) {
return (list<schedulerCandidates>) System.JSON.deserialize(json, list<schedulerCandidates>.class);

Example Code

public with sharing class LXScheduleFirstAvailable {

@invocableMethod(label='Schedule to First Available' description='Schedule to whomever is next')
public static list<event> ScheduleIt(list<payloadIn> payloadList ){
if(payloadList == null || payloadList.size() <> 1) return null;
payloadIn pl = payloadList[0];
lxscheduler.GetAppointmentCandidatesInput input = new lxscheduler.GetAppointmentCandidatesInputBuilder()
.setTerritoryIds(new List<String>{pl.serviceTerritoryId})
//call the Apex Method - no REST API authentication or API user needed!!!
String response = lxscheduler.SchedulerResources.getAppointmentCandidates(input);
//parse the results using JSON.deserialize
if(response==null) return null;
list<schedulerCandidates> allslots = schedulerCandidates.parse(response);
//slots found, return just the first one
if(allslots!=null) {
event thisevent = new event();
thisevent.startdatetime = allslots[0].startTime;
thisevent.enddatetime = allslots[0].endTime;
thisevent.description = allslots[0].territoryId;
thisevent.whoid = allslots[0].resources[0];
return new list<event>{thisevent};
//no slots found, return null
return null;
public class payloadIn{
public string workTypeGroupId;
public string serviceTerritoryId;
public string schedulingPolicyId;
public string accountId;
public datetime startDateTime;


Example Code Test Method

private class LXSchedulerFirstAvailableTest {
static testMethod void getAppCandidatesTest() {
String expectedResponse = '[' +
' {' +
' \"startTime\": \"2021-03-18T16:00:00.000+0000\",' +
' \"endTime\": \"2021-03-18T17:00:00.000+0000\",' +
' \"resources\": [' +
' \"0HnRM0000000Fxv0AE\"' +
' ],' +
' \"territoryId\": \"0HhRM0000000G8W0AU\"' +
' },' +
' {' +
' \"startTime\": \"2021-03-18T19:00:00.000+0000\",' +
' \"endTime\": \"2021-03-18T20:00:00.000+0000\",' +
' \"resources\": [' +
' \"0HnRM0000000Fxv0AE\"' +
' ],' +
' \"territoryId\": \"0HhRM0000000G8W0AU\"' +
' }' +
LXScheduleFirstAvailable.payloadIn pl = new LXScheduleFirstAvailable.payloadIn();
pl.workTypeGroupId = '0VSB0000000LB9TOAW';
pl.serviceTerritoryId ='0HhB0000000TrsdKAC';
pl.startDateTime =;
pl.accountId = '001B000001KYUM3IAP';
pl.schedulingPolicyId = '0VrB0000000Kz6Z';
list<LXScheduleFirstAvailable.payloadIn> plList = new list<LXScheduleFirstAvailable.payloadIn>();
List<event> candidateList = LXScheduleFirstAvailable.ScheduleIt(plList);
System.assertEquals(1, candidateList.size(), 'Should return only 1 record!');


This new Apex API is awesome and simplifies calling the Scheduler API for use cases where the user is already logged into Salesforce.

Additional Notes about this Example

In my example flow, I’m using 2 create record steps to create the Service Appointment and Assigned Resource. In production, you probably want to use the Scheduler flow component called Save Appointment. I’ve described how to use that component here. This component gives you added capabilities to ensure that the time slot selected is still available at the time of record save.

Reading External Calendars

Several of your schedulable resources (Bankers, Advisors, Doctors, Store associates) work out of External calendars like Outlook, Google Calendar or even external systems (EHRs) for their daily work. Their non-customer meetings like “Out for training for 2 hours” or “Out for lunch” are setup in an external calendar. Obviously you would want Scheduler to stop showing up these times slots to your customers.

Below are two ways to achieve this

1. Using a Syncing solution

Here you can use a Syncing solution (In Exchange to Salesforce mode or X2S) which pulls that training appointment setup in the banker’s external calendar from your external calendar (Lets call it Exchange for now) to the Salesforce calendar.

Make sure, you have enabled the Scheduling Policy switch : “Check Salesforce Calendar for Resource Availability.” This ensure that Scheduler will not show any slots that have Salesforce Calendar events at that time. (Only Salesforce Calendar Events that have Show Time As = Free are not considered.)

So how does this work?

Now say your banker has booked 2 hours of training in their exchange calendar. The syncing solution will make an asynchronously call to pull this appointment from Exchange to the Salesforce calendar. When a customer loads the Scheduler interface*, Scheduler will consider existing Service Appointments and also check the events in the Salesforce calendar. Scheduler will then merge all the unavailable times from both these sources and then provide the free time slots. Hence, the 2 hour banker’s training will not show up as a free slot in the Scheduler’s interface*.

Documentation Link:

Usually syncing solutions gives an end to end solution which can connect to your external calendars via an easy configuration.

Salesforce has a product called Einstein Activity Capture which can do this.

2. Using Apex Interface capability

This helps get on-demand availability by building an apex class which pulls availability from an external calendar.

Obviously, here customers will have to make investment to build & maintain this middleware that reads from their external calendar. This needs significant expertise on the customer side but gives more control of the security model and could give more flexibility in case the customers have a complex external calendar architecture.

There are already partners out there who have built out adaptors to connect to this apex interface. (Check the comments 🙂 )

One thing to keep in mind is that this solution is NOT a replacement for the standard syncing solution. This will exclusively be used with Scheduler & only for service appointments.

So how does it work?

Once you have built this middleware, make sure, you have enabled the Scheduling Policy : “Check External Systems for Resource Availability” and called this interface that you have built. This ensures that Scheduler is checking your external system for availability.

Now say your banker has booked 2 hours of training in their exchange calendar. When a customer loads the Scheduler interface*, Scheduler will consider existing Service Appointments and also get the availability synchronously from this Apex class which you have configured to reach out to your Exchange server and get those unavailable 2 hours. Scheduler will then merge all the unavailable times from both these sources and then provide the free time slots. Hence, the 2 hour banker’s training will not show up as a free slot in the Scheduler’s interface*.

Doc link to get on demand availability (Reading from external calendars):

* I used Scheduler Interface generically but it actually includes multiple touch points depending on how you have built out your appointment booking experience using the various options provided by Scheduler (See below)

From Chris Albanese: Your Branding on the Salesforce Scheduler Flows

Salesforce Scheduler flows are just flows. You can supplement the screens with any information you want with simple configuration.


Adding a display field in the same Screen element where the Select Work Type Groups is. The example includes merge fields and an image field.

From Shantinath Patil: Location Screen Tips and Tricks

Salesforce scheduler provides you a really nice component to search for a location. You can enter any address or zip code in the provided input box and get the desired territory (if it’s correctly configured! duh! ).

There are certain tricks involved in this out-of-the-box component you can play around with and make it work according to your need.

Distance measure (kms or miles):

This is a straightforward setting where you can define the search radius to be in miles or kilometers. By default, it is in miles. However, if you want to change it to kilometers, you need to change a flow variable called “distanceUnit”. This variable is of type text and valid values for this are mi or km. If you put any other value, this component will simply reject it and will show default miles. So make sure you have set it only to one of those accepted values.

Screenshot from 2021-05-01 22-45-32.png

Setting the default distance value

There is another attribute to set the default search radius as well. It’s a flow variable called “locationDistance”. Supported values for this variable are [ 5, 10, 25, 50, 100 ] if the distance unit is miles and [ 10, 20, 50, 100, 200 ] if the distance is kilometers. By default is 5 miles OR 10 kilometers depending on your distance unit setting. If you set any value other than the above specified, it will set back to its default value. So make sure you set it correctly.

Screenshot from 2021-05-01 23-43-18.png

Skip Location Screen:

Often customers want to create virtual territories or want to skip the territory selection and instead preselect a service territory in the Scheduler flows. It’s pretty simple to customize the flow (using clicks not code) to make this happen!

Here is one of the way we can do that:

  1. Copy the Service Territory Id of the Service Territory which you want to auto select in the flow. (xYou can do this by going to your Service Territory record and selecting the Id from the URL as shown below)
  1. Open your flow. Open Initial Assignment node.
Screenshot from 2021-05-04 16-33-04.png
  1. In the Edit Assignment screen, look for ServiceAppointment > Territory ID field. Put the Id of territory you have copied in earlier stages.
  1. Since now we have already chosen a territory, we do not need to show the location screen to the user. We can simply remove it from the flow. Just join the lines from the Appointment Type Screen node to the Resource Decision box (considering this is an outbound flow).
Screenshot from 2021-05-04 16-39-28.png
  1. Hit that Save and activate button and voila! You won’t see that location screen anymore!

Just to keep in mind:

  1. Salesforce scheduler is a Precision Scheduling Engine which checks calendars in real-time so say you have 1000 resources with the same skills, the engine has to parse through calendars (both internal & external) of resources to come up with time slots. So definitively test the performance of this setup.
  2. Since you will be setting only the service territory id, you will not see Address populated on Service Appointment. So if you need that, make sure you populate those values in the Assignment screen itself ( step number 3 above).

Auto-populate Location:

There are 2 design attributes on the location screen among others. Those are called Latitude and Longitude. These attributes take values set in the flow variables “locationLatitude” and “locationLongitude” respectively. These are geolocation attributes that get set when you select a location on that screen. Since these attributes are available for input in flow builder, we can set those values beforehand so that the location component will populate with a territory as soon as it loads.


Let us take an example of a territory location: Market Street Branch in San Francisco. Geolocation for this would be 37.793872°, -122.394865°. You can get this value if you google for geo-location a certain address or simply query one of the territories with that address [SELECT Latitude, Longitude FROM ServiceTerritory]. We can set these values in the design attributes which take lat lang as input.

Screenshot from 2021-05-01 23-03-41.png
Screenshot from 2021-05-01 23-04-05.png

Just save this and you will get all the locations selected from the default radius of the search.

Now, to make it more interesting, let us try auto-populating lat lang info from the user’s browser. We can make use of simple HTML Geolocation API. Since location is related to users’ privacy, this API will return the location once the user approves it.

Since the location screen is an OOB component, we will have to create a new component that will run this HTML Geolocation API. Also, this component will have to execute before the location screen is loaded so that as soon as the location screen loads, we will get location coordinates.

Here is a sample component which will give you output as gelocation is design attributes:

<aura:component implements="lightning:availableForFlowScreens" access="global" >

<aura:attribute name="latitude" type="String" description="latitude of the browser location" />
<aura:attribute name="longitude" type="String" description="longitude of the browser location" />

<aura:handler name="init" value="{!this}" action="{!c.doInit}"/>

its controller:

doInit : function(component, event, helper) {
var geolocation = {
lat: position.coords.latitude,
lng: position.coords.longitude
component.set("v.longitude", geolocation.lng);

and design file:

<design:attribute name="latitude" label="Latitude" />
<design:attribute name="longitude" label="Longitude" />

Once the component is created, we can add that to any screen before the location screen. Since we are using OOB flow, let’s configure it at the Appointment Type Screen.


That’s it! Activate and run the flow. Once you land on the Select Appointment Type screen, our geolocation component will ask users’ consent to share the location. If the user approves the consent, then you will see service territory getting searched according to the users’ location. If there are no territories available in that geolocation, the user will get a standard message as: No results for that Work Type Group found in that service territory. Try a different address, or expand your search area. Users can then search specific locations from the search bar available on the screen.

All the above code is bundled at It contains the above component and a flow which makes use of it.

Do checkout how you can add a Google maps interface to the location screen in this blog:

From Shantinath Patil: Smart Service Resource Onboarding


One of the challenges we get while going live with Salesforce Scheduler is to load Service Resources. This challenge includes loading resources initially, as well as maintaining them.

Now, To make a service resource; schedulable service resource, you have to take different steps, such as

  1. Create Service Resource record
  2. Assign Skills with a date range to that resource
  3. Assign Service Resource to different territories that he will support
  4. Assign Salesforce Scheduler permission set

Of course, you can use a data loader for this activity, however, it becomes difficult to keep mapping those ids in different CSV files which may lead to incorrect mapping of resources. Data loader does not support inserting bulk data into multiple objects in one go! Eventually, it becomes cumbersome for admins, especially if resources are multi-skilled, provide services at different branches OR have different operating hours at different locations. To simplify these steps, we can use a sample example to maintain service resources. All you have to do is to maintain CSV files with all the information you need! The crux of the whole logic here is to maintain this CSV file in a static resource and iterate over records in it with help of APEX.

For this particular blog, we will consider below scenario:

Screenshot from 2021-04-23 22-17-35.png

At Universal Banking Solutions company, there are 5 service resources.

  1. Karl Schmidt is a banker who is serving customers for their wealth management needs at Market Street and Golden Gate Ave branch. He caters English speaking clients at the Market Street branch and German-speaking clients at Golden Gate Ave branch.
  2. Rachel Adams is another resource who looks at general banking.
  3. Ryan Dobson serves business banking needs at the Market Street branch.
  4. Jacob Smith and Jessie Park take care of Wealth Management at the Market Street branch. However, they speak English and Korean respectively.

At an initial glance, we can see how we can map skills based on each resource. Here what skills look like:

Wealth ManagementWealth Management_English
Wealth ManagementWealth Management_German
Wealth ManagementWealth Management_Korean
General BankingGeneral Banking_English
Business BankingBusiness Banking_English

Since this is a straightforward mapping, we can load this data via a data loader. Next comes the fun part where we will map correct resources to correct skills at respective branches.

Part 1:

Let’s begin with creating service resources. To create a service resource, all we need is a user record reference. All other information on the Service Resource record is the same across Salesforce Scheduler implementation. So, if we create a CSV with just one column it will suffice. We can take care of the rest in our APEX logic.

Here is a sample CSV:


Once we have this CSV in static resource, we can load it in an APEX class and iterate over it. Below is a pseudo logic:

//vFileName is static resource name
List<StaticResource> defaultResource = [SELECT Body
FROM StaticResource
WHERE Name = :vFileName];
blob tempB = defaultResource[0].Body;
String contentFile = tempB.toString();
String[] filelines = contentFile.split('\n');
filelines.remove(0); //This is to remove CSV header!

After having all CSV rows in a list of strings, we can iterate over it to create an instance of Service Resource.

Set<String> vSetStringUserNames = new Set<String>();
for (Integer i = 0; i < filelines.size(); i++) {
String[] inputvalues = filelines[i].split(',');
vSetStringUserNames.add((inputValues[0]).replaceAll('\r\n|\n|\r'.toLowerCase(), ''));

List<ServiceResource> vListServiceResource = new List<ServiceResource>();
Map<String, User> vMapUserToId = new Map<String, User>();
for (User vUser : [SELECT Id, UserName, Name
WHERE UserName IN :vSetStringUserNames
]) {
vMapUserToId.put(vUser.UserName, vUser);
for (String vUserName : vSetStringUserNames) {
if (vMapUserToId.containsKey(vUserName)) {
ServiceResource vResource = new ServiceResource();
vResource.RelatedRecordId = vMapUserToId.get(vUserName).Id;
vResource.ResourceType = 'T';
vResource.Name = vMapUserToId.get(vUserName).Name;
vResource.IsActive = true;

INSERT vListServiceResource;

In the end, we will get all matching users mapped with service resource records. PS: Once you create a Service Resource, you cannot delete it. You can only deactivate it. So make sure you have correct data in CSV.

Part 2:

Now moving on to mapping skills. This is needed when your org has skill matching enabled. Based on observations we made of resources at Universal Banking Solutions:

ryan.dobson@example.comBusiness BankingEnglish2021-04-30T17:30:00.000+0000
rachel.adams@example.comGeneral BankingEnglish2020-12-04T00:00:00.000+0000 ManagementGerman2021-05-19T00:00:00.000+0000 ManagementEnglish2020-08-08T00:00:00.000+0000
jacob.smith@example.comWealth ManagementEnglish2018-02-04T00:00:00.000+0000
jessie.park@example.comWealth ManagementKorean2019-11-09T00:00:00.000+0000

Since we have made DeveloperName of skill be matched with language capability, a combination of SkillName and Language will suffice. We will also map Skill Start date as those may differ from resource to resource.

In this part too, we will fetch all rows in a list of strings from CSV in a static resource. Once all rows are parsed, below is a pseudo logic that will iterate over it and create ServiceResourceSkill records.

Set<String> vSetStringUserNames = new Set<String>();

for (Integer i = 0; i < filelines.size(); i++) {
String[] inputvalues = filelines[i].split(',');
vSetStringUserNames.add((inputValues[0]).replaceAll('\r\n|\n|\r', ''));

//First fetch all Service Resource records based in first column
Map<String, Id> vMapServiceResourceToUserName = new Map<String, Id>();
Map<String, Id> vMapSkillNameToId = new Map<String, Id>();
if (!vSetStringUserNames.isEmpty()) {
for (ServiceResource vServiceResource : [SELECT Id, Name, RelatedRecord.UserName
FROM ServiceResource
WHERE RelatedRecord.UserName IN :vSetStringUserNames
]) {
vMapServiceResourceToUserName.put(vServiceResource.RelatedRecord.UserName, vServiceResource.Id);

//Get all the skills based in the org.
//We can even fetch only limited number of skills from second column of CSV.
for (Skill vSkill : [SELECT Id, DeveloperName FROM Skill]) {
vMapSkillNameToId.put(vSkill.DeveloperName, vSkill.Id);

List<ServiceResourceSkill> vListServiceResourceSkill = new List<ServiceResourceSkill>();

//To make sure we have unique combination of skill matching
Set<String> vSetDeDup = new Set<String>();

//Now iterate over all the data in CSV rows.
for (Integer i = 0; i < filelines.size(); i++) {
String[] inputvalues = filelines[i].split(',');
String firstCol = (inputValues[0]).replaceAll('\r\n|\n|\r', '');

List<String> vListAllSkills = new List<String>();
String vSkill = (inputValues[1] +'_' +
inputValues[2]).replaceAll('\r\n|\n|\r', '');

for (String vSkillKey : vListAllSkills) {
String vSkillId = vMapSkillNameToId.containsKey(vSkillKey)
? vMapSkillNameToId.get(vSkillKey)
: null;
String vSerResId = vMapServiceResourceToUserName.containsKey(firstCol)
? vMapServiceResourceToUserName.get(firstCol)
: null;
String deDupKey = vSkillId + vSerResId;
if (!vSetDeDup.contains(deDupKey)) {
ServiceResourceSkill vSrSkill = new ServiceResourceSkill();
vSrSkill.SkillId = vSkillId;
vSrSkill.ServiceResourceId = vSerResId;
vSrSkill.EffectiveStartDate = (inputValues[3]).replaceAll('\r\n|\n|\r', '');
vSrSkill.EffectiveEndDate =;



INSERT vListServiceResourceSkill;

Once this is successfully executed, you will get all the correct mapping of Skills to Service Resource!

Part 3:

Now to map a correct resource to territory, we need 2 columns in CSV. One should be the username and the other is the service territory name. We can add more columns in CSV to have a smart mapping of Operating Hours as well. For operating hours we can query for its name in our logic (considering operating hours are already loaded in the system).

ryan.dobson@example.comMarket Street Branch2021-01-10T00:00:00.000+0000Morning Shift Market StreetP
rachel.adams@example.comMarket Street Branch2020-12-04T00:00:00.000+0000Operating Hours Market StreetP Street Branch2021-05-19T00:00:00.000+0000Morning Shift Market StreetP Gate Avenue2020-08-08T00:00:00.000+0000Afternoon Shift Golden Gate AveS
jacob.smith@example.comMarket Street Branch2018-02-04T00:00:00.000+0000Morning Shift Market StreetP
jessie.park@example.comMarket Street Branch2019-11-09T00:00:00.000+0000Afternoon Shift Market StreetP

The below code snippet will process the above CSV and insert data into the ServiceTerriotryMember entity.

Set<String> vSetStringUserNames = new Set<String>();
Set<String> vSetStringTerrNames = new Set<String>();
Set<String> vSetStringOHNames = new Set<String>();

for (Integer i = 0; i < filelines.size(); i++) {
String[] inputvalues = filelines[i].split(',');
vSetStringUserNames.add((inputValues[0]).replaceAll('\r\n|\n|\r', ''));
vSetStringTerrNames.add((inputValues[1]).replaceAll('\r\n|\n|\r', ''));
vSetStringOHNames.add((inputValues[3]).replaceAll('\r\n|\n|\r', ''));

Map<String, Id> vMapServiceResourceToUserName = new Map<String, Id>();
Map<String, Id> vMapSerTerNameToId = new Map<String, Id>();
Map<String, Id> vMapSerOHNameToId = new Map<String, Id>();

//First fetch all existing data to map to CSV column values.
if (!vSetStringUserNames.isEmpty()) {
for (ServiceResource vServiceResource : [SELECT Id, Name, RelatedRecord.UserName
FROM ServiceResource
WHERE RelatedRecord.UserName IN :vSetStringUserNames
]) {
vMapServiceResourceToUserName.put(vServiceResource.RelatedRecord.UserName, vServiceResource.Id);
for (ServiceTerritory vSerTer : [SELECT Id, Name,
FROM ServiceTerritory
WHERE Name IN :vSetStringTerrNames
]) {
vMapSerTerNameToId.put(vSerTer.Name, vSerTer.Id);
for(OperatingHours vOHNames: [SELECT Id, Name
FROM OperatingHours
WHERE Name IN: vSetStringOHNames]){
vMapSerOHNameToId.put(vOHNames.Name, vOHNames.Id);

List<ServiceTerritoryMember> vListServiceTerrMember = new List<ServiceTerritoryMember>();
Set<String> vSetDeDup = new Set<String>();
for (Integer i = 0; i < filelines.size(); i++) {
String[] inputvalues = filelines[i].split(',');
String firstCol = (inputValues[0]).replaceAll('\r\n|\n|\r', '');
String vTerrName = (inputValues[1]).replaceAll('\r\n|\n|\r', '');
String vOHName = (inputValues[3]).replaceAll('\r\n|\n|\r', '');

String vServiceResourceId = (vMapServiceResourceToUserName.containsKey(firstCol))
? '' + vMapServiceResourceToUserName.get(firstCol)
: null;
String vServiceTerrId = (vMapSerTerNameToId.containsKey(vTerrName))
? '' + vMapSerTerNameToId.get(vTerrName)
: null;
String vOperatingHourId = (vMapSerOHNameToId.containsKey(vOHName))
? '' + vMapSerOHNameToId.get(vOHName)
: null;

String deDupKey = vTerrName + vServiceResourceId + vServiceTerrId;
if (!vSetDeDup.contains(deDupKey)) {
ServiceTerritoryMember vSerTer = new ServiceTerritoryMember();
vSerTer.ServiceResourceId = vServiceResourceId;
vSerTer.ServiceTerritoryId = vServiceTerrId;
vSerTer.OperatingHoursId = vOperatingHourId;
vSerTer.EffectiveStartDate = (inputValues[2]).replaceAll('\r\n|\n|\r', '');
vSerTer.TerritoryType = (inputValues[4]).replaceAll('\r\n|\n|\r', '');


INSERT vListServiceTerrMember;


In conclusion, if we can make a little customization using CSV data and APEX, we can easily maintain Service Resource data. Since loading a large CSV file may get into APEX heap and CPU time limits, we can combine all logic into a single batch class and process data in bulk. Sample batch class is here: This Git repo will show you how you can execute it and make it work for all 3 objects. In case of missing data or exceptions during data insert, this batch class will send an email with a CSV attachment with error details. Admin can, later on, mitigate issues in that CSV and upload data with the data loader.

All this logic will help you maintain existing resources and onboarding new resources in your org. Even during loading data into your sandboxes, this comes in handy to quickly load some dummy data, so that developers and QA can test those corner cases which may get unnoticed if you create a small data set.

This will surely make your System Admins life easy!

Source Code

From Chris Albanese: Spring 21 Feature: Resource Priority and Filtering

Salesforce Scheduler introduced a new feature in the Spring ’21 Release which allows you to control very precisely what resources are considered when scheduling an appointment. This provides many benefits and opens up the solution to address additional requirements.

Resource Priority and Filtering gives you an easy way to control and influence what resources are considered when scheduling an appointment. Up to this point it was the required skills of the Work Type and the Service Territory Membership that defined the pool of resources. Now you can layer on another filter. What are some use cases for this?

  • Gold/Silver/Platinum tiered service
  • Preferred Language
  • Fair distribution of work to resources (think Round Robin Lite)
  • Prioritized distribution of work (today I want all of my appointments to go to Connie)

It helps you address these requirements while, at the same time, keeps the number of Work Type Groups down to a minimum.

Check this out below write up and also this quick video demo.

Work Type Groups, Work Types and Service Territories

Let’s say you’re a bank and you offer appointment scheduling for general banking, lending and retirement and investing services. And let’s also say that you provide differentiated services based on the customer’s investments with the bank, where customers with lots of investments are serviced by ‘gold’ advisors, customers with a good amount are serviced by ‘silver’ advisors and everyone else is serviced by ‘bronze’ advisors.

By now you’ve probably figured out that for a given Service Territory, you can only have 1 work type from a given work type group.
Let’s look at a example:

  • Service Territory = Market Street Branch
  • Work Type Group = General Banking
  • Work Types = General Banking Gold, General Banking Silver, General Banking Bronze

If you want to offer Gold, Silver and Bronze General Banking Services, you might think you could add those 3 Work Types to the Market Street Branch. Well, you can’t because you can only pick 1 from the the General Banking Work Type Group. So you could create Work Type Groups for General Banking Gold, General Banking Silver and General Banking Bronze. And if you want to offer the same for Lending and Retirement Services you would wind up with 9 Work Type Groups when you really want 3.


What you want the user to see – 3 Work Type Groups


What they will see – 9 Work Type Groups

Resource Priority and Filtering to the Rescue

Resource Priority and Filtering allows you to offer only the 3 Work Type Groups but still ensure that Gold customers are serviced by gold bankers, silver by silver bankers, and bronze by bronze bankers.

What do you need to do? Leverage the Resource Priority and Filtering Feature – see the help and training article.
There is a new parameter the Resource Selection screen that allows you to filter the list of resources above and beyond what the required skills and service territory provide. Check out my example below for how to set this up.



When running the scheduling flow, use the Filter by Resource Ids parameter to consider only resources whose Tier matches the customer’s Tier.

New fields

To the account object, I’ve added a picklist field called Tier, with values of Gold, Silver and Bronze.
And to the service resource object a picklist field called Tier, with values of Gold, Silver and Bronze. I’ve also created a formula field on the service territory member object which equals serviceresource.tier__c. (this helps make the flow easier)

Flow Changes

In the flow, add a step to retrieve the customer’s tier. Also add a step to retrieve all of the service territory member records where the tier matches. Make sure that you retrieve the STMs after the location step, since we need to know the territory id. In the screen shot below, you can see that after the Location Screen, I’ve added the 2 get records, the loop and the 2 assignment steps.


Then loop through all of the STMs you find to concatenate them together in a string. Here’s what’s inside the add SR ids to string assignment step in the screenshot above.


Since there’s an extra comma at the end of this, I created a formula field to which excludes the last character of the string.


And then assign the formula to the FilterByResourcesId string.



Only 3 Work Type Groups are needed yet we can still provide Gold, Silver and Bronze service. In the screen shot to the right, 5 resources would have been returned without this feature, but I was able to limit the list to just the 2 gold bankers.


Resource Priority and Filtering Guidelines

Like everything else in Salesforce, there are best practices, guidelines and limits to consider. For Resource Priority and Filtering, 50 and 10 are the magic numbers. 50 is the maximum number of Service Resource Ids that can be passed into the filter, BUT, you want to make sure that after the application of the relevant skills, territory and availability checks, the resulting number of service resources with availability is no greater than 10.

Part II – Using Skills and APEX to power Resource Priority and Filtering

Check this out to learn how to further extend this feature using Apex.

In the example above, we used a service resource custom picklist field to identify which resources had the Gold/Silver/Bronze “skill”. What if we wanted to use the skill object itself. In other words, we have set up skills for Gold, Silver and Bronze and we have assigned those to bankers accordingly. We want the flow to consider only those resources that have the both the skills needed through the work type group (and related work type) as well as the skill needed from the account tier.

Getting Records from 1 object where Service Resource Ids are in another object

Normally, I would say do this all in flow but in reality we need to do a loop inside of a loop – Find all resources in the territory who also possess the desired tier skill. Unfortunately, we cannot do this type of query in flow. As you might have seen with a simple web search, flow does not support the equivalent of a SOQL “IN” condition. But we can do this with an APEX class with an invocableMethod, and we can do it with just a few lines of code.

Create an invocableMethod

As you’ve probably seen in other blogs, invocableMethods extend the power of flow with Apex code. In my Apex class below, I’m accepting a list of service territory member records (the resources in the territory) and a skill record and returning a string which is a comma separated list of service resource ids of only those service resources with the skill.

Remember with invocableMethods, you pass in a list of something and it returns a list of something. In this example, we’re passing in a list of a user-defined type called ResourceFilter, which consists of a List of Service Territory Member records and a Skill record and the method returns a list of strings.

Apex class example

//this class returns a common separated string of service resource ids
//it accepts a list of service territory member records and filters those with the required skill
public with sharing class PriorityResources {

public static list<string> getResources(list<ResourceFilter> rfList) {
if (rfList==null) return new list<string>{''};
set<string> setResource = new set<string>();
set<string> setResourcewithSkill = new set<string>();
ResourceFilter rf = rfList[0];
//create a set of service resource ids from the stmlist passed in
for(serviceterritorymember stm : rf.stmList) setResource.add(stm.serviceresourceid);
try {
datetime dt =;
//create a set of service resource ids that have the skill passed in
for(serviceresourceskill srskill : [select serviceresourceid from serviceresourceskill where serviceresourceid in :setResource and skillid = and effectivestartdate <= :dt and (effectiveenddate = null or effectiveenddate >= :dt) ])
catch(system.exception e) {
return new list<string>{''};
//convert the set of resource ids into a comma separated string
return new list<string>{String.join(new list<string>(setResourcewithSkill),',')};

public class ResourceFilter {
public list<serviceterritorymember> stmList;
public Skill skillRequired;

Flow Example

Like in the previous example, in the flow, add a step to retrieve the customer’s tier. Also add a step to retrieve all of the service territory member records where the territory matches. And also retrieve the skill record that matches the customer’s tier. Make sure that you retrieve the STMs after the location step, since we need to know the territory id. In the screen shot below, you can see that after the Location Screen, I’ve added the 3 get records and the Apex action. You can also see how the Apex action is setup: I’m passing in the skill object and the STM objects (this is a list) and the Apex class will return the CSV string of Service Resources into the variable FilterByResourcesId. That’s it, you will have a list of Service Resources who work in the Territory who have the Tier Skill and these will be passed to the resource selection screen. This screen will further filter out only those resources which also have the skills needed for the selected Work Type Group (and work type).


From Chris Albanese: Build your own appointment review screen

Do check out the existing possibilities to make changes to the appointment review screen before trying this out.

When you look at a Salesforce Scheduler flow, there’s a core action called saveAppointment. Did you ever wonder what it does, what the inputs are and whether you can tailor it?


What does it do?

It takes the values gathered during the flow interview and creates or updates a ServiceAppointment record and an associated AssignedResource record. If you provide optional attendees, it will also create AssignedResource records for each optional service resource id provided.

The help and training article is locate here, but let’s look at what’s inside the inputs provided this action.

What is contained in the input fields?

The easiest way to see what is passed into the Save Action is to run the flow in debug mode. There you will see the values that are stored into each field passed into the Save Action.

  • Look in the right hand panel of the debugger. You can see the value of each field.
  • On a given screen, you will have to press next to see what the values are for that screen.
  • You can scroll up and down in the debug details panel to review the values and every step.

After you press finish, scroll up and check out what is in the Save Appointment step.


What do we see here?

  • optionalAttendees = a list of comma separate values of Service Resource Ids.
  • serviceAppointmentFields = a JSON string of the Service Appointment fields, including fields such as:
    • start and end times
    • service territory id
    • Service Resource ID for the service resource the appointment is for
    • ParentRecordId = the Account Id (or the opportunity or lead id)
    • other fields on the page layout – there’s a custom field I added to the Service Appointment object and page layout called My_Custom_Field
  • selectedTimezone – the timezone selected by or defaulted for the user


  • the Service Appointment Id

How do these values get set?

In a Salesforce Scheduler flow, the Screen steps each have components on them that save values to variables in the flow. For example, the Select Location screen has a screen component that saves the Service Territory Id and Address fields to the corresponding fields in the ServiceAppointment variable, as you can see in the screen shot below.


But how does the serviceAppointmentFields variable get set?

This field is the bulk of what is passed into the Save Action.

It gets set by the Review Screen component that is located on the Review Screen.

  • This component is located in the Review Screen.
  • It gives the user the ability to review the appointment prior to saving. For example, they can enter a value into the Subject field or other fields, including custom ones.
  • But this component is really a black box.

What if I wanted to set the values for the serviceAppointmentFields variable myself?

So as I have written above, the Review Screen component is really a black box. It takes the fields from the review screen along with other fields in the ServiceAppointment object variable and the WorkTypeGroupId variable and creates a JSON string from them.

What is JSON you ask? You’ve probably heard of it and you can definitely Google it and find out more, but it’s essentially a collection of name:value pairs, and you see that in the debugger output above. It’s very powerful and allows you to store lots of rich information, including arrays, in a text field.

You can create your own JSON string using a formula field. You can pass that formula field into the Save Appointment action. Or you can assign it to the serviceAppointmentFields variable in an assignment step.

Here’s an example of a formula field that I have used to pass into the Save Appointment action:

'{"Description":"' &{!ServiceAppointment.Description}& 
'",'& '"SchedStartTime":"' & substitute(substitute(text({!ServiceAppointment.SchedStartTime}),' ','T'),'Z','.000Z')&
'",'& '"SchedEndTime":"' & substitute(substitute(text({!ServiceAppointment.SchedEndTime}),' ','T'),'Z','.000Z')&
'",'& '"Subject":"' & {!ServiceAppointment.Subject}&
'",'& '"AdditionalInformation":"' & {!ServiceAppointment.AdditionalInformation}&
'",'& '"AppointmentType":"' & text({!ServiceAppointment.AppointmentType})&
'",'& '"Comments":"' & {!ServiceAppointment.Comments}&
'",'& '"ParentRecordId":"' & {!ServiceAppointment.ParentRecordId}&
'",'& '"Street":"' & {!ServiceAppointment.Street}&
'",'& '"City":"' & {!ServiceAppointment.City}&
'",'& '"State":"' & {!ServiceAppointment.State}&
'",'& '"PostalCode":"' & {!ServiceAppointment.PostalCode}&
'",'& '"Country":"' & {!ServiceAppointment.Country}&
'",'& '"WorkTypeGroupId":"' & {!WorkTypeGroupId}&
'",'& '"ServiceTerritoryId":"' & {!ServiceAppointment.ServiceTerritoryId}&
'",'& '"ServiceResourceId":"' & {!ServiceResourceId}&
'",'& '"Phone":"' & {!ServiceAppointment.Phone}&
'",'& '"Email":"' & {!ServiceAppointment.Email}&
'",'& '"IsAnonymousBooking":"' & if({!ServiceAppointment.IsAnonymousBooking},"True","False")&
'",'& '"isSlotChanged":"' & "False"&

What do you see in the field above?

Lot’s of care taken to make sure each name:value pair is enclosed in double quotes and that each pair has a colon between it and is separated by a comma from the next pair. And the whole string is wrapped in curly braces {}. Note the added complexity of the SchedStartTime and SchedEndTime fields, to convert them from datetime data types to a text type in a “”yyyy-MM-dd’T’HH:mm:ss.SSSZ”” format. I’ve also had to use a text() function to convert the picklist field called AppointmentType to a string value.

Can I replace the out of the box review screen with my own review screen?

Yes you can! You can display whatever content you like using standard flow components such as Display Text and you and capture user input using standard flow components such as Input Text or Date.

You just need to make sure you create the JSON string to pass into the Save Appointment action.

Minimum values required: make sure the at least the following fields are passed in order to get the desired results :

{"ParentRecordId": "001xx0000000000000",
"ServiceTerritoryId": "0Hhxx0000000000000",
"ServiceResourceId": "0Hnxx0000000000000",
"WorkTypeGroupId": "0VSxx0000000000000", (for new)
"WorkTypeId":"08qxx0000000000000" (for modify)
"SchedStartTime": "2021-02-21T20:30:00.000Z",
"SchedEndTime": "2021-02-21T21:00:00.000Z",
"IsAnonymousBooking": <VALUE SET IN FLOW VARIABLE>,
"isSlotChanged": <false for new, true for modify>,
"schedulingPolicyName": "<same policy name set in flow>"

Details of the Save Appointment inputs

  • Lead – use this for guest inbound flows (unauthenticated) only. This is a record (single) variable containing the new Lead record created for the guest user running the flow.
  • Optional Service Resource Ids – comma separated list of Service Resource Ids. This represents the optional resources for the service appointment, if present.
  • Selected Timezone – the timezone selected by or defaulted for the user – see this article for the list and use the value for the Timezone (the part of the TIME ZONE NAME field in parenthesis, such as America/Los_Angeles).
  • Service Appointment Fields – a single text field containing the JSON for the Service Appointment – extensively described above. For guest inbound flows, do not provide a value for parentRecordId, as this will be provided via the Lead object described above.
  • Service Resources – if using Multi-Resource scheduling – this is a single text field containing the JSON for the list of Service Resources and Territory Members selected, including the primary resource. Example below (record ids are obfuscated in this example).
  • [{“UserPhoto”:”https://xxxx–”,”ServiceTerritoryMemberId”:”0HuB0000000XXXXXXXX”,”IsActive”:true,”ResourceType”:”T”,”RelatedRecordId”:”005B000000XXXXXXXX”,”Id”:”0HnB0000000XXXXXXXX”,”Name”:”Connie Ruiz”,”sobjectType”:”ServiceResource”,”AttendanceType”:”Primary”},
    {“UserPhoto”:”https://xxxx–;,”ServiceTerritoryMemberId”:”0HuB0000000XXXXXXXX”,”IsActive”:true,”ResourceType”:”T”,”RelatedRecordId”:”005B000000XXXXXXXX”,”Id”:”0HnB0000000XXXXXXXX”,”Name”:”Anita Gonzalez”,”sobjectType”:”ServiceResource”,”AttendanceType”:”Required”},
    {“UserPhoto”:”https://xxxx–;,”ServiceTerritoryMemberId”:”0HuB0000000XXXXXXXX”,”IsActive”:true,”ResourceType”:”T”,”RelatedRecordId”:”005B000000XXXXXXXX”,”Id”:”0HnB0000000XXXXXXXX”,”Name”:”Mark Mayo”,”sobjectType”:”ServiceResource”,”AttendanceType”:”Required”}]
  • WorkType – if using a Modify flow, this is a record (single) variable containing the Work Type for the Service Appointment being rescheduled. It is not used for new appointments.