Approval Pattern 
Overview
The Approval pattern implements human-in-the-loop Workflows where execution blocks until an external decision is made. It uses Workflow Signals with custom input data to unblock Workflows, enabling approval processes, manual reviews, and decision gates in automated business processes.
Problem
In many business processes, you need Workflows that wait for human approval before proceeding. These Workflows must capture approval decisions along with metadata such as the approver's identity, a reason, and a timestamp. They must also support multiple outcomes — approval, rejection, or escalation — and handle timeout scenarios when no decision arrives.
Without a structured approval pattern, you are forced to poll external systems for approval status, implement complex state machines by hand, and manage race conditions between timeouts and incoming approvals. You also risk losing approval context and metadata, and you must build custom audit logging to meet compliance requirements.
Solution
The Approval pattern uses a blocking wait with timeout to pause execution until a Signal is received. The Signal carries custom data — the approval decision, approver details, and comments — that the Workflow captures and uses to determine next steps.
The following describes each step in the diagram:
- The requester starts the Workflow with an approval request.
- The Workflow blocks execution with a timeout — using
Workflow.await()in Java,condition()in TypeScript,workflow.wait_condition()in Python, orworkflow.AwaitWithTimeout()in Go. - If an approver sends a Signal before the timeout expires, the Workflow receives the approval data, processes the decision, and returns the result to the requester.
- If the timeout expires before any Signal arrives, the Workflow unblocks and follows the timeout path, which typically results in rejection or escalation.
The approval data object carries the decision context through the Workflow. Define a type to hold the approver's identity, the decision, any comments, and a timestamp:
# models.py
from dataclasses import dataclass
@dataclass
class ApprovalData:
approver: str
decision: str # "APPROVED", "REJECTED", "ESCALATED"
comments: str
timestamp: int// types.go
type ApprovalData struct {
Approver string
Decision string // "APPROVED", "REJECTED", "ESCALATED"
Comments string
Timestamp int64
}// ApprovalData.java
public class ApprovalData {
private String approver;
private String decision; // "APPROVED", "REJECTED", "ESCALATED"
private String comments;
private long timestamp;
// Constructor, getters, setters
}// types.ts
export interface ApprovalData {
approver: string;
decision: 'APPROVED' | 'REJECTED' | 'ESCALATED';
comments: string;
timestamp: number;
}This type gives you a structured way to pass rich context through the Signal rather than a plain boolean.
Next, define the Workflow contract. The Workflow accepts a request ID and a timeout duration. A Signal receives the approval data from an external system. A Query exposes the current status without modifying Workflow state:
# workflows.py
from temporalio import workflow
from models import ApprovalData// workflow.go
// In Go, signals are received via named channels and
// queries are registered with workflow.SetQueryHandler.
// There is no separate interface definition.// ApprovalWorkflow.java
@WorkflowInterface
public interface ApprovalWorkflow {
@WorkflowMethod
String execute(String requestId, Duration timeout);
@SignalMethod
void submitApproval(ApprovalData approvalData);
@QueryMethod
String getStatus();
}// workflows.ts
import * as wf from '@temporalio/workflow';
import { ApprovalData } from './types';
export const submitApprovalSignal = wf.defineSignal<[ApprovalData]>('submitApproval');
export const getStatusQuery = wf.defineQuery<string>('getStatus');These definitions form the contract for any approval Workflow implementation.
The implementation ties everything together. The Workflow blocks until either the approval data arrives via Signal or the timeout expires:
# workflows.py
import asyncio
from datetime import timedelta
from temporalio import workflow
from models import ApprovalData
@workflow.defn
class ApprovalWorkflow:
def __init__(self) -> None:
self.approval_data: ApprovalData | None = None
self.status = "PENDING"
@workflow.run
async def run(self, request_id: str, timeout_seconds: int) -> str:
try:
await workflow.wait_condition(
lambda: self.approval_data is not None,
timeout=timedelta(seconds=timeout_seconds),
)
self.status = self.approval_data.decision
return f"Request {request_id} {self.status} by {self.approval_data.approver}"
except asyncio.TimeoutError:
self.status = "TIMEOUT"
return f"Request {request_id} timed out"
@workflow.signal
def submit_approval(self, data: ApprovalData) -> None:
self.approval_data = data
@workflow.query
def get_status(self) -> str:
return self.status// workflow.go
func ApprovalWorkflow(ctx workflow.Context, requestId string, timeout time.Duration) (string, error) {
var approvalData *ApprovalData
status := "PENDING"
err := workflow.SetQueryHandler(ctx, "getStatus", func() (string, error) {
return status, nil
})
if err != nil {
return "", err
}
// Listen for the approval signal in a goroutine
workflow.Go(ctx, func(ctx workflow.Context) {
signalChan := workflow.GetSignalChannel(ctx, "submitApproval")
signalChan.Receive(ctx, &approvalData)
})
approved, err := workflow.AwaitWithTimeout(ctx, timeout, func() bool {
return approvalData != nil
})
if err != nil {
return "", err
}
if approved {
status = approvalData.Decision
return fmt.Sprintf("Request %s %s by %s", requestId, status, approvalData.Approver), nil
}
status = "TIMEOUT"
return fmt.Sprintf("Request %s timed out", requestId), nil
}// ApprovalWorkflowImpl.java
public class ApprovalWorkflowImpl implements ApprovalWorkflow {
private ApprovalData approvalData;
private String status = "PENDING";
@Override
public String execute(String requestId, Duration timeout) {
boolean approved = Workflow.await(timeout, () -> approvalData != null);
if (approved) {
status = approvalData.getDecision();
return "Request " + requestId + " " + status + " by " + approvalData.getApprover();
} else {
status = "TIMEOUT";
return "Request " + requestId + " timed out";
}
}
@Override
public void submitApproval(ApprovalData data) {
this.approvalData = data;
}
@Override
public String getStatus() {
return status;
}
}// workflows.ts
import * as wf from '@temporalio/workflow';
import { ApprovalData } from './types';
export const submitApprovalSignal = wf.defineSignal<[ApprovalData]>('submitApproval');
export const getStatusQuery = wf.defineQuery<string>('getStatus');
export async function approvalWorkflow(
requestId: string,
timeout: string | number, // ms or Duration string
): Promise<string> {
let approvalData: ApprovalData | undefined;
let status = 'PENDING';
wf.setHandler(submitApprovalSignal, (data: ApprovalData) => {
approvalData = data;
});
wf.setHandler(getStatusQuery, () => status);
const approved = await wf.condition(() => approvalData !== undefined, timeout);
if (approved) {
status = approvalData!.decision;
return `Request ${requestId} ${status} by ${approvalData!.approver}`;
} else {
status = 'TIMEOUT';
return `Request ${requestId} timed out`;
}
}Each SDK uses a different mechanism to block with a timeout, but the core pattern is the same. In Java, Workflow.await() takes a timeout and a condition lambda, returning false on timeout. In TypeScript, condition() takes a predicate and a timeout, returning false on timeout. In Python, workflow.wait_condition() takes a lambda and a timeout, raising asyncio.TimeoutError on timeout. In Go, workflow.AwaitWithTimeout() takes a timeout and a condition function, returning ok=false on timeout.
The condition is evaluated on every state transition, so it must not call blocking operations, mutate Workflow state, or use time-based checks. When the Signal handler sets the approval data, the condition evaluates to true and the Workflow unblocks.
Implementation
Basic approval with timeout
The following implementation shows the minimal version of the pattern. The Workflow waits for a boolean approval flag to be set via Signal, and falls back to auto-rejection on timeout:
# workflows.py
import asyncio
from datetime import timedelta
from temporalio import workflow
@workflow.defn
class SimpleApprovalWorkflow:
def __init__(self) -> None:
self.approved = False
self.approver: str | None = None
@workflow.run
async def run(self, request_id: str, timeout_seconds: int) -> str:
try:
await workflow.wait_condition(
lambda: self.approved,
timeout=timedelta(seconds=timeout_seconds),
)
return f"Approved by {self.approver}"
except asyncio.TimeoutError:
return "Approval timeout - auto-rejected"
@workflow.signal
def submit_approval(self, approver_name: str) -> None:
self.approved = True
self.approver = approver_name// workflow.go
func SimpleApprovalWorkflow(ctx workflow.Context, requestId string, timeout time.Duration) (string, error) {
approved := false
var approver string
workflow.Go(ctx, func(ctx workflow.Context) {
signalChan := workflow.GetSignalChannel(ctx, "submitApproval")
signalChan.Receive(ctx, &approver)
approved = true
})
ok, err := workflow.AwaitWithTimeout(ctx, timeout, func() bool {
return approved
})
if err != nil {
return "", err
}
if ok {
return fmt.Sprintf("Approved by %s", approver), nil
}
return "Approval timeout - auto-rejected", nil
}// SimpleApprovalWorkflowImpl.java
public class SimpleApprovalWorkflowImpl implements ApprovalWorkflow {
private boolean approved = false;
private String approver;
@Override
public String execute(String requestId, Duration timeout) {
Workflow.await(timeout, () -> approved);
if (approved) {
return "Approved by " + approver;
} else {
return "Approval timeout - auto-rejected";
}
}
@Override
public void submitApproval(String approverName) {
this.approved = true;
this.approver = approverName;
}
}// workflows.ts
import * as wf from '@temporalio/workflow';
export const submitApprovalSignal = wf.defineSignal<[string]>('submitApproval');
export async function simpleApprovalWorkflow(
requestId: string,
timeout: string | number,
): Promise<string> {
let approved = false;
let approver: string | undefined;
wf.setHandler(submitApprovalSignal, (approverName: string) => {
approved = true;
approver = approverName;
});
await wf.condition(() => approved, timeout);
if (approved) {
return `Approved by ${approver}`;
} else {
return 'Approval timeout - auto-rejected';
}
}The Signal handler sets both the approval flag and the approver's name. When the wait unblocks, the Workflow checks the flag and returns the appropriate result.
Multi-level approval chain
Some business processes require approvals from multiple levels of authority in sequence. The following implementation iterates through a list of required approval levels, waiting for a Signal at each level before proceeding to the next:
# models.py
from dataclasses import dataclass
@dataclass
class MultiLevelApprovalData:
level: str # "L1", "L2", "L3"
approver: str
decision: str
comments: str// types.go
type MultiLevelApprovalData struct {
Level string // "L1", "L2", "L3"
Approver string
Decision string
Comments string
}// MultiLevelApprovalData.java
public class MultiLevelApprovalData {
private String level; // "L1", "L2", "L3"
private String approver;
private String decision;
private String comments;
}// types.ts
export interface MultiLevelApprovalData {
level: 'L1' | 'L2' | 'L3';
approver: string;
decision: string;
comments: string;
}This data type extends the basic approval data with a level field that identifies which approval tier the decision belongs to.
# workflows.py
import asyncio
from datetime import timedelta
from temporalio import workflow
from models import MultiLevelApprovalData
@workflow.defn
class MultiLevelApprovalWorkflow:
def __init__(self) -> None:
self.approvals: list[MultiLevelApprovalData] = []
@workflow.run
async def run(self, request_id: str, timeout_per_level_seconds: int) -> str:
required_levels = ["L1", "L2", "L3"]
timeout = timedelta(seconds=timeout_per_level_seconds)
for level in required_levels:
try:
await workflow.wait_condition(
lambda lv=level: any(a.level == lv for a in self.approvals),
timeout=timeout,
)
except asyncio.TimeoutError:
return f"Timeout at {level}"
approval = next(a for a in self.approvals if a.level == level)
if approval.decision == "REJECTED":
return f"Rejected at {level} by {approval.approver}"
return "Fully approved through all levels"
@workflow.signal
def submit_approval(self, data: MultiLevelApprovalData) -> None:
self.approvals.append(data)// workflow.go
func MultiLevelApprovalWorkflow(ctx workflow.Context, requestId string, timeoutPerLevel time.Duration) (string, error) {
var approvals []MultiLevelApprovalData
requiredLevels := []string{"L1", "L2", "L3"}
workflow.Go(ctx, func(ctx workflow.Context) {
signalChan := workflow.GetSignalChannel(ctx, "submitApproval")
for {
var data MultiLevelApprovalData
signalChan.Receive(ctx, &data)
approvals = append(approvals, data)
}
})
for _, level := range requiredLevels {
lv := level
ok, err := workflow.AwaitWithTimeout(ctx, timeoutPerLevel, func() bool {
for _, a := range approvals {
if a.Level == lv {
return true
}
}
return false
})
if err != nil {
return "", err
}
if !ok {
return fmt.Sprintf("Timeout at %s", lv), nil
}
var approval MultiLevelApprovalData
for _, a := range approvals {
if a.Level == lv {
approval = a
break
}
}
if approval.Decision == "REJECTED" {
return fmt.Sprintf("Rejected at %s by %s", lv, approval.Approver), nil
}
}
return "Fully approved through all levels", nil
}// MultiLevelApprovalWorkflowImpl.java
public class MultiLevelApprovalWorkflowImpl implements ApprovalWorkflow {
private List<MultiLevelApprovalData> approvals = new ArrayList<>();
private String[] requiredLevels = {"L1", "L2", "L3"};
@Override
public String execute(String requestId, Duration timeoutPerLevel) {
for (String level : requiredLevels) {
boolean received = Workflow.await(
timeoutPerLevel,
() -> hasApprovalForLevel(level));
if (!received) {
return "Timeout at " + level;
}
MultiLevelApprovalData approval = getApprovalForLevel(level);
if (approval.getDecision().equals("REJECTED")) {
return "Rejected at " + level + " by " + approval.getApprover();
}
}
return "Fully approved through all levels";
}
@Override
public void submitApproval(MultiLevelApprovalData data) {
approvals.add(data);
}
private boolean hasApprovalForLevel(String level) {
return approvals.stream().anyMatch(a -> a.getLevel().equals(level));
}
private MultiLevelApprovalData getApprovalForLevel(String level) {
return approvals.stream()
.filter(a -> a.getLevel().equals(level))
.findFirst()
.orElse(null);
}
}// workflows.ts
import * as wf from '@temporalio/workflow';
import { MultiLevelApprovalData } from './types';
export const submitApprovalSignal = wf.defineSignal<[MultiLevelApprovalData]>('submitApproval');
export async function multiLevelApprovalWorkflow(
requestId: string,
timeoutPerLevelMs: number,
): Promise<string> {
const approvals: MultiLevelApprovalData[] = [];
const requiredLevels = ['L1', 'L2', 'L3'] as const;
wf.setHandler(submitApprovalSignal, (data: MultiLevelApprovalData) => {
approvals.push(data);
});
for (const level of requiredLevels) {
const received = await wf.condition(
() => approvals.some((a) => a.level === level),
timeoutPerLevelMs,
);
if (!received) {
return `Timeout at ${level}`;
}
const approval = approvals.find((a) => a.level === level)!;
if (approval.decision === 'REJECTED') {
return `Rejected at ${level} by ${approval.approver}`;
}
}
return 'Fully approved through all levels';
}The Workflow loops through each required level and waits with a per-level timeout. The helper logic checks whether a Signal has arrived for the current level. If a timeout occurs at any level, the Workflow exits with a timeout result. If any level returns a rejection, the Workflow exits immediately without proceeding to subsequent levels.
Approval with escalation
When an initial approval times out, you may want to escalate the request to a manager rather than rejecting it outright. The following implementation adds an escalation step with an extended timeout:
# workflows.py
import asyncio
from datetime import timedelta
from temporalio import workflow
from models import ApprovalData
with workflow.unsafe.imports_passed_through():
from activities import send_escalation_email
@workflow.defn
class EscalatingApprovalWorkflow:
def __init__(self) -> None:
self.approval_data: ApprovalData | None = None
self.escalated = False
@workflow.run
async def run(self, request_id: str, initial_timeout_seconds: int) -> str:
try:
await workflow.wait_condition(
lambda: self.approval_data is not None,
timeout=timedelta(seconds=initial_timeout_seconds),
)
except asyncio.TimeoutError:
self.escalated = True
await workflow.execute_activity(
send_escalation_email,
start_to_close_timeout=timedelta(seconds=10),
)
try:
await workflow.wait_condition(
lambda: self.approval_data is not None,
timeout=timedelta(hours=24),
)
except asyncio.TimeoutError:
return "Escalation timeout - auto-rejected"
decision = self.approval_data.decision
approver = self.approval_data.approver
escalation_note = " (escalated)" if self.escalated else ""
return f"{decision} by {approver}{escalation_note}"
@workflow.signal
def submit_approval(self, data: ApprovalData) -> None:
self.approval_data = data// workflow.go
func EscalatingApprovalWorkflow(ctx workflow.Context, requestId string, initialTimeout time.Duration) (string, error) {
var approvalData *ApprovalData
escalated := false
workflow.Go(ctx, func(ctx workflow.Context) {
signalChan := workflow.GetSignalChannel(ctx, "submitApproval")
signalChan.Receive(ctx, &approvalData)
})
ok, err := workflow.AwaitWithTimeout(ctx, initialTimeout, func() bool {
return approvalData != nil
})
if err != nil {
return "", err
}
if !ok {
escalated = true
ao := workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Second,
}
actCtx := workflow.WithActivityOptions(ctx, ao)
err = workflow.ExecuteActivity(actCtx, SendEscalationEmail).Get(ctx, nil)
if err != nil {
return "", err
}
ok, err = workflow.AwaitWithTimeout(ctx, 24*time.Hour, func() bool {
return approvalData != nil
})
if err != nil {
return "", err
}
if !ok {
return "Escalation timeout - auto-rejected", nil
}
}
escalationNote := ""
if escalated {
escalationNote = " (escalated)"
}
return fmt.Sprintf("%s by %s%s", approvalData.Decision, approvalData.Approver, escalationNote), nil
}// EscalatingApprovalWorkflowImpl.java
public class EscalatingApprovalWorkflowImpl implements ApprovalWorkflow {
private ApprovalData approvalData;
private boolean escalated = false;
@Override
public String execute(String requestId, Duration initialTimeout) {
boolean received = Workflow.await(initialTimeout, () -> approvalData != null);
if (!received) {
escalated = true;
sendEscalationNotification();
received = Workflow.await(
Duration.ofHours(24),
() -> approvalData != null);
if (!received) {
return "Escalation timeout - auto-rejected";
}
}
String decision = approvalData.getDecision();
String approver = approvalData.getApprover();
String escalationNote = escalated ? " (escalated)" : "";
return decision + " by " + approver + escalationNote;
}
@Override
public void submitApproval(ApprovalData data) {
this.approvalData = data;
}
private void sendEscalationNotification() {
ActivityOptions options = ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.build();
NotificationActivities activities =
Workflow.newActivityStub(NotificationActivities.class, options);
activities.sendEscalationEmail();
}
}// workflows.ts
import * as wf from '@temporalio/workflow';
import type * as activities from './activities';
import { ApprovalData } from './types';
const { sendEscalationEmail } = wf.proxyActivities<typeof activities>({
startToCloseTimeout: '10 seconds',
});
export const submitApprovalSignal = wf.defineSignal<[ApprovalData]>('submitApproval');
export async function escalatingApprovalWorkflow(
requestId: string,
initialTimeoutMs: number,
): Promise<string> {
let approvalData: ApprovalData | undefined;
let escalated = false;
wf.setHandler(submitApprovalSignal, (data: ApprovalData) => {
approvalData = data;
});
let received = await wf.condition(
() => approvalData !== undefined,
initialTimeoutMs,
);
if (!received) {
escalated = true;
await sendEscalationEmail();
received = await wf.condition(
() => approvalData !== undefined,
'24 hours',
);
if (!received) {
return 'Escalation timeout - auto-rejected';
}
}
const { decision, approver } = approvalData!;
const escalationNote = escalated ? ' (escalated)' : '';
return `${decision} by ${approver}${escalationNote}`;
}The Workflow first waits for the initial timeout. If no Signal arrives, it sets the escalated flag, executes a notification Activity to alert the manager, and then waits again with a 24-hour extended timeout. The notification Activity uses a short start-to-close timeout, since sending an email should complete quickly. The final result includes an escalation note so the caller knows the request was escalated before approval.
When to use
The Approval pattern is a good fit for purchase order approvals, expense report reviews, code deployment gates, contract signing Workflows, manual quality checks, compliance reviews, budget authorization, and access request approvals.
It is not a good fit for fully automated processes that require no human input, real-time decisions that need synchronous API responses, or processes that require sub-second response times. If you only need a boolean yes/no without any context, a plain boolean Signal may be sufficient.
Benefits and trade-offs
The Approval pattern captures rich context — approver identity, reasons, and timestamps — alongside each decision. All approval data is recorded in the Workflow history as Signal events, giving you a built-in audit trail. Timeout handling is automatic: you define the maximum wait time and the Workflow handles the fallback. The pattern supports multi-level, conditional, and escalating approval chains, and you can check approval status at any time through Query methods without modifying Workflow state. Because all decisions are recorded in the event history, the Workflow is deterministic and replay-safe.
The trade-offs to consider are that the pattern requires an external system to send approval Signals, which means you need a separate approval interface. The Workflow blocks until the approval arrives or the timeout expires, so you must define a maximum wait time. Large approval data objects increase the size of the Workflow history.
Comparison with alternatives
| Approach | Rich data | Built-in wait | Caller gets result | Complexity | Use case |
|---|---|---|---|---|---|
| Signal with data | Yes | Yes | No | Low | Approval Workflows |
| Update | Yes | No | Yes | Low | Synchronous validation with immediate confirmation |
| Boolean Signal | No | Yes | No | Low | Yes/no decisions |
| Polling Activity | Yes | Yes | Yes | High | External approval systems |
Signals are fire-and-forget: the caller receives an acknowledgement from the server but cannot wait for the Workflow to process the Signal or receive a result. Updates are synchronous: the caller blocks until the handler completes and can receive a return value or error. If the approver's interface needs immediate confirmation that the approval was accepted and valid, consider using an Update with a validator instead of a Signal.
Best practices
- Use custom data objects. Capture rich approval context — approver identity, comments, timestamps — rather than a plain boolean.
- Set reasonable timeouts. Balance responsiveness with the time approvers realistically need to respond.
- Add Query methods. Expose the current approval status so external systems can check progress without sending a Signal.
- Validate Signal data. Verify approver permissions and data completeness before accepting an approval.
- Log approval events. Record each decision for audit trails and compliance.
- Handle timeouts gracefully. Define clear timeout behavior such as rejection, escalation, or notification.
- Support cancellation. Allow Workflows to be cancelled if the request is withdrawn.
- Ensure idempotency. Handle duplicate approval Signals safely so that re-delivery does not corrupt state. Signals may be duplicated in rare cases, so use idempotency keys when necessary.
- Include timestamps. Record when each approval was submitted to support time-based auditing.
- Expose approval history. Provide a Query method that returns all approval attempts, not only the final decision.
Common pitfalls
- No timeout. Without a timeout, the Workflow waits indefinitely for an approval that may never arrive.
- Missing validation. Accepting approvals from unauthorized users compromises the integrity of the process.
- Lost context. Failing to capture the approver's identity or reason makes audit trails incomplete.
- Assuming non-deterministic races. Temporal processes events in a deterministic, single-threaded order, so a Signal and a timer cannot truly "race." However, if the Signal arrives after the timer fires in the event history, the wait will have already returned with a timeout result. Design your timeout path to account for late-arriving Signals.
- No audit trail. Skipping approval logging makes it difficult to meet compliance requirements.
- Tight timeouts. Setting the timeout too short causes legitimate approvals to be rejected.
- Boolean-only Signals. Using a plain boolean instead of a rich data object limits your ability to capture decision context.
- No status Query. Without a Query method, external systems have no way to check approval progress.
- No duplicate handling. Receiving multiple approval Signals without deduplication can overwrite earlier decisions.
- No escalation path. Without a fallback when the initial approval times out, requests stall or are silently rejected.
Related patterns
- Signal-Based Event Handling: Receiving external events through Signals.
- Updatable Timer: Extending approval deadlines dynamically.
- Saga Pattern: Executing compensating actions on rejection.
Sample code
Java
- Hello Signal — Basic Signal handling in a Workflow.
- Safe Message Passing — Concurrent Signal handling with validation.
TypeScript
- Signals and Queries — Signal and Query usage in a Workflow.
- Message Passing — Introduction to message passing with Signals, Queries, and Updates.
Python
- Hello Signal — Basic Signal handling in a Workflow.
- Message Passing — Introduction to message passing with Signals, Queries, and Updates.
Go
- Await Signals — Waiting for Signals with timeout using
AwaitWithTimeout. - Message Passing — Introduction to message passing with Signals, Queries, and Updates.