SMeetBot API

SMeet Bot API

Receiving updates

SMeet keeps every update of a bot in a durable queue and hands it over until your program confirms it. This page is the contract your program relies on, in both delivery modes.

Choosing a mode#

A bot receives updates in exactly one mode at a time. The owner chooses it on the Connection screen of My bots; the program cannot change it with its token.

Long pollingWebhook
Who opens the connectionYour program calls getUpdatesSMeet sends an HTTPS POST to your address
What you needOutgoing HTTPS to messenger.scrile.comA public HTTPS address on port 443 or 8443
Where it fitsLocal development, servers behind NAT, any production serviceAn existing web service, hosting with incoming HTTPS, a load balancer
When an update arrivesThe waiting request returns at once, not at the end of its timeoutSMeet posts it as soon as the previous one is answered
ConfirmationThe next getUpdates call with offsetYour 2xx answer
When your program is downUpdates wait in the queue for up to 7 daysUpdates wait; SMeet retries by the rules below

Long polling is the default and a normal production choice: it gives the same guarantees as a webhook and needs no public address. What decides reliability is not the transport but whether your program stores an update before confirming it (Durable processing).

The update object#

JSON
{
  "update_id": "42",
  "event_id": "7d3f7a52-4f0e-4b64-9d2a-2c1f8e5b6a10",
  "type": "message",
  "date": "2026-09-26T10:00:00Z",
  "message": {
    "message_id": "7301",
    "chat": {"id": "550", "type": "private"},
    "from": {"id": "812", "display_name": "Anna", "username": "anna", "is_bot": false},
    "date": "2026-09-26T10:00:00Z",
    "text": "Where is my order?"
  }
}
  • update_id is the position in the bot's queue. It grows in the order the updates were written. Updates cancelled before delivery are never handed over, so the update_id values you receive can have gaps; a batch reports them in skipped.
  • event_id is the identity of the business event. It stays the same when the update is delivered again and when the owner replays it.
  • type names the one payload field that is present: message, message_edited, message_deleted, callback_query, chat_access_changed or file_status_changed (update types). Ignore types you do not know: later versions add new ones.
  • replay_of_update_id is present when the owner replayed a FAILED update (FAILED updates and replay).

Long polling#

The request#

HTTP
POST /bot-api/v1/getUpdates HTTP/1.1
Host: messenger.scrile.com
Authorization: Bearer sbt1_EXAMPLE_REPLACE_WITH_YOUR_TOKEN
Content-Type: application/json

{"consumer_id": "poller-host1", "epoch": "7", "offset": "43", "timeout": 25, "limit": 100}
JSON
{
  "ok": true,
  "result": {
    "updates": [{"update_id": "43", "event_id": "c2a1f5d0-0b7e-4a52-9f11-5a8c3e2d7b64", "type": "message", "date": "2026-09-26T10:00:04Z", "message": {"message_id": "7302", "chat": {"id": "550", "type": "private"}, "from": {"id": "812", "display_name": "Anna", "is_bot": false}, "date": "2026-09-26T10:00:04Z", "text": "Thanks"}}],
    "confirmed_offset": "43",
    "next_offset": "44",
    "skipped": [],
    "lease": {"consumer_id": "poller-host1", "epoch": "7", "expires_at": "2026-09-26T10:01:04Z"}
  }
}
  • timeout is 0 to 25 seconds (default 25). The request returns as soon as there is something to give, or after the timeout with an empty batch. Give your HTTP client a longer timeout than that; the examples use timeout plus 15 seconds.
  • limit is 1 to 100 updates (default 100).
  • An empty batch changes nothing: its next_offset equals confirmed_offset.
  • getUpdates may be called up to 5 times per second per bot.

One receiver: consumer_id, lease and epoch#

Only one program receives the updates of a bot at a time.

  • consumer_id is a stable name of your receiving process, 1 to 64 characters of A-Z a-z 0-9 . _ -, for example the host name.
  • The first call without epoch gets a lease with a new epoch. Later calls pass the same consumer_id and epoch and extend the lease: it lasts 60 seconds after each answer, and while a long poll waits, until the end of the wait plus 60 seconds.
  • A call gets 409 CONSUMER_CONFLICT when another consumer holds an active lease, when the same consumer_id comes without the current epoch, or when a long poll of this bot is still waiting, even with the right epoch. Every CONSUMER_CONFLICT carries retry_after: how long until the lease or the waiting poll ends, or 1 second when the lease was reset while your poll was waiting. Wait that long and call again with the same consumer_id, epoch and offset.
  • Once a lease has expired, the next call gets a new lease with a new epoch and continues from the confirmed position. If that call carries the epoch of the expired lease and nobody took the lease in between, its offset still confirms.
  • An epoch that is no longer the last one is foreign: another consumer took the lease since, the owner reset it, or the delivery mode was switched. A call with a foreign epoch gets a new lease when one is free, but it confirms nothing: its offset is ignored, and the updates it did not confirm come again. So a late copy of your program can never confirm what another copy received.
  • The owner can reset a stuck lease in My bots (Reset the connection). Switching the delivery mode and the emergency reset also take the lease away.

For failover, run a second copy with another consumer_id: it gets CONSUMER_CONFLICT, waits retry_after, and takes over when the first copy stops and its lease expires.

Offsets: confirming updates#

offset means "the next update_id I want". Passing it confirms every update below it, and confirmed updates are never returned again. Updates that were issued but not confirmed are issued again after a lost answer or a restart, so your program must tolerate repeats.

The server keeps two positions: confirmed_offset, the position after the confirmed prefix, and max_issued_update_id, the highest update_id it has ever returned. The rules:

  • offset equal to confirmed_offset is always accepted and changes nothing.
  • offset above confirmed_offset and up to max_issued_update_id + 1 confirms everything below it.
  • offset below confirmed_offset fails with 409 CURSOR_BEHIND. The error carries the current confirmed_offset, and nothing is sent again.
  • offset above max_issued_update_id + 1 fails with 400 OFFSET_NOT_ISSUED, unless it is exactly the next_offset the server returned for the current lease.
  • Without offset the server continues from the position it stores.

Say update 42 is the last one issued (max_issued_update_id is 42) and no new update arrives meanwhile:

You sendResult
"offset": "43"Confirms 42. confirmed_offset becomes 43. This is the normal step.
"offset": "43" again, for example after a lost answerAccepted, nothing changes. Repeating a confirmation is harmless.
"offset": "44"400 OFFSET_NOT_ISSUED: update 43 was never issued to you, so you cannot confirm past it.
"offset": "42", now that 43 is confirmed409 CURSOR_BEHIND with "confirmed_offset": "43". Nothing is sent again; continue from 43.
JSON
{
  "ok": false,
  "error": {"code": "CURSOR_BEHIND", "message": "offset is below the confirmed position", "confirmed_offset": "43"},
  "request_id": "0b4d2c1e-5a7f-4e3b-8c9d-6f1a2b3c4d5e"
}

You never compute update_id + 1 yourself: every batch comes with next_offset. Pass it back once the whole batch is stored (production) or handled (learning example).

Skipped updates and next_offset#

Updates can leave the queue before you confirm them. A batch lists them as ranges in skipped, and next_offset jumps over them:

JSON
{
  "updates": [],
  "skipped": [{"from_update_id": "43", "to_update_id": "49", "reason": "cancelled"}],
  "confirmed_offset": "43",
  "next_offset": "50",
  "lease": {"consumer_id": "poller-host1", "epoch": "7", "expires_at": "2026-09-26T10:01:00Z"}
}
reasonMeaning
cancelledAccess ended before delivery: the person pressed Stop, blocked the bot or left the space, deleted the message, the owner dropped the queue on a mode switch, or the organisation switched running off.
failedMoved to FAILED by rejectUpdate or by the webhook rules.
expiredThe 7-day retention window ran out. The batch then also carries "queue_gap": true.
skipped_by_ownerThe owner skipped a FAILED update.
deliveredAlready confirmed another way, for example by the webhook before the owner switched the bot back to long polling.

New reasons may appear. Never refuse a batch because of an unknown reason: log the range and pass next_offset on.

A jump past max_issued_update_id + 1 is valid only for the lease it was issued to. After a restart with a new lease, a saved jump may get OFFSET_NOT_ISSUED; call getUpdates without offset, and the server reports the skipped range again with a fresh next_offset.

After a restart#

Save offset and epoch together, in the same place and at the same moment, after each batch.

  • A quick restart, while the lease is still active: pass the saved consumer_id, epoch and offset, and you continue under the same lease. If the previous process died in the middle of a long poll, the server still counts that poll as waiting until its timeout ends; meanwhile you get CONSUMER_CONFLICT with retry_after. Wait and repeat.
  • A restart without the saved epoch while the lease is active gets CONSUMER_CONFLICT until the lease expires: 60 seconds after the server's last answer to the old process, where an unfinished long poll counts until its timeout. Wait retry_after and ask again.
  • After your lease expired, with nobody in between, the call with your saved epoch gets a new lease and a new epoch, and its offset still confirms. Updates you had not confirmed are issued again.
  • After someone else had the lease, the owner reset it or the mode was switched, your saved epoch is foreign: the call gets a new lease, its offset confirms nothing, and every update you had not confirmed comes again. Deduplicate them by event_id.
  • A saved jump from the old lease may get OFFSET_NOT_ISSUED: ask again without offset.
  • CURSOR_BEHIND after a restore from an old backup means the server confirmed more than your storage remembers: take confirmed_offset from the error and continue from there.

Keep sending the epoch you have, also after CONSUMER_CONFLICT: SMeet decides whether it still counts. The examples react to these errors in one place:

Pythondocs/bots/examples/python/smeet.py
def handle_poll_error(client: Client, error: ApiError, state: Dict[str, str]) -> bool:
    """React to the getUpdates errors that are part of normal operation.

    Returns False for errors the caller should raise (a wrong token, webhook mode and so on).
    429 and 503 never get here: Client.call already retried them with the same offset.
    """
    if error.code == "CONSUMER_CONFLICT":
        # Another consumer holds the lease, or a long poll of this bot is still waiting (our own,
        # right after a crash). Keep the epoch and the offset and ask again after retry_after:
        # with our own epoch the offset still confirms; if someone else had the lease in between,
        # it confirms nothing and the unconfirmed updates come again. Nothing is lost or skipped.
        wait = error.retry_after or 5
        log.warning("CONSUMER_CONFLICT: the lease is held by another getUpdates call; next try in %d s", wait)
        client.sleep(wait)
        return True
    if error.code == "CURSOR_BEHIND":
        # The server has already confirmed further than our saved offset. Nothing is sent again;
        # continue from the server's position.
        log.warning("CURSOR_BEHIND: offset %s is below the confirmed position %s, continuing from there",
                    state.get("offset"), error.details.get("confirmed_offset"))
        state["offset"] = error.details["confirmed_offset"]
        return True
    if error.code == "OFFSET_NOT_ISSUED":
        # The saved offset jumps further than this lease may confirm (for example a next_offset from
        # an older lease). Ask without offset: the server continues from its own position and reports
        # the skipped updates again.
        log.warning("OFFSET_NOT_ISSUED: offset %s is not valid for this lease, asking the server for its position",
                    state.get("offset"))
        state.pop("offset", None)
        return True
    return False

An update you cannot process: rejectUpdate#

When your program can never handle one particular update (for example a payload it cannot parse), move it aside with rejectUpdate instead of blocking the rest:

  • pass update_id, a reason of 1 to 256 characters that the owner will read (no secrets, no personal data), and the current consumer_id and epoch, with an Idempotency-Key;
  • only an update issued to the current lease and not yet confirmed can be rejected; otherwise 404 UPDATE_NOT_FOUND, and with a stale lease 409 CONSUMER_CONFLICT;
  • the update becomes FAILED and appears in the owner's FAILED list with your reason; its payload is kept until the original retention ends, so it can be replayed after a fix.
Pythondocs/bots/examples/python/production_poller.py
def accept(client: smeet.Client, update: dict, consumer_id: str, epoch: str) -> bool:
    """True if the update may be stored. Otherwise move it to FAILED so it does not block the queue."""
    problem = bot_logic.check_update(update)
    if problem is None:
        return True
    update_id = update.get("update_id")
    if not isinstance(update_id, str):
        log.error("An update without update_id was skipped: %s", problem)
        return False
    try:
        # One key per delivery position: a replay of the same event gets a new update_id, and
        # rejecting it again must not collide with the first rejection.
        key = smeet.action_key(str(update.get("event_id") or "no-event-id"), "reject", update_id)
        client.reject_update(update_id, f"Cannot process this update: {problem}", consumer_id, epoch,
                             idempotency_key=key)
        log.warning("Update %s moved to FAILED: %s", update_id, problem)
    except smeet.ApiError as error:
        if error.code != "UPDATE_NOT_FOUND":
            raise
        log.info("Update %s is no longer pending (%s), nothing to reject", update_id, error.code)
    return False

Durable processing#

A program that writes to a database, a CRM or a payment system should never confirm what it has not stored:

  1. Receive a batch with getUpdates; reject what can never be handled.
  2. Store the accepted updates and next_offset in one transaction, in a table with a unique event_id.
  3. Confirm by passing offset=next_offset in the next getUpdates call, only after that commit.
  4. Process from your own table, with Idempotency-Keys built from event_id.

A crash before step 3 makes SMeet issue the batch again, and the unique event_id turns the repeat into a no-op. This is the receiving loop of python/production_poller.py:

Pythondocs/bots/examples/python/production_poller.py
def receive(client: smeet.Client, inbox: Inbox, worker: Worker, consumer_id: str, stop: threading.Event) -> None:
    saved = inbox.settings()
    offset, epoch = saved.get("offset"), saved.get("epoch")
    log.info("Receiving as consumer %s from offset %s", consumer_id, offset or "(the server position)")
    while not stop.is_set():
        try:
            batch = client.get_updates(consumer_id, epoch=epoch, offset=offset)
            epoch = batch["lease"]["epoch"]
            accepted = [update for update in batch["updates"] if accept(client, update, consumer_id, epoch)]
            # Durable BEFORE confirming: the updates and the new position commit together.
            new = inbox.store(accepted, {"offset": batch["next_offset"], "epoch": epoch})
        except smeet.ApiError as error:
            if error.code == "CONSUMER_CONFLICT":
                wait = error.retry_after or 5
                log.warning("CONSUMER_CONFLICT: the lease is held by another getUpdates call; next try in %d s", wait)
                client.sleep(wait)
            elif error.code == "CURSOR_BEHIND":
                log.warning("CURSOR_BEHIND: offset %s is below the confirmed position %s; continuing from there",
                            offset, error.details.get("confirmed_offset"))
                offset = error.details["confirmed_offset"]
                inbox.store([], {"offset": offset})
            elif error.code == "OFFSET_NOT_ISSUED":
                log.warning("OFFSET_NOT_ISSUED: offset %s is not valid for this lease; asking the server", offset)
                offset = None
                inbox.store([], {"offset": None})
            elif error.code == "DELIVERY_MODE_CONFLICT":
                # The epoch stays: after a switch back to polling it is foreign, so the old offset
                # confirms nothing and delivery continues from the server's position.
                log.warning("The bot is in webhook mode (DELIVERY_MODE_CONFLICT); checking again in %d s",
                            MODE_CHECK_INTERVAL)
                client.sleep(MODE_CHECK_INTERVAL)
            else:
                raise
            continue
        if batch["updates"] or batch["skipped"]:
            log.info("Batch of %d update(s): %d new, %d rejected, %d skipped range(s), next offset %s",
                     len(batch["updates"]), new, len(batch["updates"]) - len(accepted), len(batch["skipped"]),
                     batch["next_offset"])
        smeet.log_skipped(batch)  # reasons are logged as they come; new ones may appear at any time
        if new:
            worker.notify()
        offset = batch["next_offset"]  # the next getUpdates call confirms this batch

The learning example python/echo_bot.py handles a batch before it confirms it. That is safe only because its one action, the reply, carries an Idempotency-Key.

Webhook#

Setting up#

On the Connection screen of My bots choose Webhook, enter the address and decide what happens to the updates already waiting (KEEP or DROP). SMeet then shows the webhook secret once; put it into your program's configuration, for example SMEET_WEBHOOK_SECRET. Saving the webhook again, even with the same address, creates a new secret and also resumes a paused delivery (A paused endpoint).

The address must be:

  • https://, on port 443 or 8443, up to 2048 characters, without a user name, a password or a #fragment;
  • a public host: not localhost, not *.localhost, *.local or *.internal, and a name that resolves only to public addresses. Loopback, private, link-local (cloud metadata included), carrier-grade NAT, multicast, documentation, benchmarking and reserved networks are refused, as are unique-local and site-local IPv6 and IPv6 forms that carry an IPv4 address.

A refused address gets WEBHOOK_URL_FORBIDDEN when you save it. SMeet checks the name again on every connection and connects only to its public addresses, so changing DNS later does not help to reach an internal network. If webhooks are switched off on the installation, saving fails with WEBHOOKS_UNAVAILABLE; long polling still works. For local development use long polling, or a public HTTPS tunnel.

The request#

SMeet sends one update per request, POST with the Update as a JSON body:

HeaderContent
X-SMeet-Signaturet=<unix time>,v1=<hex signature> (below)
X-SMeet-Event-IdThe update's event_id
X-SMeet-Update-IdThe update's update_id
X-SMeet-Delivery-Attempt1 for the first attempt; it grows with each attempt that counts against the update's budget
User-AgentSMeet-Bot-Webhook/1.0
  • One request at a time per bot, strictly in update_id order: the next update waits for your answer.
  • SMeet waits up to 5 seconds for the connection and up to 10 seconds for the whole request, answer included.
  • Redirects are not followed and cookies are not kept. SMeet reads at most 64 KiB of your answer and ignores its content: only the status matters.
  • The API token is never sent to the webhook.

Checking the signature#

v1 is the HMAC-SHA256 of the string <t>.<raw body>, keyed with the webhook secret, written as 64 lowercase hex characters.

  • Use the raw bytes of the body, before any JSON parsing: a re-serialised body has a different signature.
  • The key is the secret exactly as SMeet showed it, as UTF-8 bytes. Do not base64-decode it.
  • Compare in constant time, and refuse a t more than 5 minutes away from your clock. Every attempt is signed again with a fresh t.
  • Accept any matching v1 value: the header may carry more than one in the future.

Python, from python/webhook_bot.py:

Pythondocs/bots/examples/python/webhook_bot.py
def verify_signature(secret: bytes, header: Optional[str], body: bytes, now: float) -> bool:
    """Check X-SMeet-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">."""
    timestamp, signatures = None, []
    for item in (header or "").split(","):
        name, _, value = item.strip().partition("=")
        if name == "t":
            timestamp = value
        elif name == "v1":
            signatures.append(value)
    if timestamp is None or not re.fullmatch(r"[0-9]{1,12}", timestamp) or not signatures:
        return False
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False  # an old (or future) timestamp: possibly a replayed request
    expected = hmac.new(secret, timestamp.encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
    # Several v1 values may appear, for example while a secret is being rotated: accept any match.
    return any(HEX_64.match(value) and hmac.compare_digest(expected, value) for value in signatures)

Node.js, from node/webhook_bot.mjs:

JavaScriptdocs/bots/examples/node/webhook_bot.mjs
export function verifySignature(secret, header, body, nowSeconds) {
  let timestamp = null;
  const signatures = [];
  for (const item of (header ?? '').split(',')) {
    const [name, ...rest] = item.trim().split('=');
    if (name === 't') timestamp = rest.join('=');
    else if (name === 'v1') signatures.push(rest.join('='));
  }
  if (!timestamp || !/^[0-9]{1,12}$/.test(timestamp) || signatures.length === 0) return false;
  if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) return false; // possibly a replayed request
  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest();
  // Several v1 values may appear, for example while a secret is being rotated: accept any match.
  return signatures.some((value) => /^[0-9a-f]{64}$/.test(value) && timingSafeEqual(expected, Buffer.from(value, 'hex')));
}

Your answer#

Store the update first (unique by event_id), then answer. A 2xx confirms receipt, not business success, so run long work after answering.

Your answerWhat SMeet does
Any 2xxThe update is confirmed and the next one follows at once.
400, 413, 422This update is moved to FAILED with the reason HTTP_4XX, and the next update follows at once.
401, 403, 404, 410Delivery pauses until the owner resumes it (A paused endpoint). The update keeps its place and is not charged; nothing is moved to FAILED.
A redirect (3xx), a certificate that does not validate, an address that resolves only to forbidden networks, any other answer not listed hereThe same pause until the owner resumes delivery. Redirects are never followed.
429Delivery to your address waits Retry-After seconds (a number of seconds; 30 when the header is missing, at most 5 minutes). The update is not charged.
A DNS failure, a refused connection, a connection or TLS handshake that fails or times out before the request is sentNothing reached your program: delivery to your address waits and the update is not charged.
408, any 5xx, no answer within 10 seconds, a connection closed or broken after the request was sentCounts against the update's error budget.

The waits after a failed connection start at 5 seconds and double up to 5 minutes; they reset with the next 2xx. The queue keeps waiting meanwhile, and these failures do not count toward the circuit breaker.

To refuse one update your program can never handle, answer 400, 413 or 422: it is the webhook counterpart of rejectUpdate, and the owner sees the update in the FAILED list. Answer 401 or 403 when the signature does not match: SMeet then pauses delivery instead of throwing updates away, so a wrong secret costs time, not updates.

A paused endpoint#

Some answers cannot be fixed by trying again: a secret that does not match (401, 403), an address that is gone (404, 410), a redirect, a certificate that does not validate, a name that now resolves only into forbidden networks, or any other answer a webhook does not accept. SMeet then pauses delivery until the owner confirms the fix:

  • getWebhookInfo shows "state": "paused", and last_error.code names the cause: HTTP_401, HTTP_403, HTTP_404, HTTP_410, REDIRECT_NOT_FOLLOWED, TLS_CERTIFICATE_ERROR, ADDRESS_FORBIDDEN or HTTP_<status>. My bots shows the bot with a delivery error and says what to fix.
  • Nothing is sent while delivery is paused. The update that got the answer keeps its place and its budget, nothing is moved to FAILED, and new updates queue up behind it.
  • SMeet BotFather tells the owner (Notices to the owner).
  • After fixing the endpoint, the owner presses Resume delivery on the Connection screen of My bots, and the waiting update goes out at once. Saving the webhook settings again resumes delivery too, with a new secret.

The pause itself has no time limit, but retention does: updates still waiting after 7 days expire.

The error budget of an update#

When an attempt ends with 408, 5xx, no answer within 10 seconds or a connection that broke after the request was sent, your program may or may not have processed the update. SMeet therefore retries the same update, with the same update_id and event_id, within a budget:

  • up to 5 attempts or 10 minutes from the first failed attempt, whichever comes first;
  • pauses of 5 seconds, 20 seconds, 1 minute and 3 minutes between the attempts;
  • then the update becomes FAILED with the reason HTTP_5XX (the last answer was a 5xx) or DELIVERY_OUTCOME_UNKNOWN (a 408, a timeout or no answer), and the next update follows.

The budget is stored with the update, so a restart of SMeet does not reset it. Because a timed-out attempt may have been processed, a later replay of that update must be deduplicated by event_id.

Circuit breaker#

If three updates in a row use up their budget, without a 2xx in between, SMeet treats your endpoint as down and opens the circuit breaker:

  • no deliveries for 1 minute; after each further failure the pause grows to 5, 15, 30 and 60 minutes;
  • after a pause, one probe with the current update. A failed probe does not charge that update; a 2xx closes the breaker, confirms the update and resets the counters;
  • getWebhookInfo shows "state": "circuit_open" and next_attempt_date; the breaker survives restarts of SMeet;
  • once your endpoint works again, the owner does not have to wait: the Connection screen of My bots can ask for the probe at once, and a failed probe still charges nothing.

So an outage of your endpoint moves at most three updates to FAILED; the rest wait in the queue until the endpoint answers again or the retention window ends.

Notices to the owner#

When webhook delivery pauses, and when SMeet moves an update to FAILED after a 400, 413 or 422 or a used-up budget, SMeet BotFather writes to the owner:

  • in the owner's BotFather chat of the bot's space, in the language that chat uses;
  • with the cause in plain words and a Connection button that opens the bot's connection settings;
  • at most once an hour per bot and kind, so an outage is one message, not hundreds.

An owner who has never opened BotFather in that space gets no message; the bot's card in My bots shows the same state.

Switching modes: KEEP and DROP#

Only the owner switches the mode, on the Connection screen of My bots. The screen asks for the new mode, the webhook address when needed, and what to do with the updates still waiting:

  • KEEP: waiting updates are delivered in the new mode.
  • DROP: waiting updates are cancelled. A long polling program sees them as a skipped range with the reason cancelled.

In both cases FAILED updates stay in the FAILED list. The switch also:

  • takes the long polling lease away: a waiting getUpdates ends, the old epoch becomes foreign and confirms nothing, and in webhook mode getUpdates answers 409 DELIVERY_MODE_CONFLICT;
  • stops a webhook request that is in flight from confirming its update: that update may be delivered again in the new mode, so deduplicate by event_id;
  • on the way back to long polling, moves the position to the first update still waiting, so nothing the webhook already delivered is issued again;
  • creates a new webhook secret every time webhook mode is set, also when only the address changes, and resumes a paused delivery. The secret is shown once.

If the settings were changed on another device in the meantime, the switch is refused and the screen shows the current settings to decide again. The platform can switch a bot's webhook off; the owner can then switch to long polling but not back to a webhook until the platform allows it.

FAILED updates and replay#

An update becomes FAILED when your program rejects it with rejectUpdate, when your webhook answers 400, 413 or 422, or when it uses up its error budget. A FAILED update leaves the active sequence, so it no longer holds up the updates behind it. In webhook mode SMeet BotFather tells the owner (Notices to the owner).

The owner sees the FAILED list under Failed updates in My bots, with the type, the reason, the number of attempts and the date, selects updates and chooses Retry selected or Skip selected:

  • Retry: the update is appended at the end of the queue with a new update_id, the same event_id and replay_of_update_id set to the old position. Its original expiry date is kept. It is replayed only if the chat still allows it: not after Stop, Block or leaving the space, and not after it expired.
  • Skip: the update is removed for good. A long polling program sees it as a skipped range with the reason skipped_by_owner if it was still ahead of the position.

A replay arrives after newer updates. If the order of events matters for your business, decide what a late event means before you act on it. FAILED updates are cancelled without a replay when the organisation switches running off, when the bot is deleted, or when the source message is deleted. Replays are not possible while the bot is paused or the platform is in maintenance.

failed_update_count in getDeliveryInfo and the "delivery errors" number on the BotFather card show how many there are.

Deduplication by event_id#

  • A network repeat (a lost answer, a restart, a webhook retry) keeps both update_id and event_id.
  • An owner's replay gets a new update_id and keeps the event_id.

So move the position by next_offset, and deduplicate business actions by event_id:

  • build Idempotency-Keys of SMeet calls from event_id and the action, for example <event_id>:reply; SMeet keeps keys for 7 days;
  • protect your own side effects (a database row, a CRM record, a payment) with a unique event_id on your side, and keep processed ids for at least 7 days; the examples keep them for 8.

A file_status_changed update has an event_id of its own. When two different updates can lead to the same action, for example a message whose file was already checked and the later file_status_changed, key that action by the file_id instead (Photos and documents).

Retention and queue limits#

WhatHow long
An unconfirmed update7 days from the original event (retention_seconds 604800 in getDeliveryInfo). Pauses, maintenance and replays do not extend it. Then it is skipped with the reason expired.
A FAILED updateUntil the same 7 days end; then it expires and cannot be replayed.
The content of a confirmed update1 day; then only its identifiers remain.
Idempotency-Keys7 days.

A bot's queue holds up to 10,000 waiting updates or 50 MiB. When it is full, people who write to the bot see that it has too many unanswered messages, and their message is not sent; the queue itself loses nothing. Watch pending_update_count and oldest_pending_date in getDeliveryInfo (Operations).