Slack HIL Channel
Slack's Block Kit provides the richest interactive message experience of any messaging
platform. Buttons, overflow menus, input blocks, and modals all work as HIL
interaction surfaces. The ExecutionResId travels inside the action payload.
How Slack Interactions Work
When a Slack app sends a message containing interactive elements (buttons, select menus,
input blocks), Slack fires an HTTP POST to your registered Interactivity Request URL
whenever a user interacts with any of those elements. The payload includes everything the
user did, plus any data you embedded in the action_id, block_id,
or modal private_metadata.
Correlation Key Strategy: Direct Embed
Slack's action_id field on a block element accepts any string up to 255 characters.
A GUID (36 characters) fits comfortably. For simple approve/reject flows, embed the
ExecutionResId directly in the action_id of each action button.
// Button block action_id format: "hil_approve|{resId}"
// This lets you parse the action type and resId from one string
"action_id": "hil_approve|3fa85f64-5717-4562-b3fc-2c963f66afa6"
"action_id": "hil_reject|3fa85f64-5717-4562-b3fc-2c963f66afa6"
For flows where the human also fills in values (editable fields), use a modal
and embed the ExecutionResId in the modal's private_metadata field.
This field is returned verbatim in the view submission payload.
Building the Message
Map the resolved HIL fields to Block Kit blocks:
private object BuildSlackMessage(IReadOnlyList<ResolvedHilField> fields, string resId)
{
var blocks = new List<object>();
// Header
blocks.Add(new {
type = "header",
text = new { type = "plain_text", text = "Approval Required" }
});
foreach (var field in fields)
{
if (field.DisplayMode == HilDisplayMode.Concealed) continue;
string displayValue = field.DisplayMode == HilDisplayMode.ReadableMasked
? "••••••••"
: field.CurrentValue?.ToString() ?? "—";
if (field.InputMode == HilInputMode.Locked)
{
// Read-only context block
blocks.Add(new {
type = "section",
fields = new[] {
new { type = "mrkdwn", text = $"*{field.Label}*\n{displayValue}" }
}
});
}
else
{
// Editable — use an input block (in a modal only)
blocks.Add(new {
type = "input",
block_id = field.FieldKey,
label = new { type = "plain_text", text = field.Label },
element = new {
type = "plain_text_input",
action_id = field.FieldKey,
initial_value = displayValue,
multiline = false
},
optional = field.InputMode == HilInputMode.EditableOptional
});
}
}
// Action buttons
blocks.Add(new {
type = "actions",
elements = new object[] {
new {
type = "button",
text = new { type = "plain_text", text = "Approve" },
style = "primary",
action_id = $"hil_approve|{resId}"
},
new {
type = "button",
text = new { type = "plain_text", text = "Reject" },
style = "danger",
action_id = $"hil_reject|{resId}"
}
}
});
return new { blocks };
}
Sending the Message
// In OnProcess:
var message = BuildSlackMessage(hilFields, resId);
var channelId = settings.SlackChannelId; // from resolved config
await _slackClient.PostMessageAsync(channelId, message, cancellationToken);
return NodeExecutionResult.Suspend("waiting");
Webhook Handler
Slack sends interaction payloads as application/x-www-form-urlencoded
with a JSON string in the payload parameter. Always verify the
X-Slack-Signature header first.
[HttpPost("webhooks/slack/interactions")]
public async Task<IActionResult> HandleInteraction()
{
// 1. Read raw body for signature verification
var rawBody = await Request.ReadBodyAsStringAsync();
if (!_slackSignatureVerifier.Verify(rawBody, Request.Headers))
return Unauthorized();
// 2. Parse the payload
var payloadJson = HttpUtility.ParseQueryString(rawBody)["payload"];
var payload = JsonSerializer.Deserialize<SlackInteractionPayload>(payloadJson);
// 3. Extract resId from action_id
var actionId = payload.Actions[0].ActionId; // e.g. "hil_approve|{guid}"
var parts = actionId.Split('|', 2);
var action = parts[0]; // "hil_approve" or "hil_reject"
var resId = parts[1]; // the ExecutionResId
// 4. Build response data
var responseData = new Dictionary<string, object>
{
["approved"] = action == "hil_approve",
["actionBy"] = payload.User.Name
};
// 5. Acknowledge immediately (Slack requires response within 3 seconds)
_ = Task.Run(() => _continuationOrchestrator.ContinueAsync(resId, responseData));
return Ok();
}
Using a Modal for Editable Fields
When your HIL fields include editable inputs, use a Slack modal instead of a plain message.
Modals have a private_metadata string (up to 3000 chars) that is returned
on submission — ideal for carrying the ExecutionResId.
// Open modal via trigger_id (from button click payload)
var modal = new {
type = "modal",
callback_id = "hil_submit",
private_metadata = resId, // comes back on view_submission
title = new { type = "plain_text", text = "Review & Submit" },
submit = new { type = "plain_text", text = "Submit" },
blocks = editableBlocks
};
await _slackClient.OpenModalAsync(payload.TriggerId, modal);
// In the view_submission handler:
var resId = payload.View.PrivateMetadata;
var values = payload.View.State.Values; // field key → submitted value map
Required Slack App Scopes
| Scope | Purpose |
|---|---|
chat:write | Post messages to channels |
chat:write.public | Post to channels the app hasn't joined |
views:open | Open modals |
users:read | Resolve user display names in audit logs |
X-Slack-Signature HMAC-SHA256 header on all interaction payloads.
Compute: HMAC-SHA256(signingSecret, "v0:" + timestamp + ":" + rawBody).
Reject any request where the computed signature doesn't match.