Discord HIL Channel
Discord's Message Components API provides button interactions with a
custom_id field (up to 100 characters) that carries the
ExecutionResId directly. Discord interaction webhooks receive
all component interactions — no polling required.
Discord Interaction Flow
Discord supports two delivery modes for interaction events:
| Mode | How it works | Best for |
|---|---|---|
| Interactions Endpoint URL | Discord POSTs every interaction to a registered HTTPS endpoint — same as other channels' webhooks | BizFirst node executors (server-side integration) |
| Gateway (WebSocket) | A bot process connects to Discord's Gateway and receives events over a persistent WebSocket | Long-running Discord bots — more infrastructure, not suitable for node executors |
Use the Interactions Endpoint URL approach. It's a stateless HTTP endpoint — exactly the same pattern as every other channel in this guide.
Correlation Strategy: custom_id Direct Embed
Discord button custom_id supports up to 100 characters.
A prefixed GUID uses 38 characters — fits comfortably.
// custom_id format: "{action}|{resId}"
"custom_id": "approve|3fa85f64-5717-4562-b3fc-2c963f66afa6"
"custom_id": "reject|3fa85f64-5717-4562-b3fc-2c963f66afa6"
Building the Message with Components
Discord messages use an embeds array for rich field display and a
components array for interactive buttons.
private object BuildDiscordMessage(
IReadOnlyList<ResolvedHilField> fields,
string resId)
{
var embedFields = new List<object>();
foreach (var field in fields)
{
if (field.DisplayMode == HilDisplayMode.Concealed) continue;
string val = field.DisplayMode == HilDisplayMode.ReadableMasked
? "••••••••"
: field.CurrentValue?.ToString() ?? "—";
string editNote = field.InputMode != HilInputMode.Locked ? " ✏️" : "";
embedFields.Add(new {
name = $"{field.Label}{editNote}",
value = val,
inline = true
});
}
return new {
embeds = new[] {
new {
title = "Approval Required",
color = 0x6c8cff, // accent blue
fields = embedFields,
footer = new { text = "BizFirst Workflow Engine" }
}
},
components = new[] {
new {
type = 1, // ActionRow
components = new object[] {
new {
type = 2, // Button
label = "Approve",
style = 3, // Success (green)
custom_id = $"approve|{resId}",
emoji = new { name = "✅" }
},
new {
type = 2,
label = "Reject",
style = 4, // Danger (red)
custom_id = $"reject|{resId}",
emoji = new { name = "❌" }
}
}
}
}
};
}
Sending the Message
// POST to https://discord.com/api/v10/channels/{channelId}/messages
await _httpClient.PostAsJsonAsync(
$"https://discord.com/api/v10/channels/{settings.ChannelId}/messages",
message,
cancellationToken);
// Set Authorization: Bot {botToken} header
return NodeExecutionResult.Suspend("waiting");
Webhook Handler
Discord sends a ping verification (type 1) when you first register the endpoint — you must respond correctly or the endpoint won't be accepted. Then button interactions arrive as type 3 (MESSAGE_COMPONENT).
[HttpPost("webhooks/discord")]
public async Task<IActionResult> Handle([FromBody] DiscordInteraction interaction)
{
// 1. Verify the Ed25519 signature (Discord uses Ed25519, not HMAC-SHA256)
if (!_discordSignatureVerifier.Verify(Request, interaction))
return Unauthorized();
// 2. Handle ping
if (interaction.Type == 1)
return Ok(new { type = 1 }); // PONG
// 3. Handle button click (type 3 = MESSAGE_COMPONENT)
if (interaction.Type == 3)
{
var customId = interaction.Data.CustomId; // "approve|{resId}"
var parts = customId.Split('|', 2);
var action = parts[0];
var resId = parts[1];
var responseData = new Dictionary<string, object>
{
["approved"] = action == "approve",
["respondedBy"] = interaction.Member?.User?.Username
?? interaction.User?.Username
};
// 4. Acknowledge interaction immediately (Discord requires response within 3 seconds)
// Type 4 = CHANNEL_MESSAGE_WITH_SOURCE (sends a reply visible to the user)
// Type 6 = DEFERRED_UPDATE_MESSAGE (acknowledges silently, updates original later)
_ = Task.Run(() => _continuationOrchestrator.ContinueAsync(resId, responseData));
return Ok(new {
type = 4,
data = new { content = "Response recorded. Workflow will continue.", flags = 64 } // 64 = ephemeral
});
}
return Ok();
}
Signature Verification — Ed25519
Discord uses Ed25519 asymmetric signatures, not HMAC. The verification differs from other channels:
// Verify using NSec or BouncyCastle for Ed25519
// Required headers: X-Signature-Ed25519, X-Signature-Timestamp
// Message to verify: timestamp + rawBody (concatenated as bytes)
bool isValid = Ed25519.Verify(
publicKey: HexToBytes(settings.PublicKey), // Discord application public key
message: Encoding.UTF8.GetBytes(timestamp + rawBody),
signature: HexToBytes(signatureHeader));
Required Configuration
| Config Field | Value |
|---|---|
BotToken | @{secret:DiscordBotToken} |
ApplicationPublicKey | @{secret:DiscordPublicKey} |
ChannelId | @{input:discordChannelId} |