DEV Community

unifyport for UnifyPort

Posted on Originally published at unifyport.ai

Telegram Inline Button Keeps Loading? Fix answerCallbackQuery Correctly

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
Enter fullscreen mode Exit fullscreen mode

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"
      }
    ]
  ]
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

A handler that only checks update.message will ignore this interaction:

if (!update.message) {
  return;
}
Enter fullscreen mode Exit fullscreen mode

Instead, dispatch callbacks explicitly:

if (update.callback_query) {
  await handleCallbackQuery(update.callback_query);
  return;
}

if (update.message) {
  await handleMessage(update.message);
}
Enter fullscreen mode Exit fullscreen mode

Use the correct identifier

The callback answer requires:

callback_query_id
Enter fullscreen mode Exit fullscreen mode

Its value comes from:

update.callback_query.id
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

It does not mean:

The refund was approved and committed.
Enter fullscreen mode Exit fullscreen mode

Keep interaction state and business state separate:

Callback received
    ↓
Callback answered
    ↓
Request validated
    ↓
Authorization checked
    ↓
Business transition committed
    ↓
Message updated with the final result
Enter fullscreen mode Exit fullscreen mode

A user-facing flow might be:

  1. Clear the Telegram loading indicator immediately.
  2. Validate the callback payload.
  3. Confirm that the user is authorized.
  4. Perform the state transition idempotently.
  5. 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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or:

callback_query.inline_message_id
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example, a webhook configuration may include:

{
  "allowed_updates": [
    "message",
    "callback_query"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 answerCallbackQuery was called;
  • whether callback_query.id was 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();
});
Enter fullscreen mode Exit fullscreen mode

It tells Telegram:

The update was delivered successfully.
Enter fullscreen mode Exit fullscreen mode

It does not tell the Telegram client:

The button interaction was acknowledged.
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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(),
  });
}
Enter fullscreen mode Exit fullscreen mode

Interaction-level tracking

Record the callback query:

await database.callbackQueries.insertIfAbsent({
  callbackQueryId: query.id,
  userId: query.from.id,
  data: query.data,
  receivedAt: new Date(),
});
Enter fullscreen mode Exit fullscreen mode

Business-level idempotency

Protect the irreversible operation independently:

const idempotencyKey =
  `approval:${action.resourceId}:${action.type}`;
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

Short feedback can confirm receipt:

await answerCallbackQuery({
  callbackQueryId: query.id,
  text: "Processing…",
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

This handler deliberately separates:

  1. Telegram interaction acknowledgment.
  2. Input parsing.
  3. 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.id is passed as callback_query_id.
  • [ ] A callback without message does 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Remember:

HTTP 200 acknowledges webhook delivery.
answerCallbackQuery acknowledges the button press.
Your database confirms the business result.
Enter fullscreen mode Exit fullscreen mode

Keeping those outcomes separate produces faster interactions, safer retries and much clearer debugging.

References


This article was adapted from an original UnifyPort technical guide with AI-assisted editing.

Top comments (0)