Portal Community

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

ScopePurpose
chat:writePost messages to channels
chat:write.publicPost to channels the app hasn't joined
views:openOpen modals
users:readResolve user display names in audit logs
Signature verification is mandatory Slack sends a 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.