SMS / Twilio
SMS / Twilio HIL Channel
SMS is the lowest common denominator — no app, no account, just a phone number. The constraint is that SMS is plain text with no interactive elements. The HIL pattern relies on keyword replies with a session table to correlate the response to the suspended workflow.
How SMS HIL Works
Since SMS has no buttons or payloads, the correlation approach is different:
- Send a numbered context message listing the fields, followed by keyword instructions (
APPROVE/REJECT). - Store the active HIL session in a table keyed by the recipient's phone number. One active session per number at a time.
- When an inbound SMS arrives from that number, look up the session, read the keyword, and call
ContinueAsync.
One session per phone number
This approach assumes a person only has one active HIL at a time. If your workflows
can concurrently send multiple HIL messages to the same number, use a short numeric
code (e.g., "Reply APPROVE 4821" where 4821 is a session code) to disambiguate.
Session Table Design
// SmsHilSession table
// PhoneNumber varchar(20) — recipient phone, E.164 format
// ExecutionResId uniqueidentifier
// AllowedKeywords varchar(200) — e.g. "APPROVE,REJECT,YES,NO"
// ExpiresAt datetime
// TenantId uniqueidentifier
// CreatedAt datetime
// Index on PhoneNumber + TenantId for fast inbound lookup
Building the SMS
private string BuildSmsText(IReadOnlyList<ResolvedHilField> fields)
{
var lines = new List<string> { "BizFirst - Action Required:" };
int i = 1;
foreach (var field in fields)
{
if (field.DisplayMode == HilDisplayMode.Concealed) continue;
string val = field.DisplayMode == HilDisplayMode.ReadableMasked
? "****"
: field.CurrentValue?.ToString() ?? "—";
lines.Add($"{i++}. {field.Label}: {val}");
}
lines.Add("");
lines.Add("Reply APPROVE or REJECT.");
lines.Add("This request expires in 24hrs.");
return string.Join("\n", lines);
}
Sending the SMS and Creating the Session
// In OnProcess:
string text = BuildSmsText(hilFields);
string phone = settings.RecipientPhone;
// Store session BEFORE sending — avoids a race where reply arrives before session stored
await _smsSessionStore.CreateAsync(
phone: phone,
resId: resId,
keywords: ["APPROVE", "REJECT", "YES", "NO"],
expiresAt: DateTime.UtcNow.AddHours(24));
// Send via Twilio REST API
await _twilioClient.Messages.CreateAsync(
to: new PhoneNumber(phone),
from: new PhoneNumber(settings.TwilioNumber),
body: text);
return NodeExecutionResult.Suspend("waiting");
Webhook Handler
Twilio delivers inbound SMS as a form POST to your registered webhook URL. Always validate the Twilio request signature.
[HttpPost("webhooks/sms")]
public async Task<IActionResult> HandleSms([FromForm] TwilioSmsPayload payload)
{
// 1. Validate Twilio signature
var requestUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}";
var authToken = _settings.TwilioAuthToken;
if (!_twilioValidator.Validate(requestUrl, Request.Form, Request.Headers["X-Twilio-Signature"]))
return Forbid();
string fromPhone = payload.From; // E.164 phone number
string body = payload.Body?.Trim().ToUpperInvariant();
// 2. Look up active HIL session for this phone number
var session = await _smsSessionStore.FindActiveAsync(fromPhone);
if (session == null)
{
// No active HIL for this number — ignore or send a help reply
return TwimlResponse("No active approval request found for your number.");
}
// 3. Match keyword
bool approved;
if (body == "APPROVE" || body == "YES") approved = true;
else if (body == "REJECT" || body == "NO") approved = false;
else
return TwimlResponse("Reply APPROVE or REJECT to proceed.");
// 4. Consume the session (prevent double-processing)
await _smsSessionStore.ConsumeAsync(fromPhone);
// 5. Resume the workflow
var responseData = new Dictionary<string, object>
{
["approved"] = approved,
["respondedBy"] = fromPhone,
["respondedAt"] = DateTime.UtcNow
};
_ = Task.Run(() => _continuationOrchestrator.ContinueAsync(session.ExecutionResId, responseData));
// 6. TwiML response — sends a confirmation SMS back
return TwimlResponse(approved ? "Approved ✓ — workflow will continue." : "Rejected — workflow notified.");
}
private IActionResult TwimlResponse(string message)
{
var response = new MessagingResponse();
response.Message(message);
return Content(response.ToString(), "text/xml");
}
Numeric Code Disambiguation
For scenarios where a person may have multiple concurrent HIL requests, use a 4-digit numeric code included in the SMS text:
// Session text includes the code
"Reply 'APPROVE 4821' or 'REJECT 4821' to act on this request."
// Session table keyed by (PhoneNumber, Code) instead of just PhoneNumber
// Inbound parsing
var parts = body.Split(' ');
var keyword = parts[0]; // "APPROVE" or "REJECT"
var code = parts.ElementAtOrDefault(1) ?? "";
Required Configuration
| Config Field | Value |
|---|---|
AccountSid | @{secret:TwilioAccountSid} |
AuthToken | @{secret:TwilioAuthToken} |
TwilioNumber | @{env:TWILIO_FROM_NUMBER} |
RecipientPhone | @{input:phoneNumber} |
SessionExpiryHours | 24 (default) |
Short URL as an alternative
You can combine SMS with the email approach: include a short URL in the SMS that
links to a web confirmation page (like the email channel). This avoids keyword parsing
entirely and supports editable fields. Use a URL shortener that maps to your
signed-token endpoint. This is the best approach when the human needs to fill values.