Portal Community

Discord Interaction Flow

Discord supports two delivery modes for interaction events:

ModeHow it worksBest 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 FieldValue
BotToken@{secret:DiscordBotToken}
ApplicationPublicKey@{secret:DiscordPublicKey}
ChannelId@{input:discordChannelId}
Discord for internal teams Discord is popular for engineering teams and communities. For internal approval workflows where your team uses Discord (e.g. DevOps, SRE, release approvals), Discord HIL is lightweight, has no messaging restrictions, and the embed format makes approval cards look clean.