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:
{
"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_idis an opaque identifier that works only for your bot.kindisphotofor a JPEG, PNG, WebP or HEIC picture up to 10 MiB, otherwisedocument. A "photo" whose content turns out not to be a picture becomes a document after the check.file_nameis cleaned up: no path, no control characters or< > : " | ? *, at most 120 characters. It is safe to show and to save under.mime_typeandsizein 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_statusis usuallyscanning. A file above 20 MiB by thesizein the attachment arrives alreadyrejected, 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:
{
"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 carriesdownload_path.rejectedorscan_failed:reasonsays why (Reason codes). Tell the person in a short sentence.- A file the scanner could not check (
scan_failedwithscanner_unavailable,scanner_outdatedorscan_timeout) is checked again about an hour later, up to three rounds in total. Meanwhile getFile showsscanningagain, and anotherfile_status_changedfollows. - No
file_status_changedis 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
rejectedfile above 20 MiB, gets nofile_status_changed: act on the message itself. The attachment carriesreason.
This is how python/file_bot.py reacts to both updates:
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#
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"}'{"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:
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: attachmentwith the file name), withX-Content-Type-Options: nosniffandCache-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 asapplication/octet-stream. - There is no public or permanent link: rights are checked on every download.
409 FILE_NOT_READY(withretry_after) while the file is checked,422 FILE_REJECTED(withreason) for a refused file,404 FILE_NOT_FOUNDonce 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:
| Field | Content |
|---|---|
file | The bytes. Required, not empty. |
kind | photo or document (default). |
file_name | Up to 255 characters; without it the name of the uploaded part is used. It is cleaned up to at most 120 characters. |
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{"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_REJECTEDwithnot_a_photo. A document is up to 20 MiB. A larger file gets413 PAYLOAD_TOO_LARGEwithlimitin bytes. An emptyfilegets400 INVALID_REQUESTwith"field": "file". - Refused types (see How files are checked) get
422 FILE_REJECTEDwithtype_not_allowedat 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, withretry_afteruntil 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-Keywith the same bytes, kind and name returns the same file in its current state; with anything different it gets409 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.
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 infoThe 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:
{"chat_id": "550", "file_id": "f_Rm9yVGhlUmVjZWlwdDAx", "caption": "Your receipt", "reply_to_message_id": "7301"}file_idis 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 gets404 FILE_NOT_FOUND.captionis up to 1024 characters;reply_to_message_idandreply_markupwork as in sendMessage.- The file must be
ready. While it is checked the call gets409 FILE_NOT_READYwithretry_after: repeat the same request with the same key later, the key is not used up. A refused file gets422 FILE_REJECTEDwithreason. - sendPhoto needs a file of kind
photo; any other file gets400 INVALID_REQUESTwith"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,.jaror.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 becomesreadywithout 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:
| Code | Meaning |
|---|---|
malware_detected | The scanner found malware. |
type_not_allowed | Markup, a program, another refused type or a dangerous extension. |
not_a_photo | Uploads only: kind=photo, but the content is not a JPEG, PNG, WebP or HEIC picture. |
too_large | Larger 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_unavailable | The scanner could not be reached or failed. The file is checked again later. |
scanner_outdated | The scanner's signatures are older than 3 days. The file is checked again later. |
scan_timeout | The check did not finish in time. The file is checked again later. |
source_missing | The file disappeared before it could be checked. |
empty | The 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#
| File | The bot may use it |
|---|---|
| Sent by a person | While the message exists, the chat is started and the person stays in the space; at most 30 days. |
| Uploaded by the bot | 7 days. |
- Deleting the message, Stop, Block and leaving the space end access at once: getFile and downloads answer
404 FILE_NOT_FOUND, and nofile_status_changedis 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_READYand 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_changedfor that file. Build the keys of such an action from thefile_id, not from anevent_id, aspython/file_bot.pydoes withsmeet.action_key(file_id, "receipt"). A replay of either update then sends nothing new.
Limits#
| What | Limit |
|---|---|
| Photo uploaded by the bot | Up to 10 MiB; JPEG, PNG, WebP or HEIC by content |
| Document uploaded by the bot | Up to 20 MiB |
| File a person sends that the bot can receive | Up to 20 MiB; larger ones arrive rejected with too_large |
| Upload volume | 200 MiB per bot per UTC day (429 QUOTA_EXCEEDED) |
| Request body | 21 MiB (413 PAYLOAD_TOO_LARGE with limit) |
| Keeping an upload | 7 days |
| Access to a received file | While the message and the chat allow it, at most 30 days |
| Uploads and downloads | 2 uploads per second per bot (burst 5), 5 downloads per second per bot (burst 10) |
| File name | Cleaned up to at most 120 characters |