Telegram Bot Inline Buttons: Check Permissions and Reject Stale Actions

10 min read

Validate callback ownership, current permissions, expiry and enquiry version before a CRM change. Includes a local Python test and integration checks.

Telegram Bot Inline Buttons: Check Permissions and Reject Stale Actions

Do not update a CRM enquiry just because callback_data contains a command. Use the value to look up a server-side action record. Before making a change, check the sender, their current permissions, the button's expiry, the enquiry version, and whether the action has already run. A button sent yesterday is not permanent authority to change today's record.

This design is for developers building their own Telegram Bot API bot to handle inbound enquiries. It is not a 24go.site feature setup. The scope is ordinary callback buttons on messages in private chats. Inline mode, games, and Web Apps need separate handlers. At this point, the server must already authenticate update delivery and validate the JSON structure. Checks inside a forged request do not authenticate its user.

What a button press actually supplies

In CallbackQuery, from identifies the sender. A message sent by the bot supplies message; a message sent through inline mode supplies inline_message_id. Do not assume every callback contains an ordinary, accessible message.

The documentation explicitly warns that the originating message may contain no buttons with the received data. Looking for a matching string in the current keyboard therefore does not replace a server-side check. A label such as “Confirm demo” is not authorization either.

InlineKeyboardButton limits callback_data to 1-64 bytes. In a production design, use a short, unpredictable identifier rather than a phone number, staff role, or full enquiry JSON. Check the UTF-8 byte length, not the character count. The identifier does not replace permission checks: even a known, valid key must work only in an authorized context.

It helps to distinguish three things when handling a callback: the callback data, the message context, and the server-side action record. The first helps locate an action, the second supports context checks, and the third defines exactly which operation may run. Neither the visible button label nor the presence of a similar button in the interface proves that the operation is still authorized.

Do not use callback_data to carry the entire business command. Apart from the length limit, doing so makes revoking permission harder and can expose data in logs or during mishandling. The server-side record should remain the source of truth. Even if an identifier is guessed or copied, all the other checks still apply.

Keep the action's meaning on the server

When issuing a button, store a record containing its key, internal enquiry ID, allowed operation, intended Telegram user ID, enquiry version, expiry, and execution state. Bind the record to the appropriate bot and, where needed, to the chat and message. The application chooses the lifetime; Telegram does not define the business expiry used here.

Resolve permissions from current server-side records, not from a role embedded in a button. If an employee loses access or the enquiry moves to another team, an old button must not restore that access. For a customer-facing bot, first establish a link between the Telegram user and the specific enquiry. A matching username or knowledge of an enquiry number does not establish that link.

CheckOn failure
Is the message context supported?Do not act; ask the user to open the current conversation
Does the server recognize the key?Do not reconstruct commands from an arbitrary string
Is the sender the action's intended owner?Leave the enquiry unchanged and disclose no enquiry details
Does the user still have access?Refuse, even if the button is still visible
Has the action expired?Offer a way to request a new button
Has the action already completed?Do not run it again; show the known result only if access is authorized
Are the enquiry version and state still suitable?Reload the record and request fresh confirmation
For plan or price selection, keep the offer and its version on the server. If the terms have changed, show the new terms and ask for confirmation rather than silently applying a different price. This design is not a complete payment protocol.

The order of checks matters too. First reject unsupported contexts and unknown keys, then check the sender and their current permissions. Do not reveal a detailed rejection reason to someone who is not allowed to access the enquiry: messages such as “that enquiry does not exist” or “someone else owns it” may disclose information. Keep a structured reason in internal logs, but use a neutral response for the user.

If the user is authorized but the action has expired or the enquiry version has changed, do not automatically apply the old command to the new version. Load the current state, create a new action, and show the user what they are confirming. This is particularly important for changes to ownership, status, amount, meeting consent, or offer terms.

Expiry and version checks solve different problems. Expiry limits how long a particular button can be used. The version check detects whether the object has changed since the action was created. A longer expiry does not solve stale data, and a new version does not make an old key safe to use indefinitely.

Acknowledgement is not a successful write

Telegram clients display a progress indicator until answerCallbackQuery is called. Answer rejected callbacks too. For a longer operation, a neutral “Checking your request” is appropriate. Say “Enquiry updated” only after the result is confirmed. A successful Telegram method response does not confirm a CRM write.

Perform the version check, action claim, and local change atomically in a transaction. Otherwise, two handlers can both observe used: false. The function below does not protect against that race.

A local transaction cannot cover a request to a remote CRM. Persist a durable operation and use that CRM's documented idempotency mechanism, or reconcile an ambiguous timeout. Do not mark an action as finally completed before its effect is confirmed. The separate guide to repeated webhooks and duplicate CRM leads covers this boundary. A second click and redelivery of one event are different causes that may need to resolve to the same business operation.

Define in advance what each server-side action state means: awaiting checks, claimed by a handler, sent to an external system, confirmed, or requiring reconciliation. The names and storage model depend on the application; the important point is that a timeout must not become a confirmed success automatically. If the external request has an ambiguous outcome, retry only with a safe idempotency key or after checking the result with the CRM.

Avoid holding a database lock during a long network request unless your design requires it. One possible approach is to check permissions and version, atomically claim the action, and store a durable task in a transaction; then process the task separately and record the confirmed result. This is a general outline, not a ready-made design for every database and CRM. Choose transaction and recovery boundaries for the storage system you use.

A callback repeated after a successful operation must not start a new change. If access is still authorized, you can return the known result. If access has been revoked, do not disclose that result merely because the same user clicked the same button before. Check access when returning a result, too.

Test the refusal rules locally

Save the code as callback_policy_test.py and run python callback_policy_test.py without -O, which disables assert. It requires Python 3 and no additional libraries. This is a sequential decision test with artificial inputs, not a bot, storage implementation, or concurrency test. DEMO_ONLY is a public test string, not an example of a production secret key.

import copy
import json


def decide(query, action, lead, now):
    # Preconditions: authenticated delivery and validated object shapes.
    message = query.get("message")
    if not message or message.get("chat", {}).get("type") != "private":
        return "unsupported_context"
    if not action or query.get("data") != action["token"]:
        return "unknown_action"
    sender = query["from"]["id"]
    if type(sender) is not int or sender != action["owner_id"]:
        return "wrong_user"
    if sender not in lead["allowed_users"]:
        return "permission_denied"
    if now >= action["expires_at"]:
        return "expired"
    if action["used"]:
        return "already_used"
    if lead["version"] != action["lead_version"]:
        return "stale_version"
    if lead["status"] != "new" or action["kind"] != "request_demo":
        return "invalid_transition"
    return "eligible"


query = {"from": {"id": 1001}, "data": "DEMO_ONLY",
         "message": {"chat": {"type": "private"}}}
action = {"token": "DEMO_ONLY", "owner_id": 1001, "expires_at": 200,
          "used": False, "lead_version": 3, "kind": "request_demo"}
lead = {"allowed_users": [1001], "version": 3, "status": "new"}
results = [decide(query, action, lead, 100)]
q = copy.deepcopy(query)
q["from"]["id"] = 2002
results.append(decide(q, action, lead, 100))
results.append(decide(query, action, {**lead, "allowed_users": []}, 100))
results.append(decide(query, action, lead, 200))
results.append(decide(query, {**action, "used": True}, lead, 100))
results.append(decide(query, action, {**lead, "version": 4}, 100))
results.append(decide(query, None, lead, 100))
results.append(decide({**query, "message": None}, action, lead, 100))
results.append(decide(query, action, {**lead, "status": "closed"}, 100))
assert results == ["eligible", "wrong_user", "permission_denied", "expired",
                   "already_used", "stale_version", "unknown_action",
                   "unsupported_context", "invalid_transition"]
print(json.dumps(results))

Actual output from the local run:

["eligible", "wrong_user", "permission_denied", "expired", "already_used", "stale_version", "unknown_action", "unsupported_context", "invalid_transition"]
eligible means only that the displayed checks passed. The function does not write a record, consume a key, or perform a transaction. now and expires_at are illustrative numbers on one time scale. In production, use server time and a consistent expiry format. Validate input object shapes and expected types before calling the function.

If an AssertionError occurs, investigate which branch changed. Do not delete the assertion just to make the test pass. If the policy changes, update the expected results deliberately.

The test demonstrates individual rejection cases, but it does not cover every combination of conditions and is not a substitute for application tests. For example, it does not check a binding to a particular message, chat ID, or bot, because those details depend on the chosen action model. Add such checks to your tests if they are part of that model. Also test malformed types and missing fields at the handler boundary: the function explicitly assumes that input shapes have already been validated.

Before connecting the CRM

  1. In a private test chat, press a valid button as its intended user. Only the allowed operation on the correct enquiry should be created.
  2. Revoke the user's access, then press the old button. The enquiry must remain unchanged, and restricted details must not be disclosed.
  3. Change the enquiry version or close it before the click. An old confirmation must not overwrite the newer state.
  4. Test an expired action, an unknown key, and an unsupported context. Expect a refusal and an appropriate callback answer, not an unhandled exception.
  5. Send two concurrent requests and repeat a completed action. Verify the database's atomic action claim and confirm there is no second business effect.
  6. Simulate loss of the CRM response. Do not declare success or failure based on the timeout alone; recover the result through the durable operation.
Also test that two actions created for different versions of one enquiry cannot overwrite a newer state in sequence. Check that a repeated click on a used button returns an appropriate response without creating a second external request. For a concurrency test, use genuinely parallel handlers and the storage system that will run in the application: a sequential test of decide does not cover this.

Test the interface failure path as well. If the message containing the button is inaccessible, do not treat that as proof that the action succeeded or was cancelled. The server holds the business state; removing or updating a keyboard does not replace checking it. If the user can request a new confirmation, that confirmation must reflect the current terms and enquiry version.

These integration tests were not performed here. Decision reasons, internal operation IDs, and checked versions are usually sufficient for technical logs. Do not copy complete callbacks, personal data, or keys without a need. Removing the keyboard improves the interface but does not replace server-side rejection of stale actions. The system is ready when another user's or a stale click cannot change the enquiry, and a confirmed action cannot create a second effect.