A user presses an inline button in your Telegram bot.
The callback reaches your webhook. Your server returns HTTP 200. The business action may even complete successfully.
But the button keeps showing a loading indicator.
The missing step is usually:
answerCallbackQuery
Telegram treats these as three separate outcomes:
| Outcome | How it is confirmed |
|---|---|
| Telegram delivered the update | Your webhook returns a successful HTTP response |
| Your bot acknowledged the button press | Your bot calls answerCallbackQuery
|
| The requested business operation completed | Your application commits and records the result |
Returning HTTP 200 acknowledges webhook delivery. It does not answer the callback query.
Callback buttons are not text messages
Consider this inline keyboard:
{
"inline_keyboard": [
[
{
"text": "Approve",
"callback_data": "approval:approve:request_42"
},
{
"text": "Reject",
"callback_data": "approval:reject:request_42"
}
]
]
}
When the user presses one of these buttons, Telegram does not send an ordinary text message.
It sends an update containing callback_query:
{
"update_id": 710000001,
"callback_query": {
"id": "4382bfdwdsb323b2d9",
"from": {
"id": 123456789,
"is_bot": false,
"first_name": "Jordan"
},
"message": {
"message_id": 815,
"chat": {
"id": -1001234567890,
"type": "supergroup"
},
"text": "Approve request #42?"
},
"data": "approval:approve:request_42"
}
}
A handler that only checks update.message will ignore this interaction:
if (!update.message) {
return;
}
Instead, dispatch callbacks explicitly:
if (update.callback_query) {
await handleCallbackQuery(update.callback_query);
return;
}
if (update.message) {
await handleMessage(update.message);
}
Use the correct identifier
The callback answer requires:
callback_query_id
Its value comes from:
update.callback_query.id
Do not substitute:
-
update_id; -
message_id; -
chat.id; -
inline_message_id; -
callback_query.data.
The correct mapping is:
| Incoming field | Purpose |
|---|---|
callback_query.id |
Pass as callback_query_id
|
callback_query.data |
Application input associated with the button |
callback_query.from |
User who pressed the button |
callback_query.message |
Message context, when present |
callback_query.inline_message_id |
Inline-mode message context, when present |
A wrong ID can leave the client waiting even though your webhook completed successfully.
Call answerCallbackQuery
The Bot API request looks like this:
curl -X POST \
"https://api.telegram.org/bot$BOT_TOKEN/answerCallbackQuery" \
-H "Content-Type: application/json" \
-d '{
"callback_query_id": "4382bfdwdsb323b2d9"
}'
The text field is optional.
You do not need to show a notification merely to clear the progress indicator.
A small JavaScript helper can make the request:
async function answerCallbackQuery({
callbackQueryId,
text,
showAlert = false,
}) {
const payload = {
callback_query_id: callbackQueryId,
show_alert: showAlert,
};
if (text) {
payload.text = text;
}
const response = await fetch(
`https://api.telegram.org/bot${process.env.BOT_TOKEN}/answerCallbackQuery`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
},
);
const result = await response.json();
if (!response.ok || result.ok !== true) {
throw new Error(
`answerCallbackQuery failed: ${response.status} ${
result.description ?? "unknown error"
}`,
);
}
return result;
}
Keep BOT_TOKEN on the server. Never include it in browser code, source control, logs, screenshots, or support tickets.
Answer promptly
Do not wait for a slow business operation before answering the interaction.
This is fragile:
async function handleCallbackQuery(query) {
await callCrm(query.data);
await runAiClassification(query.data);
await updateDatabase(query.data);
await answerCallbackQuery({
callbackQueryId: query.id,
});
}
If the CRM or AI request is slow, the Telegram client continues displaying progress while the user waits.
Answer the interaction first, then perform the work:
async function handleCallbackQuery(query) {
await answerCallbackQuery({
callbackQueryId: query.id,
});
await enqueueCallbackAction({
callbackQueryId: query.id,
userId: query.from.id,
data: query.data,
messageId: query.message?.message_id ?? null,
chatId: query.message?.chat?.id ?? null,
inlineMessageId: query.inline_message_id ?? null,
});
}
This acknowledges the button press without pretending the underlying operation already succeeded.
Button feedback is not business success
Suppose the button approves a refund.
Calling answerCallbackQuery means:
The bot acknowledged the button press.
It does not mean:
The refund was approved and committed.
Keep interaction state and business state separate:
Callback received
↓
Callback answered
↓
Request validated
↓
Authorization checked
↓
Business transition committed
↓
Message updated with the final result
A user-facing flow might be:
- Clear the Telegram loading indicator immediately.
- Validate the callback payload.
- Confirm that the user is authorized.
- Perform the state transition idempotently.
- Edit the message or send a new message with the real outcome.
For example:
async function processApprovalAction(job) {
const action = parseCallbackData(job.data);
await assertAuthorized({
userId: job.userId,
action: action.type,
resourceId: action.resourceId,
});
const result = await applyActionIdempotently({
action: action.type,
resourceId: action.resourceId,
actorId: job.userId,
idempotencyKey: `telegram:${job.callbackQueryId}`,
});
await publishFinalResult({
chatId: job.chatId,
messageId: job.messageId,
result,
});
}
Do not display “Approved” merely because the callback was received.
Treat callback_data as untrusted input
A callback came from Telegram, but its contents should still be validated against current server-side state.
Do not write logic like this:
const [action, resourceId] = query.data.split(":");
await database.update(resourceId, { status: action });
Validate the format and allowlist the action:
function parseCallbackData(data) {
if (typeof data !== "string") {
throw new Error("Missing callback data");
}
const [namespace, action, resourceId] = data.split(":");
if (namespace !== "approval") {
throw new Error("Unsupported callback namespace");
}
if (!["approve", "reject"].includes(action)) {
throw new Error("Unsupported callback action");
}
if (!/^request_[a-zA-Z0-9]+$/.test(resourceId)) {
throw new Error("Invalid resource identifier");
}
return {
type: action,
resourceId,
};
}
Then check:
- whether the user may perform the action;
- whether the object still exists;
- whether it is still in an actionable state;
- whether the action was already completed;
- whether the button belongs to the expected bot workflow.
The originating message may be old, edited, forwarded, or no longer representative of the current server-side state.
Do not require callback_query.message
A callback query does not always have ordinary message context.
Depending on how the inline button was created, the callback may provide:
callback_query.message
or:
callback_query.inline_message_id
This code can crash before answering the callback:
const chatId = query.message.chat.id;
const messageId = query.message.message_id;
await answerCallbackQuery({
callbackQueryId: query.id,
});
Use optional context:
const context = {
chatId: query.message?.chat?.id ?? null,
messageId: query.message?.message_id ?? null,
inlineMessageId: query.inline_message_id ?? null,
};
await answerCallbackQuery({
callbackQueryId: query.id,
});
Answering the callback only requires the callback query ID. Do not let missing message context block that acknowledgment.
Check allowed_updates
If the callback never reaches your handler, inspect the receiver configuration.
Telegram’s allowed_updates parameter must permit:
callback_query
For example, a webhook configuration may include:
{
"allowed_updates": [
"message",
"callback_query"
]
}
A configuration that only permits message can deliver ordinary messages while excluding callback queries.
This creates a misleading symptom:
Bot receives messages correctly
Inline buttons keep loading
No callback handler logs appear
The receiving transport may be healthy while the update filter excludes the interaction type.
Telegram documents that omitting allowed_updates can retain the previous setting. Do not assume leaving it out resets the receiver to every update type.
Review the effective configuration used by the active webhook or polling worker.
Separate missing delivery from missing handling
Use this diagnostic sequence.
Case 1: no callback update reaches the receiver
Check:
-
allowed_updates; - webhook delivery status;
- the currently configured webhook URL;
- whether the polling worker requests callback updates;
- whether the button actually uses
callback_data.
A URL button does not produce a callback query. It opens its configured URL.
Case 2: the callback arrives but the loading indicator remains
Check:
- whether
answerCallbackQuerywas called; - whether
callback_query.idwas used; - whether the Bot API response returned success;
- whether the handler crashed before the answer call;
- whether a slow dependency blocked the call.
Case 3: the loading indicator clears but nothing happens
Check:
- callback-data parsing;
- user authorization;
- current server-side state;
- queue processing;
- failed business transitions;
- final message updates.
Case 4: the action happens more than once
Check:
- duplicate update delivery;
- repeated user clicks;
- queue retries;
- missing business idempotency;
- multiple consumers processing the same job.
These are different failure boundaries and should not be fixed with the same retry.
Webhook HTTP 200 is not enough
This webhook handler is incomplete:
app.post("/telegram/webhook", async (req, res) => {
const update = req.body;
await saveUpdate(update);
res.status(200).end();
});
It tells Telegram:
The update was delivered successfully.
It does not tell the Telegram client:
The button interaction was acknowledged.
A more complete structure is:
app.post("/telegram/webhook", async (req, res) => {
const update = req.body;
const inserted = await saveUpdateIfAbsent(update);
res.status(200).end();
if (!inserted) {
return;
}
if (update.callback_query) {
await handleCallbackQuery(update.callback_query);
return;
}
if (update.message) {
await handleMessage(update.message);
}
});
In production, ensure errors after the HTTP response are captured by your worker or job system rather than becoming unobserved promise rejections.
Deduplicate delivery and business actions separately
Telegram update retries and repeated button presses are different duplication problems.
Delivery-level idempotency
Use update_id to prevent processing the same update twice:
async function saveUpdateIfAbsent(update) {
return database.telegramUpdates.insertIfAbsent({
updateId: update.update_id,
payload: update,
receivedAt: new Date(),
});
}
Interaction-level tracking
Record the callback query:
await database.callbackQueries.insertIfAbsent({
callbackQueryId: query.id,
userId: query.from.id,
data: query.data,
receivedAt: new Date(),
});
Business-level idempotency
Protect the irreversible operation independently:
const idempotencyKey =
`approval:${action.resourceId}:${action.type}`;
This prevents two different callback queries from approving the same request twice.
A unique update does not necessarily represent a unique business action.
Decide what feedback the user should see
answerCallbackQuery supports optional text.
An empty answer is appropriate when the message will immediately update:
await answerCallbackQuery({
callbackQueryId: query.id,
});
Short feedback can confirm receipt:
await answerCallbackQuery({
callbackQueryId: query.id,
text: "Processing…",
});
An alert may be appropriate for an important validation error:
await answerCallbackQuery({
callbackQueryId: query.id,
text: "You are not allowed to approve this request.",
showAlert: true,
});
Do not use callback notifications as the permanent record of a business result. Update the conversation with a durable final state.
For example:
Request approved by Jordan at 14:32 UTC
A production-oriented handler
The complete flow can be organized like this:
async function handleCallbackQuery(query) {
const callbackQueryId = query.id;
try {
await answerCallbackQuery({
callbackQueryId,
});
} catch (error) {
logger.error("callback_answer_failed", {
callbackQueryId,
error: error.message,
});
throw error;
}
let action;
try {
action = parseCallbackData(query.data);
} catch (error) {
logger.warn("invalid_callback_data", {
callbackQueryId,
userId: query.from.id,
});
return;
}
await enqueueCallbackAction({
callbackQueryId,
action,
userId: query.from.id,
chatId: query.message?.chat?.id ?? null,
messageId: query.message?.message_id ?? null,
inlineMessageId: query.inline_message_id ?? null,
});
}
This handler deliberately separates:
- Telegram interaction acknowledgment.
- Input parsing.
- Durable business processing.
Acceptance tests
Before release, test all of these cases:
- [ ] A valid callback clears the progress indicator.
- [ ] An empty callback answer works without notification text.
- [ ] Optional text displays when requested.
- [ ] A slow CRM or AI task does not delay the callback answer.
- [ ]
callback_query.idis passed ascallback_query_id. - [ ] A callback without
messagedoes not crash the handler. - [ ] Inline-message context is handled when present.
- [ ] Unknown callback data does not execute an action.
- [ ] Unauthorized users cannot perform protected actions.
- [ ] A stale button cannot overwrite current server state.
- [ ] Repeated update delivery does not duplicate processing.
- [ ] Repeated clicks do not repeat an irreversible operation.
- [ ] Final business success or failure is shown separately.
- [ ] Bot tokens are absent from logs.
Bot callbacks and account-level webhooks are different
Telegram inline keyboards and answerCallbackQuery belong to the official Telegram Bot API.
An account-level normalized webhook solves a different problem.
UnifyPort’s documented event contract includes events such as:
message.received
It does not document a Telegram callback_query event or an answerCallbackQuery operation.
Do not rename a normalized message event into a callback query, and do not assume that returning Telegram Bot API JSON from an unrelated webhook receiver will execute the bot method.
If your system needs both architectures, keep them separate:
Telegram Bot API
→ Inline keyboard callbacks
→ answerCallbackQuery
→ Bot-specific business logic
Connected account webhook
→ message.received
→ Multi-channel support intake
Share downstream services where appropriate, but preserve the different authentication, event and response contracts.
Takeaway
When a Telegram inline button keeps loading, trace one click through these boundaries:
Button uses callback_data
↓
callback_query reaches the receiver
↓
Handler reads callback_query.id
↓
Bot calls answerCallbackQuery
↓
Telegram returns success
↓
Business action runs separately
Remember:
HTTP 200 acknowledges webhook delivery.
answerCallbackQuery acknowledges the button press.
Your database confirms the business result.
Keeping those outcomes separate produces faster interactions, safer retries and much clearer debugging.
References
- Telegram Bot API
- Telegram webhook response body vs separate API request
- Telegram getWebhookInfo delivery diagnostics
- UnifyPort webhook event reference
- UnifyPort webhook delivery contract
This article was adapted from an original UnifyPort technical guide with AI-assisted editing.
Top comments (0)