Skip to main content

Command Palette

Search for a command to run...

Summer '26: Define Picklist Values for Apex Action Inputs Without a Custom Property Editor

Updated
11 min read
Summer '26: Define Picklist Values for Apex Action Inputs Without a Custom Property Editor

Apex actions are one of the most useful ways to extend Flow when declarative logic is not enough. The Apex can be tested, reusable, and owned by a development team, while admins configure it inside Flow Builder.

The weak point is often the input experience. A method accepts a String, but that string is not always meant to be free text. It might represent a mode, priority, region, fallback behavior, or another value that must match a known set.

Salesforce already has Custom Property Editors for richer Flow configuration experiences. Summer '26 adds a lighter option for a narrower case: defining picklist values for Apex action inputs through metadata instead of building a custom Lightning Web Component.

The complete source code for this example is available on GitHub: View the project on GitHub

The problem

Consider an Apex action that creates a follow-up Task when an Account is created. The action needs two values from Flow:

@InvocableVariable(label='Urgency' required=true)
public String urgency;

@InvocableVariable(label='Industry Focus' required=true)
public String industryFocus;

Both inputs are strings at runtime, but neither is truly open-ended.

urgency should be one of these values:

LOW
NORMAL
HIGH

industryFocus should come from the active values of the standard Account Industry picklist.

Without input guidance, the person configuring the Flow has to know what to type. One admin might enter HIGH, another might enter High, and another might enter Urgent. The Flow can still be saved, but the Apex action must deal with the bad value later.

Apex validation is still required, but validation happens after configuration. The better experience is to show valid choices when the Flow is being configured.

The existing option: Custom Property Editors

Before this Summer '26 enhancement, Salesforce already had a way to improve the configuration UI for invocable actions: Custom Property Editors.

A Custom Property Editor is a Lightning Web Component that provides a custom UI when an admin configures a custom screen component or invocable action in Flow Builder. Salesforce documents interfaces such as inputVariables, builderContext, events for writing values back to Flow Builder, and a validate function for surfacing configuration errors.

That approach is still the right choice when the configuration experience needs a real custom UI. Examples include dependent fields, object and field selectors, conditional sections, multi-input validation, or a guided setup experience.

But if the requirement is only “show this input as a list of valid values,” a full LWC can be more work than necessary.

The Summer '26 solution

Summer '26 introduces support for defining picklist values for Apex action inputs with InvocableActionExt metadata.

The key attribute is:

<key>ProvidedValuesList</key>

Salesforce supports two approaches.

A static list defines values directly in XML:

<value>LOW|Low,NORMAL|Normal,HIGH|High</value>

A dynamic list references an Apex class:

<value>apex://AccountIndustryPicklist</value>

For static values, the value before the pipe character is the value passed to Apex. The value after the pipe character is the label used in the configuration experience.

The important design point is that the Apex method signature does not change. The action still receives a String. The Flow metadata still stores the selected value as a string. This feature improves how the input is presented during configuration; it does not replace runtime validation.

Prerequisites

  • Salesforce Summer '26 org or preview org where the feature is available

  • API version 67.0 for the examples in this article

  • Flow Builder access

  • Permission to deploy Apex classes and metadata

  • Salesforce CLI and VS Code, if deploying from source

  • Awareness that Salesforce documents a limit of up to 500 total values per input parameter

  • For managed packages, the package publisher controls packaged Apex and related metadata

Step-by-step guide

Create the invocable Apex action

The example uses one action: CustomerFollowUpAction.

It creates a follow-up Task for an Account. The two inputs we care about for this feature are urgency and industryFocus.

public with sharing class CustomerFollowUpAction {
    public class Request {
        @InvocableVariable(
            label='Account Id'
            description='The Account that needs a follow-up task.'
            required=true
        )
        public Id accountId;

        @InvocableVariable(
            label='Urgency'
            description='Static picklist example. Values are defined in InvocableActionExt metadata.'
            required=true
        )
        public String urgency;

        @InvocableVariable(
            label='Industry Focus'
            description='Dynamic picklist example. Values come from AccountIndustryPicklist.'
            required=true
        )
        public String industryFocus;
    }

    public class Result {
        @InvocableVariable(label='Success')
        public Boolean success;

        @InvocableVariable(label='Message')
        public String message;

        @InvocableVariable(label='Task Id')
        public Id taskId;
    }

    @InvocableMethod(
        label='Create Customer Follow-Up Task'
        description='Creates a follow-up Task for an Account.'
        category='Sales'
    )
    public static List<Result> createTasks(List<Request> requests) {
        List<Result> results = new List<Result>();

        if (requests == null || requests.isEmpty()) {
            return results;
        }

        Set<Id> accountIds = new Set<Id>();

        for (Request request : requests) {
            Result result = new Result();
            result.success = false;
            results.add(result);

            if (request == null || request.accountId == null) {
                result.message = 'Account Id is required.';
                continue;
            }

            accountIds.add(request.accountId);
        }

        Map<Id, Account> accountsById = new Map<Id, Account>([
            SELECT Id, Name
            FROM Account
            WHERE Id IN :accountIds
        ]);

        List<Task> tasksToInsert = new List<Task>();
        List<Integer> resultIndexes = new List<Integer>();

        for (Integer i = 0; i < requests.size(); i++) {
            Request request = requests[i];

            if (request == null || request.accountId == null) {
                continue;
            }

            Account accountRecord = accountsById.get(request.accountId);

            if (accountRecord == null) {
                results[i].message = 'The Account was not found or is not visible to the running user.';
                continue;
            }

            String urgency = normalizeUrgency(request.urgency);

            if (urgency == null) {
                results[i].message = 'Urgency must be LOW, NORMAL, or HIGH.';
                continue;
            }

            if (String.isBlank(request.industryFocus)) {
                results[i].message = 'Industry Focus is required.';
                continue;
            }

            Task followUpTask = new Task();
            followUpTask.WhatId = accountRecord.Id;
            followUpTask.Subject = 'Follow up with ' + accountRecord.Name + ' - ' + request.industryFocus;
            followUpTask.Status = 'Not Started';
            followUpTask.Priority = toTaskPriority(urgency);
            followUpTask.ActivityDate = Date.today().addDays(daysUntilDue(urgency));
            followUpTask.Description =
                'Created by Flow using an Apex action with picklist-enabled inputs.\n' +
                'Urgency: ' + urgency + '\n' +
                'Industry Focus: ' + request.industryFocus;

            tasksToInsert.add(followUpTask);
            resultIndexes.add(i);
        }

        if (tasksToInsert.isEmpty()) {
            return results;
        }

        List<Database.SaveResult> saveResults =
            Database.insert(tasksToInsert, false, AccessLevel.USER_MODE);

        for (Integer i = 0; i < saveResults.size(); i++) {
            Integer resultIndex = resultIndexes[i];

            if (saveResults[i].isSuccess()) {
                results[resultIndex].success = true;
                results[resultIndex].taskId = saveResults[i].getId();
                results[resultIndex].message = 'Follow-up task created.';
            } else {
                results[resultIndex].success = false;
                results[resultIndex].message = saveResults[i].getErrors()[0].getMessage();
            }
        }

        return results;
    }

    private static String normalizeUrgency(String urgency) {
        if (String.isBlank(urgency)) {
            return null;
        }

        String normalized = urgency.trim().toUpperCase();

        Set<String> allowedValues = new Set<String>{
            'LOW',
            'NORMAL',
            'HIGH'
        };

        return allowedValues.contains(normalized) ? normalized : null;
    }

    private static String toTaskPriority(String urgency) {
        if (urgency == 'HIGH') {
            return 'High';
        }

        if (urgency == 'LOW') {
            return 'Low';
        }

        return 'Normal';
    }

    private static Integer daysUntilDue(String urgency) {
        if (urgency == 'HIGH') {
            return 1;
        }

        if (urgency == 'LOW') {
            return 7;
        }

        return 3;
    }
}

The validation in normalizeUrgency remains important. The picklist configuration helps Flow Builder users choose a valid value, but Apex should still protect its own contract.

Add the dynamic picklist provider

The static urgency values do not need another Apex class.

The dynamic industryFocus values come from Account.Industry, so we add a provider that extends VisualEditor.DynamicPickList.

global with sharing class AccountIndustryPicklist extends VisualEditor.DynamicPickList {
    global override VisualEditor.DataRow getDefaultValue() {
        for (Schema.PicklistEntry entry : getActiveIndustryEntries()) {
            return new VisualEditor.DataRow(entry.getLabel(), entry.getValue());
        }

        return new VisualEditor.DataRow('No active Account Industry values found', '');
    }

    global override VisualEditor.DynamicPickListRows getValues() {
        VisualEditor.DynamicPickListRows rows = new VisualEditor.DynamicPickListRows();
        Boolean hasRows = false;

        for (Schema.PicklistEntry entry : getActiveIndustryEntries()) {
            rows.addRow(new VisualEditor.DataRow(entry.getLabel(), entry.getValue()));
            hasRows = true;
        }

        if (!hasRows) {
            rows.addRow(new VisualEditor.DataRow('No active Account Industry values found', ''));
        }

        return rows;
    }

    private static List<Schema.PicklistEntry> getActiveIndustryEntries() {
        List<Schema.PicklistEntry> activeEntries = new List<Schema.PicklistEntry>();

        Schema.SObjectField industryField =
            Schema.SObjectType.Account.fields.getMap().get('Industry');

        if (industryField == null) {
            return activeEntries;
        }

        Schema.DescribeFieldResult describeResult = industryField.getDescribe();

        for (Schema.PicklistEntry entry : describeResult.getPicklistValues()) {
            if (entry.isActive()) {
                activeEntries.add(entry);
            }
        }

        return activeEntries;
    }
}

This class supports configuration. The selected value is still passed to the invocable action as a string.

Define the picklist values in metadata

Create the action extension metadata file:

force-app/main/default/invocableactionextensions/CustomerFollowUpAction.invocableactionextension-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<InvocableActionExt xmlns="http://soap.sforce.com/2006/04/metadata">
    <targets>
        <targetType>ActionParameter</targetType>
        <targetName>CustomerFollowUpAction.Request.urgency</targetName>
        <attributes>
            <key>ProvidedValuesList</key>
            <dataType>String</dataType>
            <value>LOW|Low,NORMAL|Normal,HIGH|High</value>
        </attributes>
    </targets>

    <targets>
        <targetType>ActionParameter</targetType>
        <targetName>CustomerFollowUpAction.Request.industryFocus</targetName>
        <attributes>
            <key>ProvidedValuesList</key>
            <dataType>String</dataType>
            <value>apex://AccountIndustryPicklist</value>
        </attributes>
    </targets>
</InvocableActionExt>

The first target defines static values for urgency.

The second target connects industryFocus to the dynamic provider class.

The targetName must align with the Apex action input structure:

CustomerFollowUpAction.Request.urgency
CustomerFollowUpAction.Request.industryFocus

If the class name, inner class name, or variable name does not match, the binding is not applied to the intended input.

Use the action in Flow

A Flow can call the Apex action like any other Apex action.

In Flow metadata, the selected values are still stored as strings:

<inputParameters>
    <name>accountId</name>
    <value>
        <elementReference>$Record.Id</elementReference>
    </value>
</inputParameters>

<inputParameters>
    <name>urgency</name>
    <value>
        <stringValue>HIGH</stringValue>
    </value>
</inputParameters>

<inputParameters>
    <name>industryFocus</name>
    <value>
        <stringValue>Technology</stringValue>
    </value>
</inputParameters>

That is expected. The extension metadata defines the input choices used during configuration; it does not change the stored runtime value.

Test the Apex behavior

The test passes string values directly because that is what the action receives at runtime.

@IsTest
private class CustomerFollowUpActionTest {
    @IsTest
    static void createsTaskForAccount() {
        Account accountRecord = new Account(Name = 'Acme Solar');
        insert accountRecord;

        CustomerFollowUpAction.Request request = new CustomerFollowUpAction.Request();
        request.accountId = accountRecord.Id;
        request.urgency = 'HIGH';
        request.industryFocus = 'Technology';

        Test.startTest();
        List<CustomerFollowUpAction.Result> results =
            CustomerFollowUpAction.createTasks(
                new List<CustomerFollowUpAction.Request>{ request }
            );
        Test.stopTest();

        System.assertEquals(1, results.size());
        System.assertEquals(true, results[0].success, results[0].message);
        System.assertNotEquals(null, results[0].taskId);
    }

    @IsTest
    static void rejectsInvalidUrgency() {
        Account accountRecord = new Account(Name = 'Bad Urgency Example');
        insert accountRecord;

        CustomerFollowUpAction.Request request = new CustomerFollowUpAction.Request();
        request.accountId = accountRecord.Id;
        request.urgency = 'ASAP';
        request.industryFocus = 'Technology';

        Test.startTest();
        List<CustomerFollowUpAction.Result> results =
            CustomerFollowUpAction.createTasks(
                new List<CustomerFollowUpAction.Request>{ request }
            );
        Test.stopTest();

        System.assertEquals(false, results[0].success);
        System.assert(results[0].message.contains('Urgency must be'));
    }
}

This test does not test Flow Builder. It tests the Apex action behavior and confirms that invalid values are still rejected.

Deploy and verify

Deploy the Apex classes and metadata together.

sf project deploy start --source-dir force-app --target-org my-org --wait 10

After deployment, open the Flow in Flow Builder and inspect the Apex action configuration. In an org where the Summer '26 enhancement is active, the configured values from ProvidedValuesList are used for the Apex action inputs.

If the inputs still appear as text fields, check the org release, API version, deployment result, targetName values, and whether the dynamic provider class compiled successfully.

Project files

  • CustomerFollowUpAction.cls
    Invocable Apex action called by Flow.

  • AccountIndustryPicklist.cls
    Dynamic provider for industryFocus.

  • CustomerFollowUpAction.invocableactionextension-meta.xml
    Defines ProvidedValuesList for the action inputs.

  • Account_Onboarding_Create_Follow_Up_Task.flow-meta.xml
    Record-triggered Flow that calls the action.

  • CustomerFollowUpActionTest.cls
    Apex tests for valid and invalid inputs.

ProvidedValuesList vs Custom Property Editor

ProvidedValuesList and Custom Property Editors solve different problems.

Use ProvidedValuesList when an input needs a simple list of valid choices. Good examples include priority, mode, region, action type, or fallback behavior.

Use a Custom Property Editor when the configuration UI itself needs to be custom. That includes dependent inputs, object and field selectors, conditional sections, custom validation messages, or a guided setup experience.

The distinction is useful: ProvidedValuesList describes choices for an input. A Custom Property Editor provides a custom LWC-based configuration interface.

Real-world impact

This feature is useful when Apex actions are configured by people other than the developer who wrote them. Instead of relying on documentation or exact string values, the valid choices live with the action metadata.

Use static values for stable options such as priority levels or processing modes. Use dynamic values when the choices should come from Salesforce configuration, such as active values from Account.Industry.

The boundary stays clean: Apex validates and executes the logic, InvocableActionExt describes simple configuration choices, and Custom Property Editors remain the better option when the setup experience needs more than a list.

Key takeaways

  • Summer '26 adds ProvidedValuesList support for Apex action input parameters.

  • Static values can be defined as VALUE|Label pairs in InvocableActionExt metadata.

  • Dynamic values can reference an Apex class with apex://ClassName.

  • Dynamic providers extend VisualEditor.DynamicPickList.

  • The invocable method still receives a string at runtime.

  • Flow metadata still stores selected values as strings.

  • Apex validation remains necessary.

  • Use Custom Property Editors when the Flow Builder configuration UI needs more than a picklist.

Resources

The value of this feature is not that Salesforce had no way to customize Flow action configuration before. The value is that simple picklist-style inputs no longer need the weight of a full Custom Property Editor. Where would you still choose a Custom Property Editor instead?