Skip to main content

Email Approval with Callback System

Learn how to implement email-based approval workflows using ReceiveTask and the callback system. Users click approve/reject links in emails to resume BPMN processes.

Overview

The Email Approval Pattern allows you to:

  • ✅ Send approval requests via email with clickable buttons
  • ✅ Pause BPMN process until user makes a decision
  • ✅ Resume process automatically when user clicks approve/reject
  • ✅ Support multi-channel approvals (email, SMS, in-app)
  • ✅ Add security validation and timeout handling

How It Works

Complete Flow

[1. Generate Approval ID] → Unique correlation key created
↓
[2. Send Approval Email] → Email with approve/reject URLs sent
↓
[3. ReceiveTask] → Process pauses, waits for callback
↓
... User clicks button in email (30 min later) ...
↓
[4. Webhook Callback] → System receives approval decision
↓
[5. Process Resumes] → Continues with approval decision
↓
[6. Execute Next Tasks] → Based on approve/reject

Correlation Key Generation

Key Insight: Correlation key must be generated BEFORE sending email!

// ❌ WRONG: ReceiveTask generates key, but email already sent
[Send Email] → How do we know the correlation key?
↓
[ReceiveTask] → Key generated here (too late!)

// ✅ CORRECT: Generate key first, use in email and ReceiveTask
[Script Task] → Generate approvalId = "APPROVAL-LOAN123-1736544000"
↓
[Send Email] → Use ${approvalId} in email URLs
↓
[ReceiveTask] → Use ${approvalId} as correlationKey

BPMN Implementation

Complete Process Definition

<bpmn:process id="EmailApprovalProcess" name="Email-Based Approval">

<bpmn:startEvent id="Start" name="Approval Needed"/>

<!-- ═══════════════════════════════════════════════════════════ -->
<!-- STEP 1: Generate Unique Approval ID (Correlation Key) -->
<!-- ═══════════════════════════════════════════════════════════ -->
<bpmn:scriptTask id="GenerateApprovalId"
name="Generate Approval ID"
scriptFormat="javascript">
<bpmn:incoming>Start</bpmn:incoming>
<bpmn:outgoing>ToPrepareEmail</bpmn:outgoing>
<bpmn:script><![CDATA[
// Generate unique correlation key
context.approvalId = 'APPROVAL-' +
context.entityType + '-' +
context.entityId + '-' +
Date.now();

// Security token for validation
context.approvalToken = BankLingo.GenerateSecureToken(context.approvalId);

// Set expiry (48 hours)
context.approvalExpiresAt = BankLingo.AddHours(new Date(), 48);

console.log('Generated approvalId:', context.approvalId);
]]></bpmn:script>
</bpmn:scriptTask>

<!-- ═══════════════════════════════════════════════════════════ -->
<!-- STEP 2: Send Approval Email -->
<!-- ═══════════════════════════════════════════════════════════ -->
<bpmn:serviceTask id="SendApprovalEmail"
name="Send Approval Email"
implementation="SendApprovalEmailCommand">
<bpmn:incoming>ToPrepareEmail</bpmn:incoming>
<bpmn:outgoing>ToWaitForApproval</bpmn:outgoing>
<bpmn:extensionElements>
<custom:parameters>
<custom:parameter name="recipientEmail">${managerEmail}</custom:parameter>
<custom:parameter name="title">${approvalTitle}</custom:parameter>
<custom:parameter name="description">${approvalDescription}</custom:parameter>
<custom:parameter name="approvalId">${approvalId}</custom:parameter>
<custom:parameter name="approvalToken">${approvalToken}</custom:parameter>
<custom:parameter name="entityType">${entityType}</custom:parameter>
<custom:parameter name="entityId">${entityId}</custom:parameter>
<custom:parameter name="metadata">${customFields}</custom:parameter>
</custom:parameters>
</bpmn:extensionElements>
</bpmn:serviceTask>

<!-- ═══════════════════════════════════════════════════════════ -->
<!-- STEP 3: Wait for Approval (ReceiveTask) -->
<!-- Uses EXISTING approvalId as correlation key -->
<!-- ═══════════════════════════════════════════════════════════ -->
<bpmn:receiveTask id="WaitForApproval"
name="Wait for Approval Decision">
<bpmn:incoming>ToWaitForApproval</bpmn:incoming>
<bpmn:outgoing>ToCheckDecision</bpmn:outgoing>
<bpmn:extensionElements>
<!-- USE PRE-GENERATED APPROVAL ID -->
<custom:correlationKey>${approvalId}</custom:correlationKey>
<custom:resultVariable>approvalResult</custom:resultVariable>
<custom:timeoutMinutes>2880</custom:timeoutMinutes> <!-- 48 hours -->
<custom:messageRef>ApprovalDecision</custom:messageRef>
</bpmn:extensionElements>
</bpmn:receiveTask>

<!-- Timeout handler -->
<bpmn:boundaryEvent id="ApprovalTimeout"
name="Approval Timeout"
attachedToRef="WaitForApproval">
<bpmn:timerEventDefinition>
<bpmn:timeDuration>PT48H</bpmn:timeDuration>
</bpmn:timerEventDefinition>
<bpmn:outgoing>ToAutoReject</bpmn:outgoing>
</bpmn:boundaryEvent>

<bpmn:serviceTask id="AutoReject"
name="Auto-Reject (Timeout)"
implementation="RejectWithReasonCommand">
<bpmn:incoming>ToAutoReject</bpmn:incoming>
<bpmn:extensionElements>
<custom:parameters>
<custom:parameter name="reason">Approval timeout - no response within 48 hours</custom:parameter>
</custom:parameters>
</bpmn:extensionElements>
</bpmn:serviceTask>

<!-- ═══════════════════════════════════════════════════════════ -->
<!-- STEP 4: Route Based on Decision -->
<!-- ═══════════════════════════════════════════════════════════ -->
<bpmn:exclusiveGateway id="CheckDecision" name="Approved?">
<bpmn:incoming>ToCheckDecision</bpmn:incoming>
<bpmn:outgoing sequenceFlow="${approvalResult.approved == true}">ToApproved</bpmn:outgoing>
<bpmn:outgoing sequenceFlow="${approvalResult.approved == false}">ToRejected</bpmn:outgoing>
</bpmn:exclusiveGateway>

<bpmn:endEvent id="EndApproved" name="Approved"/>
<bpmn:endEvent id="EndRejected" name="Rejected"/>

</bpmn:process>

Backend Implementation

1. Send Approval Email Command

Code Removed

Implementation details removed for security.

Contact support for implementation guidance.

2. Approval Callback Controller

Code Removed

Implementation details removed for security.

Contact support for implementation guidance.

3. Resume Process Command

Code Removed

Implementation details removed for security.

Contact support for implementation guidance.

Usage Examples

Loan Approval

Code Removed

Implementation details removed for security.

Contact support for implementation guidance.

High-Value Transaction Approval

Code Removed

Implementation details removed for security.

Contact support for implementation guidance.

Key Insights

✅ Correlation Key Generation

When: Generated in Script Task BEFORE sending email

Why: Email needs the correlation key to build callback URLs

How: Stored in process variables, used by both email task and ReceiveTask

// Generate ONCE
context.approvalId = 'APPROVAL-' + context.entityId + '-' + Date.now();

// Email task uses it
SendApprovalEmailCommand { ApprovalId = ${approvalId} }

// ReceiveTask uses same key
<custom:correlationKey>${approvalId}</custom:correlationKey>

✅ Callback Flow

1. User clicks button → GET /api/approval/callback?key=APPROVAL-...&decision=approved
2. Controller validates token and expiry
3. Controller finds CallbackRegistry by correlation key
4. Controller calls ResumeProcessByCallbackCommand
5. Process state loaded from CallbackRegistry.ParentProcessState
6. Approval decision stored in process variables
7. Process resumes from ReceiveTask
8. Gateway routes based on decision

✅ Security

  • Token validation: SHA256 hash prevents tampering
  • One-time use: Callback marked as "Completed" after first use
  • Expiry check: Timeout boundary event handles no-response
  • HTTPS only: All URLs must use HTTPS in production

Best Practices

  1. Generate correlation key early - Before sending any notifications
  2. Use security tokens - Always validate tokens in webhook
  3. Handle timeouts - Use boundary events for expired approvals
  4. Store metadata - Include all context needed for approval in email
  5. Audit trail - Log all approval decisions with timestamps
  6. One-time use - Mark callbacks as completed to prevent replay attacks
  7. User-friendly messages - Show clear success/error pages after callback

You now have a production-ready email approval system! 🎉