Email HIL Channel
Email is the most universally accessible HIL channel. No app install, no bot setup.
The pattern is simple: send an HTML email with signed action buttons.
Each button links to a BizFirst resume endpoint with the ExecutionResId
encoded in a signed token — no external webhook required.
How It Works
Unlike messaging channels, email HIL doesn't use a platform interaction webhook.
Instead, the action buttons in the email are standard hyperlinks pointing to a
BizFirst-hosted endpoint. When the recipient clicks a button, their browser makes
a GET request to that endpoint, which validates the token and calls ContinueAsync.
Advantages
- No platform account or API registration needed
- Works for anyone with an email address
- No 24-hour messaging window
- Deliverable via any SMTP provider
Limitations
- No inline text inputs — editable fields require a separate web form
- Email clients may block images; use plain CSS styling
- Action links can be forwarded and clicked by anyone — tokens must be single-use
Signed Token Design
The token in each action URL must be:
- Signed — so it can't be forged or tampered with.
- Short-lived — set an expiry matching the approval SLA.
- Single-use — mark it as consumed after first use to prevent replay.
- Action-scoped — encode the action (approve/reject) in the token payload so a single URL can't be used for a different action.
// Token payload (sign with HMAC-SHA256 using a per-tenant secret)
{
"resId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"action": "approve", // "approve" | "reject"
"expiresAt": 1748000000, // Unix timestamp
"nonce": "x8k2pQ" // random 6-char nonce for single-use tracking
}
// URL format
https://your-domain/hil/action?t={base64url(payload)}.{hmac}
Building the Email
private string BuildHilEmail(
IReadOnlyList<ResolvedHilField> fields,
string approveUrl,
string rejectUrl)
{
var rows = new StringBuilder();
foreach (var field in fields)
{
if (field.DisplayMode == HilDisplayMode.Concealed) continue;
string val = field.DisplayMode == HilDisplayMode.ReadableMasked
? "••••••••"
: HtmlEncode(field.CurrentValue?.ToString() ?? "—");
string editNote = field.InputMode != HilInputMode.Locked
? "<span style='color:#f59e0b'> (editable via form)</span>"
: "";
rows.Append($@"
<tr>
<td style='padding:8px 16px;font-weight:600;color:#94a3b8;white-space:nowrap'>
{HtmlEncode(field.Label)}
</td>
<td style='padding:8px 16px;color:#e2e8f0'>
{val}{editNote}
</td>
</tr>");
}
return $@"
<!DOCTYPE html>
<html>
<body style='font-family:Segoe UI,sans-serif;background:#0f1117;color:#e2e8f0;padding:32px'>
<div style='max-width:600px;margin:0 auto;background:#1a1d27;border-radius:12px;overflow:hidden'>
<div style='background:#6c8cff;padding:20px 28px'>
<h2 style='margin:0;color:#fff'>Action Required — Approval</h2>
</div>
<div style='padding:24px 28px'>
<p style='color:#94a3b8'>Please review the following and take action:</p>
<table style='width:100%;border-collapse:collapse;margin:16px 0'>
{rows}
</table>
<div style='margin-top:24px;display:flex;gap:12px'>
<a href='{approveUrl}'
style='background:#34d399;color:#000;padding:12px 28px;border-radius:6px;
text-decoration:none;font-weight:700;display:inline-block'>
Approve ✅
</a>
<a href='{rejectUrl}'
style='background:#f87171;color:#000;padding:12px 28px;border-radius:6px;
text-decoration:none;font-weight:700;display:inline-block;margin-left:12px'>
Reject ❌
</a>
</div>
<p style='color:#4a5568;font-size:12px;margin-top:20px'>
This link expires in 72 hours. Do not forward this email.
</p>
</div>
</div>
</body>
</html>";
}
Resume Endpoint
[HttpGet("hil/action")]
public async Task<IActionResult> HandleEmailAction([FromQuery] string t)
{
// 1. Parse and verify the signed token
if (!_tokenVerifier.TryVerify(t, out var tokenData))
return BadRequest("Invalid or expired token.");
// 2. Check single-use nonce (prevent replay)
if (await _nonceStore.IsConsumedAsync(tokenData.Nonce))
return BadRequest("This action has already been recorded.");
await _nonceStore.ConsumeAsync(tokenData.Nonce);
// 3. Build response data
var responseData = new Dictionary<string, object>
{
["approved"] = tokenData.Action == "approve",
["actionedAt"] = DateTime.UtcNow
};
// 4. Resume the workflow
await _continuationOrchestrator.ContinueAsync(tokenData.ResId, responseData);
// 5. Show a confirmation page (not a raw 200 — the human's browser is here)
return View("HilConfirmation", new { Approved = tokenData.Action == "approve" });
}
Editable Fields — Web Form Pattern
For fields with RequiredFromHuman or EditableOptional,
add a third "Edit & Submit" button in the email that links to a hosted web form.
The form URL contains a signed token (different from the action token — this one
doesn't commit an action immediately). The form pre-populates editable fields and
submits to a POST endpoint that calls ContinueAsync.
Required Configuration
| Config Field | Value |
|---|---|
SmtpHost | @{env:SMTP_HOST} |
SmtpPort | @{env:SMTP_PORT} |
SmtpUser | @{secret:SmtpUser} |
SmtpPassword | @{secret:SmtpPassword} |
FromAddress | @{env:HIL_EMAIL_FROM} |
RecipientEmail | @{input:approverEmail} |
TokenSigningKey | @{secret:HilEmailTokenKey} |
TokenExpiryHours | 72 (or configured per workflow) |
ContinueAsync.