SfSkills Domains Docs GitHub

Home / admin / approval-process-apex-patterns

approval-process-apex-patterns

Programmatically driving Salesforce Approval Processes from Apex — `Approval.process(ProcessSubmitRequest)` to submit, `ProcessWorkitemRequest` to approve / reject / reassign, recall semantics, querying `ProcessInstance` and `ProcessInstanceWorkitem` to find pending approvals, and the bulk-submit / bulk-action error-row handling.

Open the skill folder on GitHub


Approval Process Apex Patterns

The platform provides standard approval-process buttons (Submit for Approval, Approve, Reject, Reassign) on record pages. They work for human-driven, single-record approvals. They don't cover:

This skill covers the Apex API for those cases.

What this skill is NOT. Defining the Approval Process itself (entry criteria, approval steps, approver assignment) is declarative admin work — see admin/approval-processes. The modern Flow-based equivalent (Flow Orchestration with interactive steps assigned to approvers) is a different runtime entirely — see flow/flow-orchestration-patterns.


Before Starting


Questions to Ask Before Configuring

Ask these before the first new Approval.ProcessSubmitRequest(). Every one of them maps to a row that fails at runtime rather than at compile time.

AskWhy it mattersWhat a good answer adds
"Who is in allowedSubmitters on this process, and is the record owner one of them?"setSubmitterId rejects any user who isn't an allowed submitter, so a batch submitting on behalf of owners fails per rowThe list of owner profiles/roles to add to allowedSubmitters, or a decision to submit as a single service submitter
"Is allowRecall true, and who is allowed to recall?"setAction('Removed') is admin-only; allowRecall=false narrows recall to administrators entirelyWhether Pattern D can run in the editing user's context at all, or needs an admin-context escalation
"Does any step use approver type adhoc or queue?"adhoc needs an approver supplied at submission; a queue-assigned workitem has an ActorId pointing at a Group, not a UserWhich steps need setNextApproverIds, and whether the stuck-approval query can dereference Actor.IsActive at all
"Is this process the only active one on the object, and what is its process order?"With processDefinitionNameOrId null the platform walks every applicable process in org process order — and process order is not carried in the metadataA decision to always name the process explicitly, plus a post-deploy step to reset process order
"Is whenMultipleApprovers on any step Unanimous?"On a unanimous step, one rejection flips every other approver's StepStatus to NoResponse, not RejectedThe correct filter for the approval-audit report, so rejections aren't undercounted
"How many rows will one transaction submit, and is it sync or async?"Approval.process is DML: 150 statements and 10,000 records processed per transactionThe chunk size and whether this belongs in Batch Apex or Queueable rather than a trigger
"What happens to a row that fails — retry, park, or alert?"allOrNone=false returns per-row ProcessResults that nothing reads unless you write the handlerThe error-row destination: a log object, a retry queue, or an admin notification

What a proper implementation adds over just calling Approval.process(req): submissions land in the right process under the right submitter, failed rows are visible and re-drivable instead of silently rolled back, and the approval audit trail says what an auditor expects it to say.


Core Concepts

Request types

RequestPurpose
Approval.ProcessSubmitRequestSubmit a record into an approval process
Approval.ProcessWorkitemRequestTake action on a pending work item — approve / reject / reassign / remove

Both are passed to Approval.process(...). The call returns a list of Approval.ProcessResult (one per input Request) with success / errors.

ProcessSubmitRequest essentials

Approval.ProcessSubmitRequest req = new Approval.ProcessSubmitRequest();
req.setObjectId(record.Id);
req.setProcessDefinitionNameOrId('Expense_Approval_Process');  // approval process API name
req.setSubmitterId(UserInfo.getUserId());                       // who's "submitting"
req.setComments('Submitting via batch on month-end close');
req.setSkipEntryCriteria(false);                                 // run the entry criteria
Approval.ProcessResult result = Approval.process(req);

Key options:

MethodDocumented contract (Apex Reference Guide)
setProcessDefinitionNameOrId(nameOrId)"The process definition developer name or process definition ID." If null, "every entry criteria of the process definition in the process order is evaluated and the one that satisfies is picked" (apexrefguide.txt L3974-3989). Use the developer name — record IDs are not portable.
setSubmitterId(userID)"The user must be one of the allowed submitters in the process definition setup. If you don't set a submitter ID, the process uses the current user as the submitter" (apexrefguide.txt L4021-4024).
setSkipEntryCriteria(skipEntryCriteria)Skips entry-criteria evaluation for the named process. "If the process definition name or ID is not specified, this parameter is ignored" (apexrefguide.txt L4005-4014).
setComments(comments)Inherited from ProcessRequest. Lands in ProcessInstanceStep.Comments, limit 4,000 bytes (object_reference.txt L226666-226675).
setNextApproverIds(nextApproverIds)Inherited from ProcessRequest. Parameter "must be a single-entry list"; it "sets the ActorId field of the associated ProcessInstanceWorkItem" (apexrefguide.txt L3631-3634, L3684-3695).

setSkipEntryCriteria(true) submits a record the admin's criteria were written to exclude. Record why in the calling class, not in a commit message.

ProcessWorkitemRequest essentials

// Find the pending workitem.
ProcessInstanceWorkitem workitem = [
    SELECT Id FROM ProcessInstanceWorkitem
    WHERE ProcessInstance.TargetObjectId = :recordId
      AND ProcessInstance.Status = 'Pending'
    ORDER BY CreatedDate DESC LIMIT 1
];

Approval.ProcessWorkitemRequest req = new Approval.ProcessWorkitemRequest();
req.setWorkitemId(workitem.Id);
req.setAction('Approve');     // 'Approve', 'Reject', 'Removed' (recall), or null + setNextApproverIds for reassign
req.setComments('Approved by system per policy 4.2');
Approval.ProcessResult result = Approval.process(req);

Action values — "Valid values are: Approve, Reject, or Removed. Only system administrators can specify Removed" (apexrefguide.txt L4123-4127):

ValueEffectConstraint
'Approve'Approves the workitem—
'Reject'Rejects the workitemOn a Unanimous step, other approvers' steps flip to NoResponse
'Removed'Recalls the submissionSystem administrators only, per the Apex Reference. ApprovalProcess.allowRecall=false narrows recall to administrators even in the UI (api_meta.txt L23095-23098)
null + setNextApproverIdsReassignThe list must hold exactly one Id (apexrefguide.txt L3690-3695)

You rarely need a query to find the workitem you just created. ProcessResult.getNewWorkitemIds() returns "the IDs of the new items submitted to the approval process" (apexrefguide.txt L3795-3801), so a submit-then-approve sequence in the same transaction reads straight from the submit result — this is the shape the Apex Developer Guide's own sample uses (apexdev.txt L20651-20715).

Querying pending approvals

List<ProcessInstance> pending = [
    SELECT Id, TargetObjectId, Status, SubmittedById, ElapsedTimeInDays,
           (SELECT Id, ActorId FROM Workitems)
    FROM ProcessInstance
    WHERE Status = 'Pending'
      AND ProcessDefinition.DeveloperName = 'Expense_Approval'
];

Workitems is the child relationship name for ProcessInstanceWorkitem, and Steps for ProcessInstanceStep (object_reference.txt L226220-226240). Age on ElapsedTimeInDays, not CreatedDate — the field tables for these objects document the elapsed-time fields and do not list CreatedDate.

ProcessInstance is the in-flight approval. ProcessInstanceStep is the audit trail of completed steps. ProcessInstanceWorkitem is the open assignment to a specific approver.

ObjectSupported callsNotes from the Object Reference
ProcessDefinitionquery, retrieve, searchRead-only. DeveloperName, State, LockType (object_reference.txt L225355-225426)
ProcessInstancedescribeSObjects, query, retrieveStatus picklist: Approved, Fault, Held, NoResponse, Pending, Reassigned, Rejected, Removed, Started (L226112-226145). TargetObjectId is not nillable (L226147-226160)
ProcessInstanceStepdescribeSObjects, query, retrieveCompleted steps. StepStatus uses the same nine values (L226748-226766)
ProcessInstanceWorkitemdelete, describeSObjects, query, retrieve, updateOpen assignments. ActorId is Updateable, so reassignment is also a plain DML update (L226816-226835)
ProcessInstanceHistoryread-only view"Combines fields from ProcessInstanceStep and ProcessInstanceWorkitem" (L226802-226810)

ActorId on both ProcessInstanceStep and ProcessInstanceWorkitem is a polymorphic relationship field that "Refers To Group, User" (object_reference.txt L226648-226670, L226820-226835). A bare Actor.IsActive in the SELECT does not compile; use TYPEOF, and handle the queue case where there is no user at all:

SELECT Id, ActorId, ElapsedTimeInDays, ProcessInstance.TargetObjectId,
       TYPEOF Actor
           WHEN User THEN Username, IsActive
           WHEN Group THEN DeveloperName
       END
FROM ProcessInstanceWorkitem
WHERE ProcessInstance.Status = 'Pending'
  AND ElapsedTimeInDays > 7

ElapsedTimeInDays / InHours / InMinutes are filterable and sortable on both the workitem and the step (object_reference.txt L226836-226878), which is what the stuck-approval audit should age on.

Locking and unlocking

Approval.lock / Approval.unlock / Approval.isLocked set and read the approval lock independently of the process itself. Two constraints worth internalising: "Salesforce admins can edit locked records. Depending on your approval process configuration settings, an assigned approver can also edit locked records", and "Record locks and unlocks are treated as DML. They're blocked before a callout, they count toward your DML limits, and if a failure occurs, they're rolled back along with the rest of your transaction" (apexrefguide.txt L199726-199736). Both take the same optional allOrNothing flag and return LockResult / UnlockResult arrays positionally matched to the input.

Approval.unlock drops the lock. It does not remove the pending approval — the ProcessInstanceWorkitem survives and the approver can still act on it.

Bulk submission

The overload set is process(request), process(request, allOrNone), process(requests), process(requests, allOrNone) (apexrefguide.txt L200066-200172). The guides document no per-call cap on list size; what they do document is that each call is one DML statement (150 per transaction) and that the records it processes count against the 10,000 records-processed-per-transaction limit (apexdev.txt L19548-19556, L19624). UNVERIFIED (2026-09-05): the widely-repeated "200 requests per Approval.process call" figure does not appear in the Apex Reference Guide, the Apex Developer Guide, or the App Limits cheat sheet. Chunking at 200 is still a sound convention — it keeps heap and per-call failure blast radius predictable — but treat it as a design choice, not a documented governor.

Chunk the list anyway, and count DML statements while you do:

List<Approval.ProcessSubmitRequest> requests = ...;
for (Integer i = 0; i < requests.size(); i += 200) {
    Integer end = Math.min(i + 200, requests.size());
    List<Approval.ProcessSubmitRequest> chunk =
        new List<Approval.ProcessSubmitRequest>();
    for (Integer j = i; j < end; j++) chunk.add(requests[j]);
    Approval.ProcessResult[] results = Approval.process(chunk, false);  // allOrNone = false
    for (Integer j = 0; j < results.size(); j++) {
        if (!results[j].isSuccess()) {
            // Log + decide per row
        }
    }
}

allOrNone = false is essential when you have known-bad rows mixed in (e.g. records that don't match entry criteria — those will fail individually rather than killing the whole batch).


Common Patterns

Pattern A — System-initiated batch submission

When to use. Month-end close: identify all expense reports past threshold, submit them all into approval automatically.

public class MonthEndExpenseSubmitter {
    public static void submitOverThreshold() {
        List<Expense__c> toSubmit = [
            SELECT Id FROM Expense__c
            WHERE Status__c = 'Draft'
              AND Total_Amount__c >= 1000
              AND Submitted_Date__c = NULL
        ];

        List<Approval.ProcessSubmitRequest> requests = new List<Approval.ProcessSubmitRequest>();
        for (Expense__c e : toSubmit) {
            Approval.ProcessSubmitRequest req = new Approval.ProcessSubmitRequest();
            req.setObjectId(e.Id);
            req.setProcessDefinitionNameOrId('Expense_Approval');
            req.setSubmitterId(e.OwnerId);  // from the owner, not the running batch user
            req.setComments('Auto-submitted by month-end batch');
            requests.add(req);
        }

        // Bulk submit, allow partial success.
        for (Integer i = 0; i < requests.size(); i += 200) {
            Integer end = Math.min(i + 200, requests.size());
            List<Approval.ProcessSubmitRequest> chunk = new List<Approval.ProcessSubmitRequest>();
            for (Integer j = i; j < end; j++) chunk.add(requests[j]);
            Approval.ProcessResult[] results = Approval.process(chunk, false);
            for (Integer j = 0; j < results.size(); j++) {
                if (!results[j].isSuccess()) {
                    ApplicationLogger.warn(
                        'Submission failed: ' + requests[j].getObjectId() +
                        ' — ' + results[j].getErrors()
                    );
                }
            }
        }
    }
}

Key points: setSubmitterId to the owner (not the batch user), allOrNone = false, log failures rather than abort.

The submitter caveat that bites here: every OwnerId you pass must be an allowed submitter on the process, or that row fails (apexrefguide.txt L4021-4024). If owners are a moving population, either widen allowedSubmitters to <type>owner</type> / <type>allInternalUsers</type> (api_meta.txt L23228-23262) or drop setSubmitterId and submit as one designated service user, and say so in the approval comment.

Pattern B — Auto-approve based on system event

When to use. A downstream system signals approval (e.g. CFO office signs off in an external system; a Platform Event fires; Apex subscriber auto-approves the matching Salesforce expense).

trigger ExpenseApprovedEventSubscriber on Expense_Approved__e (after insert) {
    EventBus.TriggerContext ctx = EventBus.TriggerContext.currentContext();
    for (Expense_Approved__e e : Trigger.new) {
        try {
            ProcessInstanceWorkitem wi = findPendingWorkitem(e.Expense_Id__c);
            if (wi == null) {
                ctx.setResumeCheckpoint(e.ReplayId);
                continue;
            }
            Approval.ProcessWorkitemRequest req = new Approval.ProcessWorkitemRequest();
            req.setWorkitemId(wi.Id);
            req.setAction('Approve');
            req.setComments('Auto-approved by external CFO system; ref ' + e.External_Ref__c);
            Approval.process(req);
            ctx.setResumeCheckpoint(e.ReplayId);
        } catch (Exception ex) {
            ApplicationLogger.error('Auto-approve failed', ex);
            ctx.setResumeCheckpoint(e.ReplayId);
        }
    }
}

Caveat: auto-approval is a security-sensitive action. Audit who can publish the trigger event. The approval-process-step defines who's authorized to approve; programmatic auto-approval bypasses that policy.

Pattern C — Find stuck approvals

When to use. Operational monitoring — surface approvals waiting on inactive users, approvals older than SLA, approvals on deleted source records.

List<ProcessInstanceWorkitem> stuck = [
    SELECT Id, ActorId, ElapsedTimeInDays,
           TYPEOF Actor
               WHEN User THEN Username, IsActive
               WHEN Group THEN DeveloperName
           END,
           ProcessInstance.TargetObjectId, ProcessInstance.Status
    FROM ProcessInstanceWorkitem
    WHERE ProcessInstance.Status = 'Pending'
      AND ElapsedTimeInDays > 7
];

for (ProcessInstanceWorkitem w : stuck) {
    if (w.Actor instanceof User && !((User) w.Actor).IsActive) {
        // Assigned to a deactivated user — reassign.
    } else if (w.Actor instanceof Group) {
        // Assigned to a queue — nobody owns it personally; nudge the queue.
    }
}

Three corrections worth carrying: ActorId is polymorphic over Group and User, so Actor.IsActive needs TYPEOF and a runtime type check; age on ElapsedTimeInDays, which the Object Reference documents as filterable, rather than on CreatedDate, which the ProcessInstanceWorkitem field table does not list (object_reference.txt L226816-226905); and do not test ProcessInstance.TargetObjectId == null as a deleted-record signal — that field is not nillable (object_reference.txt L226147-226160), so it never comes back empty.

Run as a scheduled batch; surface results to admin dashboard or notification channel.

Pattern D — Recall a submission when the source record is invalidated

When to use. Source record changes mid-approval such that the approval should no longer proceed. Example: expense report's amount drops below the threshold that requires VP approval.

Approach. Trigger / flow on the source record detects the invalidating change. Find the pending workitem and recall.

public static void recallApproval(Id recordId, String reason) {
    ProcessInstanceWorkitem wi = [
        SELECT Id FROM ProcessInstanceWorkitem
        WHERE ProcessInstance.TargetObjectId = :recordId
          AND ProcessInstance.Status = 'Pending'
        LIMIT 1
    ];
    Approval.ProcessWorkitemRequest req = new Approval.ProcessWorkitemRequest();
    req.setWorkitemId(wi.Id);
    req.setAction('Removed');  // recall
    req.setComments('Auto-recalled: ' + reason);
    Approval.process(req);
}

Permission note, stated flatly by the Apex Reference: "Only system administrators can specify Removed" (apexrefguide.txt L4123-4127). ApprovalProcess.allowRecall governs the UI equivalent — false means "only administrators can recall approval requests" (api_meta.txt L23094-23097). A recall from an ordinary user's trigger context will fail, so route it: catch the failure and park the record for an admin queue, or run the recall from an async context that executes as a designated admin.

An alternative that needs no admin: reassign instead of recall. ProcessInstanceWorkitem supports update() and ActorId is Updateable (object_reference.txt L226816-226835), so moving the request to a different approver is plain DML.


Decision Guidance

SituationApproachReason
User clicks Submit for Approval on a record pageStandard platform buttonNo Apex needed
System submits 1000 records in a batchPattern A with allOrNone = falseBulk-submit pattern; partial success preserves successful submissions
External system signals approvalPattern B via Platform Event subscriberAsync, durable, decoupled
Find approvals stuck > 7 days on inactive usersPattern C with scheduled batchOperational monitoring
Source record changes invalidate the approvalPattern D recallDon't let an invalid approval complete
Backlog of records stuck pending (bad load, rule change, departed approver)Setup → Mass Transfer Approval Requests (remove or transfer); no ApexDeclarative bulk cleanup — Pattern C only surfaces them; Approval.unlock drops the lock but removes nothing
Custom Lightning component shows approval status + buttonsApex-driven submit + workitem actionsWrap Approval.process() in @AuraEnabled
Approval step's assignedApprover type is adhocSupply the approver with setNextApproverIds (single-entry list)adhoc means "the approver for the step must be selected manually" (api_meta.txt L23389-23392); the Apex guide constrains the list to one Id
Need to move a pending request to a different approverupdate the ProcessInstanceWorkitem.ActorIdThe field is Updateable and the object supports update() — no admin permission needed, unlike recall
Bulk approve as part of a system batchPattern B shape (find workitems → ProcessWorkitemRequest)Same DML budget: one statement per call, rows against the 10,000 records-processed limit
User wants to delegate approvals to another userStandard Delegated Approver field on User; no ApexPlatform handles delegation
Audit trail of who-approved-whatQuery ProcessInstanceStepComplete audit history per approval

  1. Answer the ## Questions to Ask Before Configuring table into templates/approval-process-apex-patterns-template.md. The allowedSubmitters, allowRecall, and adhoc-approver answers decide whether Patterns A and D are even runnable in your context.
  2. Read the process definition, don't assume it. Run the ProcessDefinition query from ## Before Starting, then retrieve the approvalProcess metadata (references/metadata-examples.md § "Retrieve, lint, deploy") and read allowedSubmitters, allowRecall, recordEditability, and every step's assignedApprover and whenMultipleApprovers.
  3. Deploy the test fixture — the minimal ApprovalProcess XML in references/metadata-examples.md — into a scratch org, so the Apex has something deterministic to submit into. Full declarative design lives in admin/approval-processes, not here.
  4. Write the service class from references/metadata-examples.md § "The Apex service class": bulk submit with allOrNone=false and an error-row collector, workitem action via getNewWorkitemIds() or the TYPEOF query, and recall guarded by the admin-only constraint.
  5. Lint before you deploy: python3 skills/admin/approval-process-apex-patterns/scripts/check_approval_process_apex_patterns.py --manifest-dir force-app/main/default. It reads approvalProcesses/*.approvalProcess-meta.xml and every .cls / .trigger in the tree, and flags a process name in Apex that no deployed process defines.
  6. Write the tests from references/metadata-examples.md § "Test class skeleton": success, mixed-row partial success, the not-an-allowed-submitter failure, and the non-admin recall failure. Assert on ProcessResult.getInstanceStatus() and getErrors(), not just isSuccess().
  7. Walk references/gotchas.md and confirm each of the four correction gotchas (5, 7, 8, 11) does not apply to the code you just wrote.

Review Checklist


Salesforce-Specific Gotchas

  1. setProcessDefinitionNameOrId accepts the API name; hardcoded record IDs break across orgs. Use API name. (See references/gotchas.md § 1.)
  2. Default allOrNone = true rolls back the whole batch on first failure. Bulk submissions need allOrNone = false to preserve successful records. (See references/gotchas.md § 2.)
  3. setSubmitterId defaults to the running user. System batches that don't set it explicitly produce approvals "submitted by" the batch service account, not the actual owner. (See references/gotchas.md § 3.)
  4. ProcessWorkitemRequest action values are case-sensitive strings — 'Approve' not 'approve'. (See references/gotchas.md § 4.)
  5. Recall ('Removed') is documented as system-administrator-only. Not "usually restricted" — the Apex Reference says only sysadmins can specify it. (See references/gotchas.md § 5.)
  6. Auto-approval bypasses the approval-process step's "Approver assignment" — the platform records the running user as the approver, not the configured one. Audit implications. (See references/gotchas.md § 6.)
  7. The real bulk ceiling is DML, not a 200-request cap. Approval.process burns a DML statement and its rows count toward the 10,000 records-processed limit. (See references/gotchas.md § 7.)
  8. setSubmitterId fails for any user not in allowedSubmitters — the batch-submits-as-owner pattern breaks the moment an owner falls outside that list. (See references/gotchas.md § 11.)
  9. ActorId is polymorphic over Group and User, so Actor.IsActive needs TYPEOF and a queue-assigned workitem has no user at all. (See references/gotchas.md § 13.)
  10. A null processDefinitionNameOrId routes by org process order, and process order is not carried in the metadata across a deploy. (See references/gotchas.md § 14.)

Output Artifacts

ArtifactDescription
Apex class implementing the chosen patternSubmit / action / monitor / recall
Bulk submission helperChunks 200-at-a-time with allOrNone = false + per-row error logging
Stuck-approval monitorScheduled batch query + admin notification
Test classCovers success, partial-success, recall, and the inactive-approver case

Reference Files

FileRead it when
references/metadata-examples.mdYou need the deployable test-fixture ApprovalProcess XML, the bulk-submit / action / recall service class, the pending-approvals SOQL, the test-class skeleton, the package.xml, or the retrieve/deploy/verify commands
references/gotchas.mdA submission fails per-row, a recall is refused, a stuck-approval query returns nothing or won't compile, or a process that worked in the sandbox picks a different process in production
references/examples.mdWalking a concrete failure end to end — the rolled-back batch, the hardcoded process Id, the auto-approval audit finding, the invalidated-record recall, the stuck-approval sweep
references/llm-anti-patterns.mdReviewing Apex an AI assistant wrote against the Approval namespace before you deploy it
references/well-architected.mdJustifying Apex-driven approval to an architecture review, or tracing the official source behind any claim in this skill
templates/approval-process-apex-patterns-template.mdWorkflow step 1 — capturing the process-definition answers before any Apex is written
scripts/check_approval_process_apex_patterns.pyWorkflow step 5, before every deploy that touches approval Apex or approval-process metadata

Package files