SMeetBot API

SMeet Bot API

Photos and documents

Bots receive and send photos and documents. Every file is checked by an antivirus scanner before a bot may use it, in both directions, and waiting for that check never needs frequent polling.

When files are available#

File support is switched on per SMeet installation, together with the antivirus scanner. getMe tells your program: "can_send_files": true.

While it is switched off, uploadFile, getFile, downloads, sendPhoto and sendDocument answer 400 INVALID_REQUEST, and a message with files reaches the bot with "has_unsupported_content": true and without attachments.

Receiving a file#

Files a person sends are listed in the message's attachments:

JSON
{
  "update_id": "57",
  "event_id": "9a7c1e2b-3d4f-4a5b-8c6d-7e8f9a0b1c2d",
  "type": "message",
  "date": "2026-09-26T10:00:00Z",
  "message": {
    "message_id": "7301",
    "chat": {"id": "550", "type": "private"},
    "from": {"id": "812", "display_name": "Anna", "is_bot": false},
    "date": "2026-09-26T10:00:00Z",
    "text": "Here is the invoice",
    "attachments": [
      {"file_id": "f_Qm9vdGZpbGVfMDAwMDAx", "kind": "document", "file_name": "invoice.pdf",
       "mime_type": "application/pdf", "size": 48213, "processing_status": "scanning"}
    ]
  }
}
  • file_id is an opaque identifier that works only for your bot.
  • kind is photo for a JPEG, PNG, WebP or HEIC picture up to 10 MiB, otherwise document. A "photo" whose content turns out not to be a picture becomes a document after the check.
  • file_name is cleaned up: no path, no control characters or < > : " | ? *, at most 120 characters. It is safe to show and to save under.
  • mime_type and size in the message are what the person's app declared at upload; nothing has checked them yet. After the check, getFile returns the type detected from the content and the size of the stored copy: rely on those. A file is not refused only because its content differs from the declared type: it is kept under the detected type, and only refused types fail the check (How files are checked).
  • processing_status is usually scanning. A file above 20 MiB by the size in the attachment arrives already rejected, with "reason": "too_large" in the attachment, and no check follows.

Waiting for the check#

Do nothing with a scanning file. When its check ends, SMeet sends a file_status_changed update with its own event_id:

JSON
{
  "update_id": "58",
  "event_id": "2f0c6b8e-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
  "type": "file_status_changed",
  "date": "2026-09-26T10:00:05Z",
  "file_status": {"file_id": "f_Qm9vdGZpbGVfMDAwMDAx", "status": "ready", "message_id": "7301",
                  "chat": {"id": "550", "type": "private"}}
}
  • ready: call getFile once; the answer carries download_path.
  • rejected or scan_failed: reason says why (Reason codes). Tell the person in a short sentence.
  • A file the scanner could not check (scan_failed with scanner_unavailable, scanner_outdated or scan_timeout) is checked again about an hour later, up to three rounds in total. Meanwhile getFile shows scanning again, and another file_status_changed follows.
  • No file_status_changed is sent into a chat the person has stopped, blocked or left, or for a deleted message.
  • A file that arrived with its check already over, such as a rejected file above 20 MiB, gets no file_status_changed: act on the message itself. The attachment carries reason.

This is how python/file_bot.py reacts to both updates:

Pythondocs/bots/examples/python/file_bot.py
def handle(client: smeet.Client, update: dict) -> None:
    if update["type"] == "file_status_changed":
        change = update["file_status"]
        if "message_id" not in change or "chat" not in change:
            return  # the check of one of our own uploads; wait_for_file follows those with getFile
        chat_id, message_id = change["chat"]["id"], change["message_id"]
        if change["status"] == "ready":
            send_receipt(client, chat_id, message_id, change["file_id"])
        elif change["status"] in REFUSED:
            explain_refusal(client, chat_id, message_id, change["file_id"], change.get("reason"))
        return  # any other status, including ones added later, needs nothing

    if update["type"] != "message":
        return  # button presses, edits, access changes and unknown types need nothing here
    message = update["message"]
    chat_id, message_id = message["chat"]["id"], message["message_id"]
    attachments = message.get("attachments") or []
    if not attachments:
        client.send_message(chat_id, HINT, idempotency_key=smeet.action_key(update["event_id"], "hint"))
        return
    for attachment in attachments:
        file_id, status = attachment["file_id"], attachment["processing_status"]
        if status == "scanning":
            log.info("File %s is being checked; file_status_changed will say when it is done", file_id)
        elif status == "ready":
            send_receipt(client, chat_id, message_id, file_id)
        elif status in REFUSED:
            # Refused before any check (too large, for example): no file_status_changed follows, and
            # the attachment carries the reason itself.
            explain_refusal(client, chat_id, message_id, file_id, attachment.get("reason"))

Downloading#

Shell
curl -s https://messenger.scrile.com/bot-api/v1/getFile \
  -H "Authorization: Bearer $SMEET_BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"file_id": "f_Qm9vdGZpbGVfMDAwMDAx"}'
JSON
{"ok": true, "result": {"file_id": "f_Qm9vdGZpbGVfMDAwMDAx", "kind": "document", "file_name": "invoice.pdf",
 "mime_type": "application/pdf", "size": 48213, "status": "ready", "download_path": "/files/f_Qm9vdGZpbGVfMDAwMDAx/content"}}

download_path is relative to the API base URL and needs the token on every call:

Shell
curl -s -o invoice.pdf \
  https://messenger.scrile.com/bot-api/v1/files/f_Qm9vdGZpbGVfMDAwMDAx/content \
  -H "Authorization: Bearer $SMEET_BOT_TOKEN"
  • The answer is the file itself, always as an attachment (Content-Disposition: attachment with the file name), with X-Content-Type-Options: nosniff and Cache-Control: private, no-store. A photo keeps its picture type; a document keeps its type only if it is an image, audio, video, PDF or plain text, otherwise it comes as application/octet-stream.
  • There is no public or permanent link: rights are checked on every download.
  • 409 FILE_NOT_READY (with retry_after) while the file is checked, 422 FILE_REJECTED (with reason) for a refused file, 404 FILE_NOT_FOUND once access has ended (When access ends).
  • Downloads are limited to 5 per second per bot (burst 10).

Uploading a file#

uploadFile takes multipart/form-data and an Idempotency-Key:

FieldContent
fileThe bytes. Required, not empty.
kindphoto or document (default).
file_nameUp to 255 characters; without it the name of the uploaded part is used. It is cleaned up to at most 120 characters.
Shell
curl -s https://messenger.scrile.com/bot-api/v1/uploadFile \
  -H "Authorization: Bearer $SMEET_BOT_TOKEN" \
  -H "Idempotency-Key: receipt-A-1001" \
  -F kind=document -F file_name=receipt.pdf -F file=@receipt.pdf
JSON
{"ok": true, "result": {"file_id": "f_Rm9yVGhlUmVjZWlwdDAx", "kind": "document", "file_name": "receipt.pdf",
 "mime_type": "application/pdf", "size": 20480, "status": "scanning", "retry_after": 5}}
  • A photo is up to 10 MiB and must be a JPEG, PNG, WebP or HEIC picture by its content; otherwise 422 FILE_REJECTED with not_a_photo. A document is up to 20 MiB. A larger file gets 413 PAYLOAD_TOO_LARGE with limit in bytes. An empty file gets 400 INVALID_REQUEST with "field": "file".
  • Refused types (see How files are checked) get 422 FILE_REJECTED with type_not_allowed at once.
  • Each bot may upload 200 MiB per UTC day. Every upload counts, including files the check refuses later. Over the quota: 429 QUOTA_EXCEEDED, with retry_after until midnight UTC.
  • Uploads are limited to 2 per second per bot (burst 5).
  • An uploaded file is kept for 7 days; send it within that time.
  • The same Idempotency-Key with the same bytes, kind and name returns the same file in its current state; with anything different it gets 409 IDEMPOTENCY_CONFLICT.

Waiting for your own upload#

An upload starts as scanning with retry_after. Ask getFile again after exactly that many seconds, not in a tight loop, until the status is ready, rejected or scan_failed. SMeet also sends a file_status_changed without chat and message_id when the check of an upload ends; the examples rely on getFile and ignore it.

Pythondocs/bots/examples/python/smeet.py
def wait_for_file(client: Client, file: dict, max_wait: float = 600.0) -> dict:
    """Wait until the check of a file THIS BOT UPLOADED is over; return it as getFile describes it.

    `file` is what uploadFile returned. SMeet scans every file before a bot may use it. While the
    status is "scanning" the answer carries retry_after, and we sleep exactly that long before
    asking getFile again, instead of asking in a tight loop. The result has status ready,
    rejected or scan_failed. For a file a user sent you do not need this: SMeet sends
    file_status_changed when the check of a received attachment is over (see file_bot.py).
    """
    deadline = time.monotonic() + max_wait
    info = file
    while info["status"] == "scanning":
        delay = max(1, min(int(info.get("retry_after") or 5), 60))
        if time.monotonic() + delay > deadline:
            raise TimeoutError(f"file {file['file_id']} is still {info['status']} after {max_wait:.0f} s")
        log.info("File %s is %s, asking again in %d s", file["file_id"], info["status"], delay)
        client.sleep(delay)
        info = client.get_file(file["file_id"])
    return info

The production examples do not sleep at all: python/bot_logic.py raises smeet.RetryLater(retry_after), the worker postpones that update and answers other chats meanwhile.

Sending a file#

sendPhoto and sendDocument send a ready file as an ordinary attachment:

JSON
{"chat_id": "550", "file_id": "f_Rm9yVGhlUmVjZWlwdDAx", "caption": "Your receipt", "reply_to_message_id": "7301"}
  • file_id is a file your bot uploaded, or an attachment of a message your bot received in the same space while the bot still has access to it. A file of another space gets 404 FILE_NOT_FOUND.
  • caption is up to 1024 characters; reply_to_message_id and reply_markup work as in sendMessage.
  • The file must be ready. While it is checked the call gets 409 FILE_NOT_READY with retry_after: repeat the same request with the same key later, the key is not used up. A refused file gets 422 FILE_REJECTED with reason.
  • sendPhoto needs a file of kind photo; any other file gets 400 INVALID_REQUEST with "field": "file_id", send it with sendDocument.
  • A document always reaches the person as a file to download, never opened inline.

How files are checked#

  • Every file is checked before a bot may use it: a file a person sends before the bot can download it, a file the bot uploads before it can be sent.
  • The bytes are kept in a private store without a public link. A file a person sends is copied there before the check, and only that copy is checked and served to the bot, so later changes elsewhere cannot reach it.
  • The type is detected from the content, never from the name or the declared type, and it replaces the declared type once the check is over. Markup a viewer could run (HTML, SVG, XML), scripts that start with #! and programs (Windows, Linux and macOS executables) are refused, and so are names with dangerous extensions such as .html, .svg, .js, .exe, .sh, .py, .jar or .apk.
  • An antivirus scanner (ClamAV) checks the content with signatures no older than 3 days. A clean result for identical content is reused while the signatures are fresh.
  • When the scanner is unavailable, too old or does not finish within 60 seconds, the check is tried 3 times, 30 seconds and then 2 minutes apart, and the file becomes scan_failed. About an hour later it is checked again, up to three rounds in total. Nothing becomes ready without a clean check.
  • During a technical pause of the platform checks wait and continue afterwards.

Reason codes#

reason of a rejected or scan_failed file, and error.reason of FILE_REJECTED:

CodeMeaning
malware_detectedThe scanner found malware.
type_not_allowedMarkup, a program, another refused type or a dangerous extension.
not_a_photoUploads only: kind=photo, but the content is not a JPEG, PNG, WebP or HEIC picture.
too_largeLarger than allowed: 20 MiB for a file a person sends; 10 MiB for a photo and 20 MiB for a document the bot uploads. Such a file is rejected. A file the scanner itself refuses for its size ends as scan_failed with this reason and is not checked again.
scanner_unavailableThe scanner could not be reached or failed. The file is checked again later.
scanner_outdatedThe scanner's signatures are older than 3 days. The file is checked again later.
scan_timeoutThe check did not finish in time. The file is checked again later.
source_missingThe file disappeared before it could be checked.
emptyThe file has no content, for example an empty file a person sent.

New codes can appear at any time. Turn the known ones into a sentence for the person and use a general sentence for the rest, as smeet.file_reason_text does in the examples.

When access ends#

FileThe bot may use it
Sent by a personWhile the message exists, the chat is started and the person stays in the space; at most 30 days.
Uploaded by the bot7 days.
  • Deleting the message, Stop, Block and leaving the space end access at once: getFile and downloads answer 404 FILE_NOT_FOUND, and no file_status_changed is sent for such files.
  • A copy your server has already downloaded cannot be recalled by SMeet. Delete it when the person asks, and say in the bot's description what the bot keeps.

Repeats and keys#

  • The same Idempotency-Key never creates a second file or a second message.
  • FILE_NOT_READY and the other errors before an action do not use up the key: repeat the same request later.
  • Two different updates can lead to the same action: a message whose file was already checked, and a later file_status_changed for that file. Build the keys of such an action from the file_id, not from an event_id, as python/file_bot.py does with smeet.action_key(file_id, "receipt"). A replay of either update then sends nothing new.

Limits#

WhatLimit
Photo uploaded by the botUp to 10 MiB; JPEG, PNG, WebP or HEIC by content
Document uploaded by the botUp to 20 MiB
File a person sends that the bot can receiveUp to 20 MiB; larger ones arrive rejected with too_large
Upload volume200 MiB per bot per UTC day (429 QUOTA_EXCEEDED)
Request body21 MiB (413 PAYLOAD_TOO_LARGE with limit)
Keeping an upload7 days
Access to a received fileWhile the message and the chat allow it, at most 30 days
Uploads and downloads2 uploads per second per bot (burst 5), 5 downloads per second per bot (burst 10)
File nameCleaned up to at most 120 characters