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).
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:
- Payment gateway processes payment (external event)
- Webhook triggers process to ReceiveTask
- Manager receives notification
- Manager opens form to review payment details
- Manager approves/rejects payment in form
- 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:
- Payment gateway processes payment
- Gateway sends webhook directly with payment result
- Process resumes automatically
- 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:
- Deserializes process state to get waiting task details
- Detects task type (userTask vs receiveTask)
- Routes internally:
- For UserTask: Standard processing (no CallbackRegistry)
- For ReceiveTask: Finds and updates CallbackRegistry entry
- Executes ServerScript for both task types
- Stores results in appropriate variable:
- UserTask:
lastUserTaskResult - ReceiveTask:
lastReceiveTaskResult
- UserTask:
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:
- Process starts with payment initiation
- Reaches
Task_WaitForPayment(webhook mode)- CallbackRegistry created with
PENDINGstatus - External payment gateway processes payment
- Gateway sends webhook via ReturnCallbackCommand
- CallbackRegistry updated to
COMPLETED - Process resumes automatically
- CallbackRegistry created with
- Reaches
Task_ReviewPayment(form mode)- New CallbackRegistry created with
PENDINGstatus - 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
COMPLETEDautomatically - Process continues
- New CallbackRegistry created with
- 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:
- Process reaches UserTask
- Manager calls GetTaskFormQuery (same API as ReceiveTask!)
- Manager submits form via SignalProcessInstanceCommand (same API as ReceiveTask!)
- No CallbackRegistry involved (UserTask doesn't need it)
- Process continues
Frontend Decision Matrix
When should frontend use which command?
| Scenario | Command to Use | Notes |
|---|---|---|
| Load UserTask form | GetTaskFormQuery | Unified form loading |
| Load ReceiveTask (form mode) form | GetTaskFormQuery | Same API as UserTask |
| Submit UserTask form | SignalProcessInstanceCommand | Unified form submission |
| Submit ReceiveTask (form mode) form | SignalProcessInstanceCommand | Same API as UserTask, backend updates CallbackRegistry |
| External webhook callback | ReturnCallbackCommand | For ReceiveTask webhook mode only |
You never need to check task type in frontend! Just use:
GetTaskFormQueryfor loading any formSignalProcessInstanceCommandfor 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
signalPayloadcontinues 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
- Phase 1: No changes required - existing code works
- Phase 2: Update new code to use
callbackDataparameter - Phase 3: Gradually migrate old code as you touch it
- Phase 4 (optional): Complete migration, deprecate
signalPayload
Troubleshooting
Form Not Loading
Check:
- Is process in
WAITINGstate? - Is task type
userTaskorreceiveTask? - For ReceiveTask, does it have
formproperty? - Check access control (ResponsibleUser/ResponsibleTeam)
CallbackRegistry Not Updated
Check:
- Is task type
receiveTask? - Was CallbackRegistry created when task started?
- Check
taskIdparameter matches waiting task - Check backend logs for errors
Access Denied Errors
Check:
- Is user authenticated (token provided)?
- Does user match ResponsibleUser?
- Is user member of ResponsibleTeam?
- Try without authentication for public forms