GoodBooks GB5 — EIP & DirectAction Implementation Guide

For system administrators, ERP consultants, and implementation teams. Covers all configuration needed to set up approval flows, email actions, and conversational chatbot flows.

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:

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:

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

Entity Saved (e.g. PO raised) │ ▼ Event Published → Action Pipeline → EmailActionHandler │ └─ Generates clickable approval links (signed tokens, 48h expiry) Embeds them in email body: [Approve] [Reject] [Return] Sends email to the configured approver Approver clicks [Approve] │ ▼ GET /Action/Execute?token=... │ ├─ Validates token (signature + expiry + one-time-use) ├─ Calls POST /PurchaseOrder/ApprovePO with the PO ID └─ Shows "Action Successful" page WhatsApp Message: "start leave" │ ▼ POST /MessageHubGenerator/RequestMessageHubGenerator │ ├─ Routing: "start leave" → LEAVE_REQUEST flow ├─ Flow Engine: collects dates, type, confirms └─ Calls POST /Leave/SaveLeave → replies to user

Step 1 — Configure MDIRECTACTION (Action Group Header)

MDIRECTACTION defines a group of buttons for a specific business entity. One row per approval scenario.

MDIRECTACTION — Column Reference
ColumnExample ValueWhat it means
DIRECTACTIONID10Auto-generated PK. Referenced by MACTION when linking events to actions.
DIRECTACTIONCODEPO_APPROVALShort code. Must be unique. Use uppercase with underscores.
DIRECTACTIONNAMEPurchase Order ApprovalDisplay name for admin reference only.
CONTEXTIDFIELDPurchaseOrderIdCritical: 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}.
ASSIGNEEUSERIDFIELDApproverUserIdCritical: The field name in the entity DTO (or ContextBag) that holds the assignee's user ID. Becomes the user identity in the signed token.
TENANTID42The tenant this action group belongs to. Use -1 for a global template usable by all tenants.
STATUS11 = active, 0 = inactive (soft delete).
SORTORDER1Display 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);
Field names are case-sensitive
The value in CONTEXTIDFIELD must match exactly the JSON property name in the published entity payload. If the DTO has 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.

MDIRECTACTIONDETAIL — Column Reference
ColumnExample ValueWhat it means
DIRECTACTIONDETAILID101Auto-generated PK.
DIRECTACTIONID10FK to MDIRECTACTION parent.
SLNO1Display order of the button within the group.
ACTIONCODEPO_APPROVECritical: Unique code embedded in the token. Used to look up this row on every click. Must be globally unique across all tenants.
ACTIONLABELApproveButton text. For NeedsInput=1 actions, also used as the form title when the user sees the remarks input page.
EMAILPLACEHOLDERAPPROVE_URLThe 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.
BUTTONPAYLOADPREFIXPO_APPROVEPrefix used in WhatsApp interactive button payload. The full payload sent is PO_APPROVE_5001 (prefix + "_" + ContextId). Leave blank if not using WhatsApp.
APIENDPOINT/PurchaseOrder/ApprovePORelative URL path of the internal GB5 API to call when this button is clicked. Do not include the base URL.
HTTPMETHODPOSTHTTP 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.
NEEDSINPUT00 = one-click direct action; 1 = show remarks form before executing. For reject/return buttons, use 1 to capture the reason.
FLOWID-1Reserved 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 CasePayloadTemplateNEEDSINPUT
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
Tip: keep PayloadTemplate minimal
Only include fields the internal API actually reads. Extra fields are silently ignored, but large payloads increase log noise. Put entity-specific data retrieval inside the API, not in the payload.

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

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>
One-time-use links
Once a button is clicked, the link is permanently consumed. If the approver clicks Approve and then tries to click Reject, they will see "Already Actioned". Ensure your email copy explains this to users.

MMAILTEMPLATE Key Columns

ColumnValueNotes
TEMPLATEID(auto PK)Referenced by MACTION as TemplateId
TEMPLATECODEPO_APPROVAL_EMAILDescriptive code
SUBJECTAction Required: PO ##PurchaseOrderCode## Pending Approval##placeholders## work in subject too
BODY(HTML above)Full HTML email body
ISHTML11 = HTML email, 0 = plain text
TENANTID42Tenant 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

FieldWhat to setConsequence if wrong
DirectActionIdMDIRECTACTION.DIRECTACTIONID (e.g., 10)No buttons in email — system can't find the action group
ContextIdEntity PK (e.g., PurchaseOrderId = 5001)Wrong entity actioned or 404 from API
AssigneeUserIdThe specific user's UserId in MUSERAudit log shows wrong approver; if -1, audit is broken
DatabaseNamelogin.DatabaseNameSystem cannot route to correct tenant DB; links will fail
TenantIdlogin.ClientIdCross-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

ChannelTypeNameNotes
0PostManHTTP direct-call testing channel used for development and integration testing. Not exposed to end-users.
1WhatsAppMeta WhatsApp Business API. Supports text, interactive buttons, images, and location sharing.
2TeamsMicrosoft Teams Bot Framework. Supports adaptive cards and text. Location sharing not available.
3TelegramTelegram Bot API. Supports text, inline keyboards, photos, and location sharing.
4SlackSlack Events API with Block Kit for rich interactive messages. Location sharing not available.
5SMSPlain text only via gateway (Twilio / Vonage). No interactive buttons or location support.
6AppChatGoodBooks GB5 in-app chat widget. Full feature support including native QR scanner, location sharing, file upload, and rating widgets.
7VoiceTelephony / 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.
MEIPROUTINGRULE — Column Reference
ColumnExampleNotes
ROUTINGRULEID1PK
TENANTID42Tenant isolation
FLOWDEFINITIONID5FK → MEIPFLOWDEFINITION — which flow to start
ENDPOINTID1FK → MEIPCHANNELENDPOINT — which channel this rule applies to
MATCHTYPE21=EQUALS, 2=STARTS_WITH, 3=CONTAINS, 4=REGEX
MATCHVALUEleaveThe pattern to match against the user's message (lowercased)
PRIORITY10Lower number = checked first. Recommended: 10, 20, 30 (leave gaps for future rules)
STATUS11 = active

Example Routing Table

PriorityMATCHTYPEMATCHVALUEFlowUse Case
10EQUALS (1)helpHELP_MENUUser types exactly "help"
20STARTS_WITH (2)leaveLEAVE_REQUEST"leave", "leave request", "leave 2024"
30STARTS_WITH (2)orderORDER_STATUS"order 1234", "order status"
40CONTAINS (3)balanceLEAVE_BALANCE"check my balance", "current leave balance"
90REGEX (4)^PO-\d+$PO_QUERY"PO-5001", "PO-12345"
999CONTAINS (3).GENERAL_HELPCatch-all for any message (always last)
Always add a catch-all rule at the end
Without a catch-all, unrecognized messages will cause an error. Add a CONTAINS rule with pattern "." (matches any text) at the highest priority number so it only fires when nothing else matches.

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

StepTypeWhen to useRequired 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."
Dot-notation for nested JSON variables

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

  1. Purchaser raises a PO → PurchaseOrderBLL.SavePO() publishes an ActionEventDto
  2. EmailActionHandler generates 3 signed links (Approve / Reject / Return) and sends the email
  3. Approver sees email, clicks "Reject", enters reason in the remarks form
  4. System calls POST /PurchaseOrder/RejectPO with the PO ID and reason
  5. 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

Approve clicked → "✓ Action Successful: Action 'PO_APPROVE' for item #5001 processed." Reject clicked → Shows form: ┌──────────────────────────────────────────┐ │ Reject Purchase Order │ │ │ │ Remarks │ │ ┌────────────────────────────────────┐ │ │ │ Over budget for Q3. Please resubm │ │ │ │it in Q4. │ │ │ └────────────────────────────────────┘ │ │ [Submit] │ └──────────────────────────────────────────┘ Submit clicked → "✓ Action Submitted: Your response has been recorded successfully." API receives: {"PurchaseOrderId":5001,"RejectionReason":"Over budget for Q3..."}

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

User: "leave request" Bot: "Hi! I'll help you apply for leave. Please type the start date (YYYY-MM-DD):" User: "2024-08-01" Bot: "Enter your leave end date:" User: "2024-08-05" Bot: "What type of leave? 1. Annual Leave 2. Sick Leave 3. Unpaid Leave" User: "1" Bot: "Submitting leave from 2024-08-01 to 2024-08-05..." [calls POST /Leave/SaveLeave internally] Bot: "✅ Leave submitted successfully! Reference: LV-2024-0892 Your manager will be notified."

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.

QUALIFY — Configuration Fields
FieldRequiredDescription
BoundToTypeYesThe entity type name this qualifier applies to (must match a row in MQUALIFIERDEFINITION.BOUNDTOTYPE).
InputKeysYesArray of context variable names whose values are passed to the qualifier as input facts.
OutputFactKeyYesContext variable name where the qualifier's output fact JSON is stored after execution.
MapperCodeYesCode of the mapper that transforms the raw qualifier output into the context fact schema.
OnValidationErrorStepCodeYesStep to route to when the qualifier returns one or more Error-severity findings.
NextStepCodeYesStep 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.

ENRICH — Configuration Fields
FieldRequiredDescription
MapperCodeYesCode of the mapper to execute. Must exist in MMAPPER.
InputKeyYesContext variable name whose value is passed as input to the mapper.
OutputKeyYesContext variable name where the mapper output is stored.
NextStepCodeYesStep 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.

DYNAMIC_CHOICE — Configuration Fields
FieldRequiredDescription
SourceKeyYesContext variable name holding the JSON array of available options.
DataPathNoDot-notation path into the source JSON to reach the array (e.g., "Data.Items"). Omit if SourceKey already holds the array directly.
LabelFieldYesField name within each array element to use as the button label shown to the user.
ValueFieldYesField name within each array element whose value is stored on selection.
StoreSelectedKeyYesContext variable name where the selected item's ValueField is stored.
StoreItemKeyNoIf set, the entire selected array element (as JSON) is stored under this context variable name for downstream steps.
MaxChoicesNoMaximum 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.
MessageTemplateYesPrompt text displayed above the generated buttons.
NextStepCodeYesStep 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.

LOCATION — Configuration Fields
FieldRequiredDescription
ContextKeyYesContext variable name where the location is stored as a JSON object {"Lat":..., "Lng":..., "Address":"..."}.
MessageTemplateYesPrompt text sent to the user (e.g., "Please share your current location.").
NextStepCodeYesStep 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"
}

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.

LINK — Configuration Fields
FieldRequiredDescription
MessageTemplateYesText displayed above the buttons.
LinkButtonsYesArray of button objects, each with Title (button label) and Url (destination URL, tel: URI, or payload string).
ButtonTypeNoRendering hint: REPLY (chat button, default), URL (opens browser), CALL (tel: link), QR (renders URL as QR image).
NextStepCodeYesStep 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.

PRESENT — Configuration Fields
FieldRequiredDescription
ContextKeyYesContext variable name holding the JSON data to display.
MessageTemplateNoOptional header text shown above the rendered data.
ResponseFormat.TypeYesRendering 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.FieldsYesArray of field names from the JSON objects to include in the output. Order determines column/display order.
ResponseFormat.MaxRowsNoMaximum number of rows/cards to render (default: 10). Data beyond this limit is silently truncated.
ResponseFormat.TitleFieldNoField name used as the card title when Type = CARD.
NextStepCodeYesStep 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"
}
CARD format for rich detail views
Use 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.

InputTypeBehaviourStored 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

ChannelRendering
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:

VariableTypeContent
_Location.LatdecimalLatitude (e.g., 25.2048)
_Location.LngdecimalLongitude (e.g., 55.2708)
_Location.AddressstringReverse-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).

Reverse geocoding requires a configured provider
Address lookup uses the geocoding provider configured in 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:

FieldTypeDescription
TtsTextstringPlain-text version of the message for basic TTS engines that do not support SSML.
SsmlTextstringFull SSML markup from VoiceHint. Null when VoiceHint is not set.
IsCompletedbooltrue when the step is an END step and the call should be terminated by the telephony platform.

Platform Integration Notes

PlatformWebhook ConfigurationNotes
TwilioSet "A Call Comes In" webhook to POST /MessageHubGenerator/RequestMessageHubGeneratorReturn TwiML <Say> or <Play> verbs built from SsmlText/TtsText in the adapter layer.
Azure Bot ServiceConfigure Direct Line Speech channel; point Bot endpoint to the EIP voice adapterAdapter maps Activity type message to the EIP request and returns Activity with speak property set from SsmlText.
Generic SIPUse a media server (e.g., FreeSWITCH, Asterisk) to POST ASR transcripts to the EIP endpointResponse TtsText must be fed back to the TTS synthesizer and played to the caller. Set IsCompleted=true to hangup.
Voice does not support rich input types
INPUT steps with 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:

Security Notes for Administrators

Token Security

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

SymptomLikely CauseResolution
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