Skip to main content

Unified Callback Architecture

Overview

The BankLingo BPMN Process Engine implements a unified callback architecture that provides a single, consistent API for all form submissions, whether they're for UserTask or ReceiveTask (form mode).

Key Benefit

Frontend developers don't need to distinguish between UserTask and ReceiveTask form submissions. The same API calls work for both, and the backend handles routing and CallbackRegistry updates automatically.

Task Types and Modes

UserTask - Always Form Mode

UserTask nodes always require human interaction and have forms:

<bpmn:userTask id="Task_Approval" name="Manager Approval">
<bpmn:extensionElements>
<custom:properties>
<custom:property name="FormKey" value="approval-form"/>
<custom:property name="ResponsibleTeam" value="MANAGERS"/>
</custom:properties>
</bpmn:extensionElements>
</bpmn:userTask>

Characteristics:

  • ✅ Always has form
  • ✅ Human interaction required
  • ✅ Uses SignalProcessInstanceCommand for submission
  • ❌ No CallbackRegistry entry
  • 📝 Data stored in lastUserTaskResult

ReceiveTask - Dual Mode

ReceiveTask can operate in two distinct modes:

Form Mode (Human Interaction After External Event)

ReceiveTask with form property requires human approval after external event:

<bpmn:receiveTask id="Task_ReviewPayment" name="Review Payment Details">
<bpmn:extensionElements>
<custom:properties>
<!-- ✅ Form mode: HAS form property -->
<custom:property name="form" value="payment-review-form"/>
<custom:property name="correlationKey" value="context.paymentId"/>
<custom:property name="message" value="PaymentReceived"/>
</custom:properties>
</bpmn:extensionElements>
</bpmn:receiveTask>

Use Case Example:

  1. Payment gateway processes payment (external event)
  2. Webhook triggers process to ReceiveTask
  3. Manager receives notification
  4. Manager opens form to review payment details
  5. Manager approves/rejects payment in form
  6. Process continues with decision

Characteristics:

  • ✅ Has form property
  • ✅ Human interaction required
  • ✅ Uses SignalProcessInstanceCommand for submission
  • CallbackRegistry entry created (for tracking)
  • CallbackRegistry updated on form submission
  • 📝 Data stored in lastReceiveTaskResult

Webhook Mode (Pure External Callback)

ReceiveTask without form property is pure external webhook callback:

<bpmn:receiveTask id="Task_WaitForWebhook" name="Wait for External Callback">
<bpmn:extensionElements>
<custom:properties>
<!-- ❌ Webhook mode: NO form property -->
<custom:property name="correlationKey" value="context.transactionId"/>
<custom:property name="message" value="PaymentConfirmation"/>
</custom:properties>
</bpmn:extensionElements>
</bpmn:receiveTask>

Use Case Example:

  1. Payment gateway processes payment
  2. Gateway sends webhook directly with payment result
  3. Process resumes automatically
  4. No human interaction required

Characteristics:

  • ❌ No form property
  • ❌ No human interaction
  • ✅ Uses ReturnCallbackCommand
  • ✅ CallbackRegistry entry created
  • ✅ CallbackRegistry updated by webhook
  • 📝 Data stored in lastReceiveTaskResult

Architecture Comparison

Unified Frontend API

Single Form Loading Endpoint

Both UserTask and ReceiveTask (form mode) use the same API:

// Works for BOTH UserTask and ReceiveTask!
const response = await fetch('/api/core/cmd', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
cmd: 'GetTaskFormQuery',
data: { instanceGuid }
})
});

const result = await response.json();

// Response has same structure regardless of task type
if (result.data.status === 'SUCCESS') {
renderForm(result.data.formJson);
console.log('Task type:', result.data.taskType); // 'userTask' or 'receiveTask'
}

Single Form Submission Endpoint

Both task types use the same submission command:

// Works for BOTH UserTask and ReceiveTask!
const response = await fetch('/api/core/cmd', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
cmd: 'SignalProcessInstanceCommand',
data: {
instanceGuid,
callbackData: formData, // Same parameter for both
taskId // Optional, recommended
}
})
});

Backend Implementation

How SignalProcessInstanceCommand Detects Task Type

When SignalProcessInstanceCommand is called, it:

  1. Deserializes process state to get waiting task details
  2. Detects task type (userTask vs receiveTask)
  3. Routes internally:
    • For UserTask: Standard processing (no CallbackRegistry)
    • For ReceiveTask: Finds and updates CallbackRegistry entry
  4. Executes ServerScript for both task types
  5. Stores results in appropriate variable:
    • UserTask: lastUserTaskResult
    • ReceiveTask: lastReceiveTaskResult

CallbackRegistry Tracking

For ReceiveTask (both modes), the CallbackRegistry table tracks:

interface CallbackRegistry {
ParentInstanceId: string; // Process instance GUID
CallbackTaskId: string; // Waiting task ID
CorrelationKey: string; // External reference (e.g., "PAYMENT-12345")
CallbackType: CallbackType; // 'External'
Status: CallbackStatus; // 'Pending' | 'Completed' | 'Failed' | 'Expired'
ParentProcessState: string; // Serialized process state snapshot
ResultData: string; // Callback result (JSON)
CompletedAt: DateTime?; // When callback completed
WebhookUrl: string?; // For webhook notifications
MessageRef: string?; // Expected message name
TimeoutMinutes: number?; // Timeout configuration
}

Form Mode: SignalProcessInstanceCommand updates status to Completed
Webhook Mode: ReturnCallbackCommand updates status to Completed

Complete Workflow Examples

Example 1: Payment Approval with ReceiveTask Form Mode

<bpmn:process id="PaymentApprovalProcess">
<!-- Start process -->
<bpmn:startEvent id="Start" name="Payment Initiated"/>

<!-- Wait for external payment processing -->
<bpmn:receiveTask id="Task_WaitForPayment" name="Wait for Payment Gateway">
<bpmn:extensionElements>
<custom:properties>
<!-- Webhook mode: no form, just wait for callback -->
<custom:property name="correlationKey" value="context.paymentId"/>
<custom:property name="message" value="PaymentProcessed"/>
</custom:properties>
</bpmn:extensionElements>
</bpmn:receiveTask>

<!-- Manager reviews payment details -->
<bpmn:receiveTask id="Task_ReviewPayment" name="Review Payment Details">
<bpmn:extensionElements>
<custom:properties>
<!-- Form mode: has form, needs human approval -->
<custom:property name="form" value="payment-review-form"/>
<custom:property name="correlationKey" value="context.paymentId"/>
<custom:property name="ServerScript" value="
// Validate manager's decision
if (!formData.decision) {
throw new Error('Decision is required');
}

return {
approved: formData.decision === 'approve',
reviewedBy: currentUser,
reviewedAt: new Date(),
comments: formData.comments
};
"/>
</custom:properties>
</bpmn:extensionElements>
</bpmn:receiveTask>

<!-- Process payment -->
<bpmn:serviceTask id="Task_ProcessPayment" name="Process Payment"/>

<bpmn:endEvent id="End" name="Completed"/>
</bpmn:process>

Flow:

  1. Process starts with payment initiation
  2. Reaches Task_WaitForPayment (webhook mode)
    • CallbackRegistry created with PENDING status
    • External payment gateway processes payment
    • Gateway sends webhook via ReturnCallbackCommand
    • CallbackRegistry updated to COMPLETED
    • Process resumes automatically
  3. Reaches Task_ReviewPayment (form mode)
    • New CallbackRegistry created with PENDING status
    • Manager receives notification
    • Manager calls GetTaskFormQuery to load form
    • Form shows: payment amount, gateway response, payer details
    • Manager fills decision and comments
    • Manager submits via SignalProcessInstanceCommand
    • CallbackRegistry updated to COMPLETED automatically
    • Process continues
  4. Payment processed and completed

Example 2: Loan Approval with UserTask

<bpmn:process id="LoanApprovalProcess">
<!-- Manager reviews application -->
<bpmn:userTask id="Task_ManagerReview" name="Manager Review">
<bpmn:extensionElements>
<custom:properties>
<custom:property name="FormKey" value="loan-review-form"/>
<custom:property name="ResponsibleTeam" value="MANAGERS"/>
<custom:property name="ServerScript" value="
// Validate approval
if (formData.decision === 'approve' && !formData.approvedAmount) {
throw new Error('Approved amount is required');
}

return {
approved: formData.decision === 'approve',
approvedAmount: formData.approvedAmount,
approvedBy: currentUser
};
"/>
</custom:properties>
</bpmn:extensionElements>
</bpmn:userTask>
</bpmn:process>

Flow:

  1. Process reaches UserTask
  2. Manager calls GetTaskFormQuery (same API as ReceiveTask!)
  3. Manager submits form via SignalProcessInstanceCommand (same API as ReceiveTask!)
  4. No CallbackRegistry involved (UserTask doesn't need it)
  5. Process continues

Frontend Decision Matrix

When should frontend use which command?

ScenarioCommand to UseNotes
Load UserTask formGetTaskFormQueryUnified form loading
Load ReceiveTask (form mode) formGetTaskFormQuerySame API as UserTask
Submit UserTask formSignalProcessInstanceCommandUnified form submission
Submit ReceiveTask (form mode) formSignalProcessInstanceCommandSame API as UserTask, backend updates CallbackRegistry
External webhook callbackReturnCallbackCommandFor ReceiveTask webhook mode only
Frontend Simplification

You never need to check task type in frontend! Just use:

  • GetTaskFormQuery for loading any form
  • SignalProcessInstanceCommand for submitting any form
  • Backend handles everything else automatically

Benefits of Unified Architecture

1. Simplified Frontend Development

Before (Complex):

// Frontend needed to know task type
if (taskType === 'userTask') {
await submitUserTask(instanceGuid, formData);
} else if (taskType === 'receiveTask' && hasForm) {
await submitReceiveTaskWithForm(instanceGuid, formData);
await updateCallbackRegistry(instanceGuid, taskId); // Extra call!
}

After (Simple):

// Same API for both!
await submitForm(instanceGuid, formData);

2. Consistent User Experience

  • Same form loading process
  • Same submission feedback
  • Same error handling
  • Users don't see any difference between task types

3. Automatic Callback Tracking

  • ReceiveTask CallbackRegistry automatically updated
  • No separate API call needed
  • Consistent audit trail
  • No manual tracking required

4. Backward Compatibility

  • Existing code using signalPayload continues working
  • Gradual migration path
  • No breaking changes
  • Legacy systems unaffected

5. Clean Separation of Concerns

  • Form submissions → SignalProcessInstanceCommand
  • External webhooks → ReturnCallbackCommand
  • Backend handles routing internally
  • Frontend doesn't need implementation knowledge

API Reference Summary

GetTaskFormQuery

Purpose: Load form for UserTask or ReceiveTask (form mode)
Input: instanceGuid or correlationId
Output: Form JSON, context, metadata
Auth: Optional (uses token if provided)

SignalProcessInstanceCommand (Enhanced)

Purpose: Submit form for UserTask or ReceiveTask (form mode)
Input: instanceGuid, callbackData (or legacy signalPayload), optional taskId
Behavior:

  • Detects task type automatically
  • For UserTask: Standard processing
  • For ReceiveTask: Updates CallbackRegistry to Completed
  • Executes ServerScript for both Output: Process continuation status

ReturnCallbackCommand

Purpose: External webhook callback for ReceiveTask (webhook mode)
Input: correlationKey, webhook payload
Behavior: Finds CallbackRegistry, updates to Completed, resumes process
Output: Callback processing status

Migration Guide

For New Implementations

Use the new parameter names:

// ✅ New style (recommended)
{
cmd: 'SignalProcessInstanceCommand',
data: {
instanceGuid,
callbackData: formData, // New parameter
taskId // Optional but recommended
}
}

For Existing Implementations

Legacy code continues working:

// ✅ Legacy style (still supported)
{
cmd: 'SignalProcessInstanceCommand',
data: {
instanceGuid,
signalPayload: formData // Old parameter still works
}
}

Gradual Migration Strategy

  1. Phase 1: No changes required - existing code works
  2. Phase 2: Update new code to use callbackData parameter
  3. Phase 3: Gradually migrate old code as you touch it
  4. Phase 4 (optional): Complete migration, deprecate signalPayload

Troubleshooting

Form Not Loading

Check:

  1. Is process in WAITING state?
  2. Is task type userTask or receiveTask?
  3. For ReceiveTask, does it have form property?
  4. Check access control (ResponsibleUser/ResponsibleTeam)

CallbackRegistry Not Updated

Check:

  1. Is task type receiveTask?
  2. Was CallbackRegistry created when task started?
  3. Check taskId parameter matches waiting task
  4. Check backend logs for errors

Access Denied Errors

Check:

  1. Is user authenticated (token provided)?
  2. Does user match ResponsibleUser?
  3. Is user member of ResponsibleTeam?
  4. Try without authentication for public forms

See Also