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 polling | Webhook | |
|---|---|---|
| Who opens the connection | Your program calls getUpdates | SMeet sends an HTTPS POST to your address |
| What you need | Outgoing HTTPS to messenger.scrile.com | A public HTTPS address on port 443 or 8443 |
| Where it fits | Local development, servers behind NAT, any production service | An existing web service, hosting with incoming HTTPS, a load balancer |
| When an update arrives | The waiting request returns at once, not at the end of its timeout | SMeet posts it as soon as the previous one is answered |
| Confirmation | The next getUpdates call with offset | Your 2xx answer |
| When your program is down | Updates wait in the queue for up to 7 days | Updates 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#
{
"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_idis 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 theupdate_idvalues you receive can have gaps; a batch reports them inskipped.event_idis the identity of the business event. It stays the same when the update is delivered again and when the owner replays it.typenames the one payload field that is present:message,message_edited,message_deleted,callback_query,chat_access_changedorfile_status_changed(update types). Ignore types you do not know: later versions add new ones.replay_of_update_idis present when the owner replayed a FAILED update (FAILED updates and replay).
Long polling#
The request#
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}{
"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"}
}
}timeoutis 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 usetimeoutplus 15 seconds.limitis 1 to 100 updates (default 100).- An empty batch changes nothing: its
next_offsetequalsconfirmed_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_idis a stable name of your receiving process, 1 to 64 characters ofA-Z a-z 0-9 . _ -, for example the host name.- The first call without
epochgets a lease with a newepoch. Later calls pass the sameconsumer_idandepochand 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_CONFLICTwhen another consumer holds an active lease, when the sameconsumer_idcomes without the currentepoch, or when a long poll of this bot is still waiting, even with the right epoch. EveryCONSUMER_CONFLICTcarriesretry_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 sameconsumer_id,epochandoffset. - 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
offsetstill 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
offsetis 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:
offsetequal toconfirmed_offsetis always accepted and changes nothing.offsetaboveconfirmed_offsetand up tomax_issued_update_id + 1confirms everything below it.offsetbelowconfirmed_offsetfails with409 CURSOR_BEHIND. The error carries the currentconfirmed_offset, and nothing is sent again.offsetabovemax_issued_update_id + 1fails with400 OFFSET_NOT_ISSUED, unless it is exactly thenext_offsetthe server returned for the current lease.- Without
offsetthe 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 send | Result |
|---|---|
"offset": "43" | Confirms 42. confirmed_offset becomes 43. This is the normal step. |
"offset": "43" again, for example after a lost answer | Accepted, 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 confirmed | 409 CURSOR_BEHIND with "confirmed_offset": "43". Nothing is sent again; continue from 43. |
{
"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:
{
"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"}
}reason | Meaning |
|---|---|
cancelled | Access 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. |
failed | Moved to FAILED by rejectUpdate or by the webhook rules. |
expired | The 7-day retention window ran out. The batch then also carries "queue_gap": true. |
skipped_by_owner | The owner skipped a FAILED update. |
delivered | Already 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,epochandoffset, 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 getCONSUMER_CONFLICTwithretry_after. Wait and repeat. - A restart without the saved epoch while the lease is active gets
CONSUMER_CONFLICTuntil the lease expires: 60 seconds after the server's last answer to the old process, where an unfinished long poll counts until its timeout. Waitretry_afterand 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
offsetstill 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
offsetconfirms nothing, and every update you had not confirmed comes again. Deduplicate them byevent_id. - A saved jump from the old lease may get
OFFSET_NOT_ISSUED: ask again withoutoffset. CURSOR_BEHINDafter a restore from an old backup means the server confirmed more than your storage remembers: takeconfirmed_offsetfrom 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:
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 FalseAn 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, areasonof 1 to 256 characters that the owner will read (no secrets, no personal data), and the currentconsumer_idandepoch, with anIdempotency-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 lease409 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.
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 FalseDurable processing#
A program that writes to a database, a CRM or a payment system should never confirm what it has not stored:
- Receive a batch with getUpdates; reject what can never be handled.
- Store the accepted updates and
next_offsetin one transaction, in a table with a uniqueevent_id. - Confirm by passing
offset=next_offsetin the next getUpdates call, only after that commit. - 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:
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 batchThe 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,*.localor*.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:
| Header | Content |
|---|---|
X-SMeet-Signature | t=<unix time>,v1=<hex signature> (below) |
X-SMeet-Event-Id | The update's event_id |
X-SMeet-Update-Id | The update's update_id |
X-SMeet-Delivery-Attempt | 1 for the first attempt; it grows with each attempt that counts against the update's budget |
User-Agent | SMeet-Bot-Webhook/1.0 |
- One request at a time per bot, strictly in
update_idorder: 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
tmore than 5 minutes away from your clock. Every attempt is signed again with a fresht. - Accept any matching
v1value: the header may carry more than one in the future.
Python, from 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:
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 answer | What SMeet does |
|---|---|
Any 2xx | The update is confirmed and the next one follows at once. |
400, 413, 422 | This update is moved to FAILED with the reason HTTP_4XX, and the next update follows at once. |
401, 403, 404, 410 | Delivery 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 here | The same pause until the owner resumes delivery. Redirects are never followed. |
429 | Delivery 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 sent | Nothing 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 sent | Counts 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", andlast_error.codenames the cause:HTTP_401,HTTP_403,HTTP_404,HTTP_410,REDIRECT_NOT_FOLLOWED,TLS_CERTIFICATE_ERROR,ADDRESS_FORBIDDENorHTTP_<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 a5xx) orDELIVERY_OUTCOME_UNKNOWN(a408, 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
2xxcloses the breaker, confirms the update and resets the counters; - getWebhookInfo shows
"state": "circuit_open"andnext_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 sameevent_idandreplay_of_update_idset 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_ownerif 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_idandevent_id. - An owner's replay gets a new
update_idand keeps theevent_id.
So move the position by next_offset, and deduplicate business actions by event_id:
- build Idempotency-Keys of SMeet calls from
event_idand 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_idon 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#
| What | How long |
|---|---|
| An unconfirmed update | 7 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 update | Until the same 7 days end; then it expires and cannot be replayed. |
| The content of a confirmed update | 1 day; then only its identifiers remain. |
| Idempotency-Keys | 7 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).