Portal Community

How Telegram Inline Keyboards Work

When you send a message with an InlineKeyboardMarkup, Telegram renders buttons beneath the message. When a user taps a button, Telegram sends a callback_query update to your webhook. The callback_query.data field contains whatever string you put in callback_data on the button — returned verbatim, up to 64 bytes.

A GUID is 36 bytes. Adding a short prefix (e.g., A|) costs 2 bytes. A prefixed GUID is 38 bytes — comfortably within the 64-byte limit.

Building the Message

private object BuildTelegramMessage(
    IReadOnlyList<ResolvedHilField> fields,
    string resId,
    long chatId)
{
    var lines = new List<string> { "📋 *Approval Required*\n" };
    foreach (var field in fields)
    {
        if (field.DisplayMode == HilDisplayMode.Concealed) continue;
        string val = field.DisplayMode == HilDisplayMode.ReadableMasked
            ? "••••••••"
            : EscapeMarkdown(field.CurrentValue?.ToString() ?? "—");

        string editMark = field.InputMode != HilInputMode.Locked ? " ✏️" : "";
        lines.Add($"*{EscapeMarkdown(field.Label)}*{editMark}: {val}");

        if (!string.IsNullOrEmpty(field.Description))
            lines.Add($"_{EscapeMarkdown(field.Description)}_");
    }

    return new {
        chat_id      = chatId,
        text         = string.Join("\n", lines),
        parse_mode   = "MarkdownV2",
        reply_markup = new {
            inline_keyboard = new[] {
                new object[] {
                    new { text = "Approve ✅", callback_data = $"A|{resId}" },
                    new { text = "Reject ❌",  callback_data = $"R|{resId}" }
                }
            }
        }
    };
}

Sending the Message

// POST to https://api.telegram.org/bot{token}/sendMessage
await _httpClient.PostAsJsonAsync(
    $"https://api.telegram.org/bot{settings.BotToken}/sendMessage",
    message,
    cancellationToken);

return NodeExecutionResult.Suspend("waiting");

Webhook Handler

Telegram sends Update objects to your webhook. A button tap produces an update with a callback_query. After processing, always call answerCallbackQuery to dismiss the loading spinner on the button.

[HttpPost("webhooks/telegram/{secret}")]
public async Task<IActionResult> Handle(
    string secret,
    [FromBody] TelegramUpdate update)
{
    // 1. Validate the path secret matches your configured value
    if (secret != _settings.WebhookPathSecret)
        return Unauthorized();

    var query = update.CallbackQuery;
    if (query?.Data == null || query.Data.Length < 38) return Ok();

    // 2. Parse "A|{guid}" or "R|{guid}"
    var action = query.Data[0];   // 'A' or 'R'
    var resId  = query.Data[2..]; // substring after "A|" or "R|"

    // 3. Immediately answer the callback to dismiss the spinner
    _ = Task.Run(() => AnswerCallbackQueryAsync(query.Id));

    // 4. Build response and continue workflow
    var responseData = new Dictionary<string, object>
    {
        ["approved"]    = action == 'A',
        ["respondedBy"] = query.From.Username ?? query.From.Id.ToString()
    };

    _ = Task.Run(() => _continuationOrchestrator.ContinueAsync(resId, responseData));
    return Ok();
}

private async Task AnswerCallbackQueryAsync(string callbackQueryId)
{
    await _httpClient.PostAsJsonAsync(
        $"https://api.telegram.org/bot{_settings.BotToken}/answerCallbackQuery",
        new { callback_query_id = callbackQueryId, text = "Response recorded ✅" });
}

Securing the Webhook Endpoint

Telegram doesn't sign webhook payloads with an HMAC — instead, use a secret path segment in your webhook URL that only Telegram knows:

// Register the webhook with a secret in the URL path
POST https://api.telegram.org/bot{token}/setWebhook
{
  "url":          "https://your-domain/webhooks/telegram/{randomSecret}",
  "allowed_updates": ["callback_query", "message"]
}

Telegram also supports an official secret_token header parameter (added in Bot API 6.1) — use this if your infrastructure supports it, as it's cleaner than a path segment.

Handling Editable Fields

For RequiredFromHuman fields, Telegram supports a simple force-reply approach: send a message asking for the value with ForceReply enabled. Track the expected field in a session table keyed by chat_id. When the user replies, the inbound message handler reads the session and collects the value.

// Send a prompt for a required field
new {
    chat_id      = chatId,
    text         = $"Please enter a value for: *{field.Label}*",
    parse_mode   = "MarkdownV2",
    reply_markup = new { force_reply = true, selective = true }
}

Required Configuration

Config FieldValue
BotToken@{secret:TelegramBotToken}
WebhookPathSecret@{secret:TelegramWebhookSecret}
RecipientChatId@{input:telegramChatId}
Telegram is the easiest HIL channel to start with No 24-hour window. No template approval process. No page or business account required. Just create a bot via BotFather, get a token, register a webhook URL, and you're sending messages. Great for internal approval workflows within a team.