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, voice for a voice message (Voice messages), 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.

Voice messages#

A voice message arrives as a message with one attachment of kind voice and usually no text:

JSON
"attachments": [
  {"file_id": "f_Qm9vdGZpbGVfMDAwMDAy", "kind": "voice", "file_name": "voice.ogg",
   "mime_type": "audio/ogg", "size": 18422, "processing_status": "scanning",
   "duration": 4, "transcription": "What are your opening hours?"}
]
  • duration is the length in whole seconds, as the person's app measured it.
  • transcription is present only when the person's app recognised the speech. It is a convenience: for anything that must be exact, download the audio and recognise it yourself.
  • The recording goes through the same check as any file (Waiting for the check) and is downloaded the same way. After the check getFile names the audio type of the content: a recording made in a browser is audio/webm, one made on a phone is usually audio/mp4.
  • A voice message is not has_unsupported_content. A round video message still is.

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