GoodBooks GB5 — EIP & DirectAction Implementation Guide
What is EIP & DirectAction?
The GB5 Enterprise Integration Platform (EIP) provides two ways for people outside the system to interact with it:
Scenario A — DirectAction: One-Click Approval in Email
A Purchase Order is raised. The assigned approver receives an email with an Approve and a Reject button. Clicking either button:
- Validates a signed security token embedded in the link
- Calls the internal approval API (
POST /PurchaseOrder/ApprovePO) automatically - Shows a confirmation page ("Action Successful")
- Records who clicked, when, and on which device in the audit log
The approver never needs to log in to the ERP. One click is all it takes.
Scenario B — EIP Conversation: WhatsApp Leave Request Bot
An employee sends "start leave" on WhatsApp. The system:
- Matches the message to the LEAVE_REQUEST conversation flow
- Guides the user step-by-step: start date → leave type → confirmation
- Saves the leave record by calling the internal API
- Replies "Your leave has been submitted! Reference: LV-2024-001"
The conversation is stateful — if the user stops mid-way and continues 20 minutes later, the system remembers where they left off.
How It Works — Big Picture
Step 1 — Configure MDIRECTACTION (Action Group Header)
MDIRECTACTION defines a group of buttons for a specific business entity. One row per approval scenario.
| Column | Example Value | What it means |
|---|---|---|
DIRECTACTIONID | 10 | Auto-generated PK. Referenced by MACTION when linking events to actions. |
DIRECTACTIONCODE | PO_APPROVAL | Short code. Must be unique. Use uppercase with underscores. |
DIRECTACTIONNAME | Purchase Order Approval | Display name for admin reference only. |
CONTEXTIDFIELD | PurchaseOrderId | Critical: The field name in the entity DTO that holds the PK to action on. This value is embedded in the token and passed to the API as {ContextId}. |
ASSIGNEEUSERIDFIELD | ApproverUserId | Critical: The field name in the entity DTO (or ContextBag) that holds the assignee's user ID. Becomes the user identity in the signed token. |
TENANTID | 42 | The tenant this action group belongs to. Use -1 for a global template usable by all tenants. |
STATUS | 1 | 1 = active, 0 = inactive (soft delete). |
SORTORDER | 1 | Display order in the admin UI. |
How to identify CONTEXTIDFIELD and ASSIGNEEUSERIDFIELD
Look at the entity DTO that your BLL publishes when the event fires. The field names must exactly match the JSON property names in the payload.
// Example: PurchaseOrder DTO
public class PurchaseOrderDTO
{
public int PurchaseOrderId { get; set; } ← CONTEXTIDFIELD = "PurchaseOrderId"
public int ApproverUserId { get; set; } ← ASSIGNEEUSERIDFIELD = "ApproverUserId"
public string VendorName { get; set; }
public decimal TotalAmount { get; set; }
}
// Seed SQL:
INSERT INTO MDIRECTACTION
(DIRECTACTIONID, DIRECTACTIONCODE, DIRECTACTIONNAME,
CONTEXTIDFIELD, ASSIGNEEUSERIDFIELD, TENANTID, STATUS, SORTORDER)
VALUES
(10, 'PO_APPROVAL', 'Purchase Order Approval',
'PurchaseOrderId', 'ApproverUserId', 42, 1, 1);
PurchaseOrderId (Pascal case) but CONTEXTIDFIELD says purchaseOrderId, the system will not find the value and fall back to querying the workflow task table.
Step 2 — Configure MDIRECTACTIONDETAIL (Action Buttons)
One row per button. All buttons for the same action group share the same DIRECTACTIONID.
| Column | Example Value | What it means |
|---|---|---|
DIRECTACTIONDETAILID | 101 | Auto-generated PK. |
DIRECTACTIONID | 10 | FK to MDIRECTACTION parent. |
SLNO | 1 | Display order of the button within the group. |
ACTIONCODE | PO_APPROVE | Critical: Unique code embedded in the token. Used to look up this row on every click. Must be globally unique across all tenants. |
ACTIONLABEL | Approve | Button text. For NeedsInput=1 actions, also used as the form title when the user sees the remarks input page. |
EMAILPLACEHOLDER | APPROVE_URL | The token in the email template (without ##). Email body must contain ##APPROVE_URL## for this button's link to be embedded. Leave blank if no email link needed. |
BUTTONPAYLOADPREFIX | PO_APPROVE | Prefix used in WhatsApp interactive button payload. The full payload sent is PO_APPROVE_5001 (prefix + "_" + ContextId). Leave blank if not using WhatsApp. |
APIENDPOINT | /PurchaseOrder/ApprovePO | Relative URL path of the internal GB5 API to call when this button is clicked. Do not include the base URL. |
HTTPMETHOD | POST | HTTP verb: POST, PUT, PATCH, or DELETE. |
PAYLOADTEMPLATE | {"PurchaseOrderId":{ContextId}} | JSON body sent to APIENDPOINT. Use {ContextId} for the entity PK and {Remarks} for user-entered text. Both are substituted at execution time. |
NEEDSINPUT | 0 | 0 = one-click direct action; 1 = show remarks form before executing. For reject/return buttons, use 1 to capture the reason. |
FLOWID | -1 | Reserved for future use. Set to -1 unless integrating with an EIP conversation flow. |
Full Example — Purchase Order Approval (3 buttons)
-- Button 1: Approve (direct, no remarks needed)
INSERT INTO MDIRECTACTIONDETAIL
(DIRECTACTIONDETAILID, DIRECTACTIONID, SLNO, ACTIONCODE, ACTIONLABEL,
EMAILPLACEHOLDER, BUTTONPAYLOADPREFIX, APIENDPOINT, HTTPMETHOD,
PAYLOADTEMPLATE, NEEDSINPUT, FLOWID)
VALUES
(101, 10, 1, 'PO_APPROVE', 'Approve',
'APPROVE_URL', 'PO_APPROVE', '/PurchaseOrder/ApprovePO', 'POST',
'{"PurchaseOrderId":{ContextId}}', 0, -1);
-- Button 2: Reject (requires remarks — reason for rejection)
INSERT INTO MDIRECTACTIONDETAIL
(DIRECTACTIONDETAILID, DIRECTACTIONID, SLNO, ACTIONCODE, ACTIONLABEL,
EMAILPLACEHOLDER, BUTTONPAYLOADPREFIX, APIENDPOINT, HTTPMETHOD,
PAYLOADTEMPLATE, NEEDSINPUT, FLOWID)
VALUES
(102, 10, 2, 'PO_REJECT', 'Reject',
'REJECT_URL', 'PO_REJECT', '/PurchaseOrder/RejectPO', 'POST',
'{"PurchaseOrderId":{ContextId},"RejectionReason":"{Remarks}"}', 1, -1);
-- Button 3: Return for Revision (requires comments)
INSERT INTO MDIRECTACTIONDETAIL
(DIRECTACTIONDETAILID, DIRECTACTIONID, SLNO, ACTIONCODE, ACTIONLABEL,
EMAILPLACEHOLDER, BUTTONPAYLOADPREFIX, APIENDPOINT, HTTPMETHOD,
PAYLOADTEMPLATE, NEEDSINPUT, FLOWID)
VALUES
(103, 10, 3, 'PO_RETURN', 'Return for Revision',
'RETURN_URL', 'PO_RETURN', '/PurchaseOrder/ReturnPO', 'POST',
'{"PurchaseOrderId":{ContextId},"Comments":"{Remarks}"}', 1, -1);
PayloadTemplate Cookbook
| Use Case | PayloadTemplate | NEEDSINPUT |
|---|---|---|
| Simple approve (entity PK only) | {"PurchaseOrderId":{ContextId}} | 0 |
| Approve with explicit action flag | {"Items":[{"TaskId":{ContextId},"Action":1}]} | 0 |
| Reject with reason | {"Items":[{"TaskId":{ContextId},"Action":2,"Remarks":"{Remarks}"}]} | 1 |
| Leave cancel (no payload body) | {"LeaveId":{ContextId}} | 0 |
| Claim with approver note | {"ClaimId":{ContextId},"ApproverNote":"{Remarks}","Status":1} | 1 |
Step 3 — Email Template Setup (MMAILTEMPLATE)
The email template defines the HTML body of the notification email. Buttons are embedded using the ##PLACEHOLDER## syntax, where PLACEHOLDER matches the EMAILPLACEHOLDER column in MDIRECTACTIONDETAIL.
Token Replacement Rules
##APPROVE_URL##→ replaced with the full signed URL for the APPROVE button##REJECT_URL##→ replaced with the full signed URL for the REJECT button##FieldName##→ replaced with the value of FieldName from the entity DTO (e.g.,##VendorName##)@Model.FieldName→ alternative syntax for entity field replacement
Example Email Template HTML (MMAILTEMPLATE body)
<!DOCTYPE html>
<html><body style="font-family:sans-serif;">
<p>Dear ##ApproverName##,</p>
<p>A Purchase Order requires your approval:</p>
<table border="1" cellpadding="8" style="border-collapse:collapse;">
<tr><td><b>PO Number</b></td><td>##PurchaseOrderCode##</td></tr>
<tr><td><b>Vendor</b></td><td>##VendorName##</td></tr>
<tr><td><b>Amount</b></td><td>##TotalAmount##</td></tr>
<tr><td><b>Requested By</b></td><td>##RequestedByName##</td></tr>
</table>
<p style="margin-top:24px;">Please take action:</p>
<a href="##APPROVE_URL##"
style="background:#2e7d32;color:#fff;padding:12px 28px;text-decoration:none;
border-radius:4px;margin-right:8px;display:inline-block;">
✓ Approve
</a>
<a href="##REJECT_URL##"
style="background:#c62828;color:#fff;padding:12px 28px;text-decoration:none;
border-radius:4px;margin-right:8px;display:inline-block;">
✗ Reject
</a>
<a href="##RETURN_URL##"
style="background:#e65100;color:#fff;padding:12px 28px;text-decoration:none;
border-radius:4px;display:inline-block;">
↩ Return for Revision
</a>
<p style="font-size:12px;color:#999;margin-top:32px;">
This link expires in 48 hours. Do not share this email.
</p>
</body></html>
MMAILTEMPLATE Key Columns
| Column | Value | Notes |
|---|---|---|
| TEMPLATEID | (auto PK) | Referenced by MACTION as TemplateId |
| TEMPLATECODE | PO_APPROVAL_EMAIL | Descriptive code |
| SUBJECT | Action Required: PO ##PurchaseOrderCode## Pending Approval | ##placeholders## work in subject too |
| BODY | (HTML above) | Full HTML email body |
| ISHTML | 1 | 1 = HTML email, 0 = plain text |
| TENANTID | 42 | Tenant isolation |
Step 4 — Publishing ActionEventDto from Application BLL
The application BLL (e.g., PurchaseOrderBLL) is responsible for publishing the event when the entity is saved. The framework generates tokens and sends the email automatically — but only if the publisher provides the correct fields.
Required Fields in ActionEventDto
| Field | What to set | Consequence if wrong |
|---|---|---|
DirectActionId | MDIRECTACTION.DIRECTACTIONID (e.g., 10) | No buttons in email — system can't find the action group |
ContextId | Entity PK (e.g., PurchaseOrderId = 5001) | Wrong entity actioned or 404 from API |
AssigneeUserId | The specific user's UserId in MUSER | Audit log shows wrong approver; if -1, audit is broken |
DatabaseName | login.DatabaseName | System cannot route to correct tenant DB; links will fail |
TenantId | login.ClientId | Cross-tenant security violation |
Single Approver Pattern
// In PurchaseOrderBLL.SavePurchaseOrder():
var po = await _dal.GetPurchaseOrder(poId, login, ct);
await _daprClient.PublishEventAsync("pubsub", "action-event", new ActionEventDto
{
DirectActionId = 10, // MDIRECTACTION.DIRECTACTIONID for PO_APPROVAL
ContextId = po.PurchaseOrderId,
AssigneeUserId = po.ApproverUserId, // must be from MUSER.USERID
SendTo = po.ApproverEmail, // the approver's email address
DatabaseName = login.DatabaseName,
TenantId = login.ClientId
});
Pool Approval Pattern (any one of N approvers can act)
// One event per approver — each gets their own signed token
var approvers = await _approvalDAL.GetApprovers(poId, login, ct);
foreach (var approver in approvers)
{
await _daprClient.PublishEventAsync("pubsub", "action-event", new ActionEventDto
{
DirectActionId = 10,
ContextId = po.PurchaseOrderId,
AssigneeUserId = approver.UserId, // different for each
SendTo = approver.Email,
DatabaseName = login.DatabaseName,
TenantId = login.ClientId
});
}
// Each approver gets a separate email with their own unique token.
// First to click wins. Others who click later get "Already Actioned" page.
// The API at /PurchaseOrder/ApprovePO must handle idempotent calls gracefully.
When you only have email, not UserId
// Use IUserDAL.GetUserIdByEmailAsync() to resolve
var userId = await _userDAL.GetUserIdByEmailAsync(po.ApproverEmail, login, ct);
// Then use userId in AssigneeUserId above
Step 5 — Routing Rules (MEIPROUTINGRULE)
Routing rules determine which conversation flow is triggered when a user sends a message on WhatsApp, Slack, Teams, etc.
Supported Channel Types
| ChannelType | Name | Notes |
|---|---|---|
0 | PostMan | HTTP direct-call testing channel used for development and integration testing. Not exposed to end-users. |
1 | Meta WhatsApp Business API. Supports text, interactive buttons, images, and location sharing. | |
2 | Teams | Microsoft Teams Bot Framework. Supports adaptive cards and text. Location sharing not available. |
3 | Telegram | Telegram Bot API. Supports text, inline keyboards, photos, and location sharing. |
4 | Slack | Slack Events API with Block Kit for rich interactive messages. Location sharing not available. |
5 | SMS | Plain text only via gateway (Twilio / Vonage). No interactive buttons or location support. |
6 | AppChat | GoodBooks GB5 in-app chat widget. Full feature support including native QR scanner, location sharing, file upload, and rating widgets. |
7 | Voice | Telephony / IVR channel (Twilio, Azure Bot Service, SIP). Text-to-speech output; ASR for inbound. Rich input types and location not supported. See Voice Channel section. |
| Column | Example | Notes |
|---|---|---|
ROUTINGRULEID | 1 | PK |
TENANTID | 42 | Tenant isolation |
FLOWDEFINITIONID | 5 | FK → MEIPFLOWDEFINITION — which flow to start |
ENDPOINTID | 1 | FK → MEIPCHANNELENDPOINT — which channel this rule applies to |
MATCHTYPE | 2 | 1=EQUALS, 2=STARTS_WITH, 3=CONTAINS, 4=REGEX |
MATCHVALUE | leave | The pattern to match against the user's message (lowercased) |
PRIORITY | 10 | Lower number = checked first. Recommended: 10, 20, 30 (leave gaps for future rules) |
STATUS | 1 | 1 = active |
Example Routing Table
| Priority | MATCHTYPE | MATCHVALUE | Flow | Use Case |
|---|---|---|---|---|
| 10 | EQUALS (1) | help | HELP_MENU | User types exactly "help" |
| 20 | STARTS_WITH (2) | leave | LEAVE_REQUEST | "leave", "leave request", "leave 2024" |
| 30 | STARTS_WITH (2) | order | ORDER_STATUS | "order 1234", "order status" |
| 40 | CONTAINS (3) | balance | LEAVE_BALANCE | "check my balance", "current leave balance" |
| 90 | REGEX (4) | ^PO-\d+$ | PO_QUERY | "PO-5001", "PO-12345" |
| 999 | CONTAINS (3) | . | GENERAL_HELP | Catch-all for any message (always last) |
Step 6 — Flow Definitions (MEIPFLOWDEFINITION)
Each conversational flow is stored as a single JSON document in MEIPFLOWDEFINITION.JSONDEFINITION. The JSON defines all steps and the transitions between them.
Flow JSON Structure
{
"FlowCode": "YOUR_FLOW_CODE",
"StartStepCode": "STEP_NAME_OF_FIRST_STEP",
"Steps": {
"STEP_NAME": {
"StepType": "MESSAGE|INPUT|CHOICE|CONDITIONAL|ACTION|END",
... step-specific fields ...
}
}
}
Step Type Quick Reference
| StepType | When to use | Required Fields |
|---|---|---|
MESSAGE |
Display information, wait for any reply to continue | MessageTemplate, NextStepCode |
INPUT |
Collect one piece of information (date, number, name) | MessageTemplate, ContextKey (variable name to store), optionally Validation.Pattern and Validation.Required |
CHOICE |
Present a numbered menu; branch based on selection | MessageTemplate, Choices object: {"1. Option A": "STEP_A", "2. Option B": "STEP_B"} |
CONDITIONAL |
Branch based on a context variable value (no user input needed) | ConditionKey (variable name), Conditions array (operator/value/NextStepCode), DefaultNextStepCode |
ACTION |
Call an internal GB5 API silently (no user interaction) | ActionType: "CALL_API", ApiConfig.Endpoint, ApiConfig.Method, ApiConfig.Body, optionally ContextKey to store result |
END |
Send final message and close the conversation | MessageTemplate (final message text) |
QUALIFY |
Run a business-rule qualifier against context data; route on pass or error findings | BoundToType, InputKeys[], OutputFactKey, MapperCode, OnValidationErrorStepCode, NextStepCode |
ENRICH |
Transform one context variable through a mapper and store the result under a new key | MapperCode, InputKey, OutputKey, NextStepCode |
DYNAMIC_CHOICE |
Present buttons generated from a JSON array stored in context (populated by a prior API call) | SourceKey, LabelField, ValueField, StoreSelectedKey, MessageTemplate, NextStepCode; optionally DataPath, StoreItemKey, MaxChoices |
LOCATION |
Pause flow until user shares geo-location; store {"Lat","Lng","Address"} JSON under ContextKey |
ContextKey, MessageTemplate, NextStepCode |
LINK |
Emit interactive buttons (URL / CALL / QR) and proceed immediately without waiting for user input | MessageTemplate, LinkButtons[] (Title, Url), NextStepCode; optionally ButtonType |
PRESENT |
Render a context JSON array or object as a structured TABLE, CARD, or LIST and proceed immediately | ContextKey, ResponseFormat.Type, ResponseFormat.Fields[], NextStepCode; optionally MessageTemplate, ResponseFormat.MaxRows, ResponseFormat.TitleField |
Variable Substitution
Use {VariableName} anywhere in MessageTemplate or ApiConfig.Body to insert a value the user previously entered. Variables are stored by ContextKey in INPUT steps.
// User answered "John" in an INPUT step with ContextKey="UserName"
// Later:
"MessageTemplate": "Hello {UserName}! Your request has been submitted."
// Renders as: "Hello John! Your request has been submitted."
If a context variable holds a JSON object string (e.g., stored by a CALL_API ACTION step that returns a structured response), you can extract a specific field using dot-notation: {VariableName.FieldName}.
The engine parses the stored JSON and extracts FieldName from the root object. For example, if EmployeeDetails holds {"EmployeeName":"Alice","Department":"Finance"}, then {EmployeeDetails.EmployeeName} substitutes as Alice.
Nested paths (e.g., {EmployeeDetails.Address.City}) are supported up to three levels deep. If the field is not found, the placeholder is left as an empty string.
Complete Example: Purchase Order Approval
This example walks through the full setup for a 3-button email approval flow for Purchase Orders.
What happens end-to-end
- Purchaser raises a PO →
PurchaseOrderBLL.SavePO()publishes an ActionEventDto - EmailActionHandler generates 3 signed links (Approve / Reject / Return) and sends the email
- Approver sees email, clicks "Reject", enters reason in the remarks form
- System calls
POST /PurchaseOrder/RejectPOwith the PO ID and reason - Approver sees "Action Submitted" confirmation page
Database Setup
-- 1. Action Group Header
INSERT INTO MDIRECTACTION VALUES
(10, 'PO_APPROVAL', 'Purchase Order Approval',
'PurchaseOrderId', 'ApproverUserId', 42, 1, 1, GETUTCDATE(), NULL, NULL, GETUTCDATE());
-- 2. Approve button (NeedsInput=0 — one click, no form)
INSERT INTO MDIRECTACTIONDETAIL VALUES
(101, 10, 1, 'PO_APPROVE', 'Approve',
'APPROVE_URL', 'PO_APPROVE',
'/PurchaseOrder/ApprovePO', 'POST',
'{"PurchaseOrderId":{ContextId}}', 0, -1);
-- 3. Reject button (NeedsInput=1 — shows remarks form)
INSERT INTO MDIRECTACTIONDETAIL VALUES
(102, 10, 2, 'PO_REJECT', 'Reject Purchase Order',
'REJECT_URL', 'PO_REJECT',
'/PurchaseOrder/RejectPO', 'POST',
'{"PurchaseOrderId":{ContextId},"RejectionReason":"{Remarks}"}', 1, -1);
-- 4. Return button (NeedsInput=1 — shows remarks form)
INSERT INTO MDIRECTACTIONDETAIL VALUES
(103, 10, 3, 'PO_RETURN', 'Return for Revision',
'RETURN_URL', 'PO_RETURN',
'/PurchaseOrder/ReturnPO', 'POST',
'{"PurchaseOrderId":{ContextId},"Comments":"{Remarks}"}', 1, -1);
Email Template (MMAILTEMPLATE body)
Subject: Action Required: PO ##PurchaseOrderCode## needs your approval
Body:
<p>Dear ##ApproverName##,</p>
<p>PO <b>##PurchaseOrderCode##</b> from vendor <b>##VendorName##</b>
for amount <b>##TotalAmount##</b> requires your approval.</p>
<a href="##APPROVE_URL##" style="...green button...">✓ Approve</a>
<a href="##REJECT_URL##" style="...red button...">✗ Reject</a>
<a href="##RETURN_URL##" style="...orange button...">↩ Return</a>
<p>Links expire in 48 hours.</p>
BLL Publisher Code
// In PurchaseOrderBLL.ApprovePO() — after entity is created:
await _eventPublisher.PublishAsync(new ActionEventDto
{
DirectActionId = 10,
ContextId = po.PurchaseOrderId,
AssigneeUserId = po.ApproverUserId,
SendTo = po.ApproverEmail,
DatabaseName = login.DatabaseName,
TenantId = login.ClientId
}, login, ct);
What the approver sees
Complete Example: WhatsApp Leave Request Bot
Database Setup
-- Routing rule (WhatsApp channel)
INSERT INTO MEIPROUTINGRULE VALUES
(1, 42, 5, 1, 2, 'leave', 20, 1, GETUTCDATE(), NULL, GETUTCDATE());
-- MATCHTYPE=2 (STARTS_WITH), MATCHVALUE='leave', PRIORITY=20
-- Flow definition
INSERT INTO MEIPFLOWDEFINITION VALUES
(5, 42, 'LEAVE_REQUEST', 1, 1, '{ ... JSON below ... }', 1, 1, 1, GETUTCDATE(), NULL, NULL);
Flow JSON (JSONDEFINITION)
{
"FlowCode": "LEAVE_REQUEST",
"StartStepCode": "GREETING",
"Steps": {
"GREETING": {
"StepType": "MESSAGE",
"MessageTemplate": "Hi! I'll help you apply for leave.\nPlease type the start date (YYYY-MM-DD):",
"NextStepCode": "GET_START_DATE"
},
"GET_START_DATE": {
"StepType": "INPUT",
"MessageTemplate": "Enter your leave start date:",
"ContextKey": "LeaveStartDate",
"Validation": { "Required": true, "Pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
"NextStepCode": "GET_END_DATE"
},
"GET_END_DATE": {
"StepType": "INPUT",
"MessageTemplate": "Enter your leave end date:",
"ContextKey": "LeaveEndDate",
"Validation": { "Required": true, "Pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
"NextStepCode": "CHOOSE_TYPE"
},
"CHOOSE_TYPE": {
"StepType": "CHOICE",
"MessageTemplate": "What type of leave?",
"Choices": {
"1. Annual Leave": "CONFIRM",
"2. Sick Leave": "CONFIRM",
"3. Unpaid Leave": "CONFIRM"
}
},
"CONFIRM": {
"StepType": "MESSAGE",
"MessageTemplate": "Submitting leave from {LeaveStartDate} to {LeaveEndDate}...",
"NextStepCode": "SUBMIT"
},
"SUBMIT": {
"StepType": "ACTION",
"ActionType": "CALL_API",
"ApiConfig": {
"Endpoint": "/Leave/SaveLeave",
"Method": "POST",
"Body": "{\"StartDate\":\"{LeaveStartDate}\",\"EndDate\":\"{LeaveEndDate}\",\"LeaveType\":1}"
},
"ContextKey": "LeaveRef",
"NextStepCode": "DONE"
},
"DONE": {
"StepType": "END",
"MessageTemplate": "✅ Leave submitted successfully!\nReference: {LeaveRef}\nYour manager will be notified."
}
}
}
Conversation on WhatsApp
QUALIFY Step
Runs a configured qualifier against session context data and routes based on the result. Use QUALIFY to enforce business rules (e.g., credit limit check, duplicate detection) without writing flow-specific API endpoints.
| Field | Required | Description |
|---|---|---|
BoundToType | Yes | The entity type name this qualifier applies to (must match a row in MQUALIFIERDEFINITION.BOUNDTOTYPE). |
InputKeys | Yes | Array of context variable names whose values are passed to the qualifier as input facts. |
OutputFactKey | Yes | Context variable name where the qualifier's output fact JSON is stored after execution. |
MapperCode | Yes | Code of the mapper that transforms the raw qualifier output into the context fact schema. |
OnValidationErrorStepCode | Yes | Step to route to when the qualifier returns one or more Error-severity findings. |
NextStepCode | Yes | Step to route to when qualification passes (no Error findings). |
When the qualifier finds Error-severity findings, the engine stores a summary string in _QualifyError and routes to OnValidationErrorStepCode. Use {_QualifyError} in a subsequent MESSAGE step to display the reason to the user.
{
"StepType": "QUALIFY",
"BoundToType": "LeaveRequest",
"InputKeys": ["LeaveStartDate", "LeaveEndDate", "EmployeeId"],
"OutputFactKey": "QualifyResult",
"MapperCode": "LEAVE_QUALIFY_MAPPER",
"OnValidationErrorStepCode": "QUALIFY_ERROR",
"NextStepCode": "SUBMIT_LEAVE"
},
"QUALIFY_ERROR": {
"StepType": "MESSAGE",
"MessageTemplate": "Your leave request cannot be submitted: {_QualifyError}",
"NextStepCode": "RESTART_OR_EXIT"
}
ENRICH Step
Runs a mapper against a single context variable and stores the transformed output under a new key. Use ENRICH to format, augment, or restructure data collected in earlier steps without calling an external API.
| Field | Required | Description |
|---|---|---|
MapperCode | Yes | Code of the mapper to execute. Must exist in MMAPPER. |
InputKey | Yes | Context variable name whose value is passed as input to the mapper. |
OutputKey | Yes | Context variable name where the mapper output is stored. |
NextStepCode | Yes | Step to proceed to after enrichment. |
{
"StepType": "ENRICH",
"MapperCode": "EMPLOYEE_DETAILS_MAPPER",
"InputKey": "ScannedEmployeeCode",
"OutputKey": "EmployeeDetails",
"NextStepCode": "CONFIRM_EMPLOYEE"
}
DYNAMIC_CHOICE Step
Presents a choice menu where the options are built dynamically from a list stored in the session context (populated by a preceding CALL_API or ACTION step), rather than being hardcoded in the flow JSON.
| Field | Required | Description |
|---|---|---|
SourceKey | Yes | Context variable name holding the JSON array of available options. |
DataPath | No | Dot-notation path into the source JSON to reach the array (e.g., "Data.Items"). Omit if SourceKey already holds the array directly. |
LabelField | Yes | Field name within each array element to use as the button label shown to the user. |
ValueField | Yes | Field name within each array element whose value is stored on selection. |
StoreSelectedKey | Yes | Context variable name where the selected item's ValueField is stored. |
StoreItemKey | No | If set, the entire selected array element (as JSON) is stored under this context variable name for downstream steps. |
MaxChoices | No | Maximum number of buttons to render (default: 10). Excess options are truncated. Use pagination or a more specific CALL_API filter if the source list is large. |
MessageTemplate | Yes | Prompt text displayed above the generated buttons. |
NextStepCode | Yes | Step to route to after the user makes a selection. |
On first visit the step sends the prompt with the dynamically generated buttons. When the user selects an option, the ValueField of the chosen item is stored in StoreSelectedKey and the flow proceeds to NextStepCode.
{
"StepType": "DYNAMIC_CHOICE",
"MessageTemplate": "Select the project to log time against:",
"SourceKey": "ActiveProjects",
"DataPath": "Data.Projects",
"LabelField": "ProjectName",
"ValueField": "ProjectId",
"StoreSelectedKey": "SelectedProjectId",
"StoreItemKey": "SelectedProject",
"MaxChoices": 8,
"NextStepCode": "GET_HOURS"
}
LOCATION Step
Pauses the flow and prompts the user to share their current location. Supported on AppChat, WhatsApp, and Telegram. On other channels the step sends the prompt text but cannot receive a native location payload — users must type coordinates manually.
| Field | Required | Description |
|---|---|---|
ContextKey | Yes | Context variable name where the location is stored as a JSON object {"Lat":..., "Lng":..., "Address":"..."}. |
MessageTemplate | Yes | Prompt text sent to the user (e.g., "Please share your current location."). |
NextStepCode | Yes | Step to proceed to once a valid location is received. |
After the user shares their location the engine stores the full location object under ContextKey. Individual fields are accessible using dot-notation: {DeliveryLocation.Lat}, {DeliveryLocation.Lng}, {DeliveryLocation.Address}.
{
"StepType": "LOCATION",
"MessageTemplate": "Please share your current location so we can dispatch a technician.",
"ContextKey": "CustomerLocation",
"NextStepCode": "DISPATCH_TECHNICIAN"
},
"DISPATCH_TECHNICIAN": {
"StepType": "ACTION",
"ActionType": "CALL_API",
"ApiConfig": {
"Endpoint": "/Dispatch/CreateJob",
"Method": "POST",
"Body": "{\"Latitude\":{CustomerLocation.Lat},\"Longitude\":{CustomerLocation.Lng},\"Address\":\"{CustomerLocation.Address}\"}"
},
"NextStepCode": "DISPATCH_DONE"
}
LINK Step
Emits one or more interactive buttons and immediately routes to the next step without waiting for user input. Use LINK to present quick-access URLs, phone call shortcuts, or QR codes alongside a message.
| Field | Required | Description |
|---|---|---|
MessageTemplate | Yes | Text displayed above the buttons. |
LinkButtons | Yes | Array of button objects, each with Title (button label) and Url (destination URL, tel: URI, or payload string). |
ButtonType | No | Rendering hint: REPLY (chat button, default), URL (opens browser), CALL (tel: link), QR (renders URL as QR image). |
NextStepCode | Yes | Step to proceed to immediately after sending the buttons (no user reply expected). |
{
"StepType": "LINK",
"MessageTemplate": "Here are your quick actions for PO {POCode}:",
"ButtonType": "URL",
"LinkButtons": [
{ "Title": "View PO Details", "Url": "https://erp.example.com/po/{POCode}" },
{ "Title": "Download PDF", "Url": "https://erp.example.com/po/{POCode}/pdf" },
{ "Title": "Contact Vendor", "Url": "tel:+971501234567" }
],
"NextStepCode": "AWAIT_REPLY"
}
PRESENT Step
Renders a context variable containing a JSON object or array as a structured visual display (table, card, or list). Use PRESENT after a CALL_API step that returns a dataset to show the results to the user in a readable format.
| Field | Required | Description |
|---|---|---|
ContextKey | Yes | Context variable name holding the JSON data to display. |
MessageTemplate | No | Optional header text shown above the rendered data. |
ResponseFormat.Type | Yes | Rendering mode: TABLE (grid layout), CARD (one card per item with label/value pairs), or LIST (simple bulleted list of a single field per item). |
ResponseFormat.Fields | Yes | Array of field names from the JSON objects to include in the output. Order determines column/display order. |
ResponseFormat.MaxRows | No | Maximum number of rows/cards to render (default: 10). Data beyond this limit is silently truncated. |
ResponseFormat.TitleField | No | Field name used as the card title when Type = CARD. |
NextStepCode | Yes | Step to proceed to after rendering (PRESENT does not wait for user input). |
{
"StepType": "PRESENT",
"ContextKey": "OpenPOList",
"MessageTemplate": "Your open purchase orders:",
"ResponseFormat": {
"Type": "TABLE",
"Fields": ["POCode", "VendorName", "TotalAmount", "Status"],
"MaxRows": 5
},
"NextStepCode": "ASK_NEXT_ACTION"
}
Type: "CARD" when displaying a single record's full details (e.g., a purchase order with many fields). Set TitleField to the field that identifies the item (e.g., "POCode") so each card has a clear heading.
Rich Input Types
INPUT steps support a Validation.InputType field that controls how the client renders the input prompt and how the engine validates the response. When InputType is omitted, the step behaves as a plain freeform text input.
| InputType | Behaviour | Stored Value |
|---|---|---|
text |
Default. Freeform string. Optional Validation.Pattern regex applied server-side. |
The raw string the user typed. |
number |
Numeric keyboard hint on mobile. Rejects non-numeric input and returns an error message if Validation.Required = true. |
The number as a string (e.g., "42"). |
date |
Date picker on AppChat; expects YYYY-MM-DD on text channels. Validates the date is parseable. |
ISO date string ("2026-08-15"). |
email |
Email keyboard hint. Validates RFC 5322 format server-side. | Lowercased email string. |
phone |
Phone keyboard hint. Strips spaces and dashes; validates E.164 format when Validation.E164 = true. |
Normalized phone string (e.g., "+971501234567"). |
qr_scan |
Prompts the user to scan a QR code with their device camera (AppChat native; other channels show instructions). See QR & Geo-Location section. | Decoded QR string. |
file |
File upload prompt. Accepts MIME types listed in Validation.AllowedMimeTypes[]. Max size controlled by Validation.MaxFileSizeKb. |
Secure URL of the uploaded file in the EIP document store. |
rating |
Renders a star-rating widget (1–N stars) on AppChat; text channels accept a numeric reply. Validation.RatingMax sets the maximum value (default 5). |
Integer string (e.g., "4"). |
Example — Rating INPUT Step
{
"StepType": "INPUT",
"MessageTemplate": "How would you rate your delivery experience? (1–5 stars)",
"ContextKey": "DeliveryRating",
"Validation": {
"InputType": "rating",
"RatingMax": 5,
"Required": true
},
"NextStepCode": "RATING_CONDITIONAL"
}
QR Codes & Geo-Location
Embedding QR Codes in MESSAGE and PRESENT Steps
Add a QrCodeData field to any MESSAGE or PRESENT step to render a QR code alongside the text content. The value should be the string to encode (URL, JSON payload, plain reference code, etc.).
{
"StepType": "MESSAGE",
"MessageTemplate": "Scan this QR code to open your delivery tracking page:",
"QrCodeData": "https://track.example.com/delivery/{DeliveryRef}",
"NextStepCode": "AWAIT_SCAN_CONFIRM"
}
Per-Channel Rendering Behaviour
| Channel | Rendering |
|---|---|
| AppChat (6) | Native QR canvas rendered client-side from the QrCodeData string. No image URL required. |
| WhatsApp (1) | QR code pre-rendered server-side as a PNG image and sent as a WhatsApp image message with the caption from MessageTemplate. |
| Telegram (3) | PNG sent via sendPhoto API with the caption from MessageTemplate. |
| Teams (2), Slack (4), SMS (5) | QR image attached as an adaptive card image (Teams/Slack) or an MMS attachment (SMS). Falls back to a shortened URL if the platform does not support media. |
| Voice (7) | QrCodeData is ignored; voice channels cannot display visual content. |
qr_scan INPUT Type
Use Validation.InputType = "qr_scan" on an INPUT step to prompt the user to scan a QR code with their device camera. The decoded string is stored in the variable named by ContextKey.
{
"StepType": "INPUT",
"MessageTemplate": "Please scan the asset QR tag to continue:",
"ContextKey": "ScannedAssetCode",
"Validation": { "InputType": "qr_scan", "Required": true },
"NextStepCode": "LOOKUP_ASSET"
}
Inbound Location Messages
When a user shares their location via a channel that supports it (AppChat, WhatsApp, Telegram), the EIP engine automatically extracts the coordinates and address into the session context before routing to the next step. No explicit LOCATION step is required to receive a location — it can arrive as the response to any INPUT or CHOICE step.
The auto-extracted context variables are:
| Variable | Type | Content |
|---|---|---|
_Location.Lat | decimal | Latitude (e.g., 25.2048) |
_Location.Lng | decimal | Longitude (e.g., 55.2708) |
_Location.Address | string | Reverse-geocoded address string, or empty string if geocoding is unavailable |
These variables are accessible in all subsequent steps via {_Location.Lat}, {_Location.Lng}, and {_Location.Address} substitutions, and can also be read by a LOCATION step's ContextKey (the LOCATION step stores them as a single JSON object under the configured key).
appsettings.json under EIP:GeocodingProvider (supported values: GoogleMaps, AzureMaps, Nominatim). If no provider is configured, _Location.Address is always an empty string but Lat/Lng are still populated.
Voice Channel
EIP supports voice as a first-class channel type (ChannelType = 7). Voice conversations follow the same step-based flow engine as text channels. The key difference is that outbound messages are converted to speech and inbound audio is transcribed to text by the telephony platform before reaching the engine.
Enabling a Flow for Voice
Set the channel endpoint's CHANNELTYPE to 7 in MEIPCHANNELENDPOINT. No flow JSON changes are required — the same flow definition can serve both text and voice channels simultaneously through separate routing rules.
VoiceHint and SSML on MESSAGE Steps
Add an optional VoiceHint field to any MESSAGE or END step to provide Speech Synthesis Markup Language (SSML) instructions to the TTS engine. When VoiceHint is present, its content is sent as the spoken text; the plain MessageTemplate is still returned as display text for non-voice channels.
{
"StepType": "MESSAGE",
"MessageTemplate": "Your purchase order has been approved. Reference number: PO-5001.",
"VoiceHint": "<speak>Your purchase order has been approved. <break time='500ms'/> Reference number: <say-as interpret-as='characters'>PO-5001</say-as></speak>",
"NextStepCode": "DONE"
}
Handler Response for Voice
When the flow engine processes a voice session, the response envelope includes an AdditionalData object that the telephony adapter uses to drive the TTS/speech pipeline:
| Field | Type | Description |
|---|---|---|
TtsText | string | Plain-text version of the message for basic TTS engines that do not support SSML. |
SsmlText | string | Full SSML markup from VoiceHint. Null when VoiceHint is not set. |
IsCompleted | bool | true when the step is an END step and the call should be terminated by the telephony platform. |
Platform Integration Notes
| Platform | Webhook Configuration | Notes |
|---|---|---|
| Twilio | Set "A Call Comes In" webhook to POST /MessageHubGenerator/RequestMessageHubGenerator | Return TwiML <Say> or <Play> verbs built from SsmlText/TtsText in the adapter layer. |
| Azure Bot Service | Configure Direct Line Speech channel; point Bot endpoint to the EIP voice adapter | Adapter maps Activity type message to the EIP request and returns Activity with speak property set from SsmlText. |
| Generic SIP | Use a media server (e.g., FreeSWITCH, Asterisk) to POST ASR transcripts to the EIP endpoint | Response TtsText must be fed back to the TTS synthesizer and played to the caller. Set IsCompleted=true to hangup. |
Validation.InputType of qr_scan, file, or rating cannot be collected over voice. Use CONDITIONAL steps to route voice sessions to text-only sub-flows when these input types are required.
New Approval Flow Checklist
Follow these steps in order when setting up a new DirectAction approval flow from scratch:
- Identify the entity DTO that will be published (e.g., PurchaseOrderDTO). Note the exact field names for the entity PK and approver user ID.
- Insert one row in
MDIRECTACTIONwith CONTEXTIDFIELD and ASSIGNEEUSERIDFIELD matching the DTO field names exactly. - Insert one row in
MDIRECTACTIONDETAILper button (Approve, Reject, Return). Set NEEDSINPUT=0 for direct actions, NEEDSINPUT=1 for actions that need a reason/comments. - Create or reuse an entry in
MMAILTEMPLATE. Add##PLACEHOLDER##tokens in the HTML body matching the EMAILPLACEHOLDER column values from MDIRECTACTIONDETAIL. - Configure the MACTION row to link the EventTypeId to this DirectActionId and the mail template.
- In the application BLL (e.g., PurchaseOrderBLL), publish ActionEventDto with correct DirectActionId, ContextId, AssigneeUserId, DatabaseName, and TenantId.
- Test by raising a sample entity. Verify the email arrives with correct buttons and entity details filled in.
- Click Approve. Verify the internal API is called correctly and the confirmation page appears.
- Click Approve again. Verify "Already Actioned" page appears (one-time-use working).
- For NeedsInput=1 buttons: click, verify remarks form appears. Submit with text. Verify API payload contains the remarks text.
- Check TDIRECTACTIONTOKEN table: verify ASSIGNEEUSERID, CONTEXTID, REMARKS, EXPIRESAT columns are populated correctly.
- Test with expired token: manually update TDIRECTACTIONTOKEN... or wait 48h. Verify "Link Expired" page.
- QUALIFY steps: Ensure the qualifier is defined in
MQUALIFIERDEFINITIONwith the correctBoundToTypematching the entity being validated. VerifyMapperCodeexists and returns findings in the expected schema. - DYNAMIC_CHOICE steps: Confirm the
SourceKeyis populated in the session context by a precedingCALL_APIorACTIONstep before the DYNAMIC_CHOICE step runs. An empty source list renders zero buttons and stalls the flow. - LOCATION steps: Verify the channel supports location sharing — AppChat, WhatsApp, and Telegram are supported. SMS, Voice, and Teams do not support inbound location messages; using a LOCATION step on those channels will time out.
- Voice channel: Register the voice endpoint URL in the calling platform webhook configuration (Twilio: "A Call Comes In" → HTTP POST; Azure Bot Service: Direct Line Speech endpoint). Confirm the platform can reach the GB5 host on the configured port before go-live.
Security Notes for Administrators
Token Security
- Expiry: All email links expire after 48 hours from the time the email was sent. Users clicking expired links see "Link Expired or Invalid".
- One-time use: Each link can only be clicked once. The system tracks usage in TDIRECTACTIONTOKEN.
- Tamper protection: Tokens are signed with HMAC-SHA256. Modifying the link (even one character) invalidates it.
- Do not share: Each link is personal — it contains the assignee's user identity. Forwarding the email to a colleague and having them click the button would record the wrong approver in the audit log.
Revoking a Compromised Link
If an email is sent to the wrong person, or you need to cancel a pending approval link immediately:
-- Revoke all tokens for a specific context (e.g., all PO 5001 approval links)
UPDATE TDIRECTACTIONTOKEN
SET STATUS = 2 -- 2 = Revoked
WHERE CONTEXTID = 5001
AND ACTIONCODE IN ('PO_APPROVE', 'PO_REJECT', 'PO_RETURN')
AND TENANTID = 42;
-- User clicking a revoked link sees: "This link has been revoked."
Re-sending Approval Emails
-- Request a fresh email with new tokens via the API:
POST /Action/Resend
{
"EventTypeId": 42, -- the event type that triggered the original email
"ContextId": 5001 -- entity PK
}
-- New tokens are generated (48h from now).
-- Old tokens remain valid until their original expiry but become ineffective
-- once the entity status changes (if the API handles idempotency correctly).
Audit Trail
Every click is recorded in TDIRECTACTIONTOKEN. To check who approved what:
-- Find all actions taken on PO 5001
SELECT
t.ACTIONCODE,
t.ASSIGNEEUSERID,
u.USERNAME,
t.USEDAT,
t.REMARKS,
t.STATUS
FROM TDIRECTACTIONTOKEN t
JOIN MUSER u ON u.USERID = t.ASSIGNEEUSERID
WHERE t.CONTEXTID = 5001
AND t.TENANTID = 42
ORDER BY t.USEDAT;
Troubleshooting Guide
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Approval email received but buttons/links are missing | EMAILPLACEHOLDER in MDIRECTACTIONDETAIL doesn't match ##TOKEN## in MMAILTEMPLATE, OR MDIRECTACTIONDETAIL rows have STATUS=0 | 1. Check that EMAILPLACEHOLDER = 'APPROVE_URL' and template has ##APPROVE_URL## (exact match). 2. Ensure MDIRECTACTIONDETAIL.STATUS = 1. |
| Email field values show as ##FieldName## (not replaced) | Field name in ##placeholder## doesn't match any property in the entity DTO or ContextBag | Check entity DTO property names (case-sensitive). Use structured logs to find the resolved entity payload and compare field names. |
| Clicking Approve shows "Link Expired or Invalid" | Token expired (>48h), link was tampered, or the secret key changed | Check email send timestamp. If within 48h: verify DirectAction:TokenSecret appsettings key hasn't changed. Use /Action/Resend to generate fresh links. |
| Clicking Approve shows "Already Actioned" | Token was already used (possibly by another approver, or user double-clicked) | Expected behavior. Check TDIRECTACTIONTOKEN to see who used it and when. If accidental, there is no undo — the API call was made. Reverse it at the business logic level. |
| Action appears to succeed (confirmation page shown) but entity status not updated | Internal API returned 400 (business error) — check logs. OR PAYLOADTEMPLATE has wrong field names | Check application logs for DirectAction [FAIL] entries. Check if PAYLOADTEMPLATE JSON is valid and field names match the API's expected contract. |
| TDIRECTACTIONTOKEN.ASSIGNEEUSERID = -1 or 0 | Publisher BLL did not set AssigneeUserId in ActionEventDto | Review the BLL that publishes the event. Ensure AssigneeUserId is set from the entity's approver field (not hardcoded to 0 or -1). If only email is available, use GetUserIdByEmailAsync(). |
| WhatsApp button press has no effect (no log entries) | BUTTONPAYLOADPREFIX doesn't match the payload sent by WhatsApp | Check the webhook logs for the raw payload. Compare to MDIRECTACTIONDETAIL.BUTTONPAYLOADPREFIX. The match is case-insensitive and must include the trailing "_" before the ContextId. |
| WhatsApp button press logs "Framework base URL could not be determined" | This was a bug now fixed. Upgrade to the latest FrameworkSL build. | The fix threads frameworkBaseUrl from RequestMessageHubGenerator through to the action handler. |
| EIP conversation not resuming (restarts from beginning each time) | Session expired (30-min idle TTL) or user/channel/tenant combination has no active session | Expected if user was idle for >30 min. If within 30 min: check TEIUSERSESSION for the user's USERIDENTIFIER (phone/email), TENANTID, and CHANNELTYPE combination. Verify STATUS=1 and LASTACTIVITYAT is recent. |
| EIP conversation stuck (not responding to input) | Flow definition has a step with no NextStepCode for the user's input, or a CHOICE step received an unrecognized option | Check TEIUSERSESSION.CURRENTSTEPCODE. Look at the flow JSON to find that step. CHOICE steps only accept exact matches from the Choices keys — verify the user's reply matches a key. |
| EIP ACTION step fails silently | CRUD_OPERATION endpoint returned non-2xx, or the API URL is incorrect | Search logs for CrudOperationHandler or IntegrationActionHandler ERROR entries. Verify ApiConfig.Endpoint path is correct and the internal API is running. |
| Email buttons look correct but the wrong entity is actioned | CONTEXTIDFIELD in MDIRECTACTION doesn't match the actual field name in the entity DTO | Review MDIRECTACTION.CONTEXTIDFIELD. Must match exactly the JSON property name in the published payload (case-sensitive). Check structured logs for the extracted contextId value. |
Useful Diagnostic Queries
-- Check recent DirectAction usage for a tenant
SELECT TOP 20
t.ACTIONCODE,
t.CONTEXTID,
t.ASSIGNEEUSERID,
t.USEDAT,
t.STATUS,
t.REMARKS,
t.EXPIRESAT
FROM TDIRECTACTIONTOKEN t
WHERE t.TENANTID = 42
ORDER BY t.CREATEDON DESC;
-- Check active EIP sessions for a user
SELECT *
FROM TEIUSERSESSION
WHERE USERIDENTIFIER = '+9715551234'
AND TENANTID = 42
AND STATUS = 1;
-- Check recent action pipeline runs
SELECT TOP 20
r.ACTIONRUNID,
r.RUNSTATUS,
r.ERRORMESSAGE,
r.CREATEDON
FROM TEVENTACTIONRUN r
WHERE r.TENANTID = 42
ORDER BY r.CREATEDON DESC;
-- Check pending outbox messages (not yet sent to Dapr)
SELECT COUNT(*) AS PendingCount
FROM TACTIONOUTBOX
WHERE SENDSTATUS = 0
AND TENANTID = 42;
GoodBooks GB5 — EIP & DirectAction Admin Guide — generated 2026-06-20