Разработчикам

Как отправить фото и файлы через WhatsApp API: счета, PDF, картинки

Обновлено 7 мин чтения

Счёт на оплату, фото товара, акт сверки, схема проезда — то, что удобнее отправить файлом, чем описывать словами. В wapi.kz вложения уходят тем же запросом POST /messages/send, что и текст: вы передаёте ссылку на файл, мы скачиваем его и отправляем в WhatsApp.

Ниже — поля запроса, требования к ссылке, как отдать файл из закрытого хранилища, ошибки и примеры отправки счёта PDF из Node.js и Python. Основы — ключ, from, коды ошибок — в гайде про отправку сообщений через API, а готовые сценарии вроде счёта после заказа — в гайде про уведомления клиентам.

Как это устроено

  1. Файл лежит там, откуда его можно скачать по ссылке: на вашем сервере или в хранилище.
  2. Вы отправляете POST /messages/send с полем image, file, voice или video и этой ссылкой.
  3. Сервер wapi.kz скачивает файл и отправляет получателю. Своего лимита размера нет — действует лимит WhatsApp, до 2 ГБ на файл.
  4. В ответе — те же id и wa_message_id, что у текста; в журнале у фото тип image, у файла — document, у голосового — voice, у видео — video. Отправленный файл сохраняется в хранилище и появляется в поле media сообщения.

Загружать файл в wapi.kz заранее не нужно, base64 в теле запроса тоже не передаётся — только ссылка.

Фото: поле image

bash
curl -X POST https://wapi.kz/api/v1/messages/send \
  -H "Authorization: Bearer $WAPI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to":"77011234567","from":1,"image":{"url":"https://shop.example.kz/img/sofa-grey.jpg","caption":"Диван «Алматы», серый — есть в наличии"}}'

Поддерживаются JPEG, PNG и WebP — получатель увидит картинку прямо в чате. Тип определяется по заголовку Content-Type, который отдаёт сервер с файлом, а не по расширению в ссылке. Если сервер отдаёт картинку как application/octet-stream, API ответит invalid_media — поправьте заголовок или отправьте файл через поле file. Так же, документом, уходят GIF, HEIC и другие форматы.

Документ: поле file

JSON
{
  "to": "77011234567",
  "from": 1,
  "file": {
    "url": "https://files.example.kz/invoices/1209.pdf?exp=1792160000&sig=9f2c41d7",
    "filename": "Счёт №1209.pdf",
    "caption": "Счёт №1209 на оплату"
  }
}

file принимает любой тип: PDF, таблицы, документы Word, архивы. Получатель увидит документ с именем и размером и откроет его на телефоне.

Имя файла

filename — то, что увидит клиент. Кириллица, пробелы и «№» — пожалуйста: «Счёт №1209.pdf» придёт именно так, JSON передаётся в UTF-8. Если имя не указать, мы возьмём последнюю часть пути из ссылки (1209.pdf), а если её нет — назовём файл file. Не забывайте расширение: без него получателю сложнее открыть документ. Имя длиннее 120 символов обрежется.

Подпись

caption необязателен. Если его нет, подписью станет поле text из того же запроса; нет ни того, ни другого — файл уйдёт без подписи. Длина подписи — до 4096 символов, как у обычного текста. Отдельное сообщение перед файлом не нужно: подпись и файл приходят одним сообщением.

Голосовое: поле voice

JSON
{ "to": "77011234567", "from": 1, "voice": { "url": "https://files.example.kz/audio/reply.mp3" } }

Подойдут MP3, M4A, WAV, OGG, AAC и AMR: мы перекодируем запись в голосовое WhatsApp (OGG/Opus с волной и плеером), и клиент увидит его так же, как записанное на телефоне. Подписи у голосового нет — нужный текст отправьте отдельным сообщением.

Видео: поле video

JSON
{ "to": "77011234567", "from": 1, "video": { "url": "https://files.example.kz/video/obzor.mov", "caption": "Обзор дивана «Алматы»" } }

MP4 уходит как есть, MOV, WebM и 3GP перекодируем в MP4 (H.264). Подпись caption работает так же, как у фото. Крупное видео скачивается и перекодируется дольше — ответ может прийти через десятки секунд, поэтому дайте запросу время.

  • Публичная: открывается без логина, cookies и заголовков авторизации.
  • http или https, адрес в интернете — ссылки на localhost и внутреннюю сеть не скачиваются.
  • Не больше 3 перенаправлений.
  • Файл — до 2 ГБ: это лимит самого WhatsApp, своего ограничения у нас нет.
  • Сервер отвечает кодом 200 и отдаёт сам файл, а не страницу «Скачать».

Последний пункт — частая ловушка с облачными дисками и файлообменниками: ссылка «поделиться» ведёт на HTML-страницу с кнопкой, а не на файл. Нужна прямая ссылка на скачивание. Проверить, что ссылка без авторизации отдаёт сам файл, можно одной командой:

bash
curl -s -o /dev/null -w "%{http_code} %{content_type} %{size_download}\n" "https://files.example.kz/invoices/1209.pdf?exp=1792160000&sig=9f2c41d7"
# 200 application/pdf 184213 — всё в порядке

Счета и договоры не стоит класть в открытую папку навсегда. Решение — временная ссылка, которая работает ограниченное время и потом перестаёт открываться:

  • В S3-совместимых хранилищах это подписанная ссылка (presigned URL) на скачивание: её выдаёт SDK хранилища, срок жизни задаётся в секундах.
  • Если файлы лежат на вашем сервере, подпишите ссылку сами: имя файла и срок действия плюс HMAC с секретом. Так устроен пример на Node.js ниже.

Ставьте срок с запасом — хотя бы 10–15 минут, чтобы ссылка не истекла, пока сообщение ждёт в очереди. Клиент ссылку не увидит: мы скачиваем файл в момент отправки, и в WhatsApp приходит сам документ.

Ошибки вложений

codeКогдаЧто делать
invalid_mediaНе ссылка, адрес во внутренней сети, файл пустой, в image не JPEG, PNG или WebP, в voice не аудио, в video не видеоПроверить ссылку и Content-Type; не картинку отправить через file
media_too_largeФайл больше 2 ГБ — WhatsApp такой не приметСжать файл или отправить ссылку на него текстом
media_fetch_failedНе скачался: адрес не открывается, ответ не 200, больше 3 перенаправлений, ссылка истекла или требует входаПроверить ссылку командой выше; если истекла — выпустить новую
busyСервер прямо сейчас принимает много крупных файловПовторить через несколько минут

Первые три приходят с HTTP 400, busy — с HTTP 503: сообщение не отправляется и в журнал не попадает. Остальные ошибки — rate_limited, not_connected, number_paused и другие — такие же, как у текста, см. документацию по ошибкам.

Пример: счёт PDF из Node.js

Счета лежат на вашем сервере в /var/invoices. Express отдаёт их по временной подписанной ссылке, функция sendInvoice отправляет счёт клиенту. Node.js 18+, пакет express.

JavaScript
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.FILE_LINK_SECRET; // длинная случайная строка
const PUBLIC_URL = "https://shop.example.kz"; // адрес этого сервера снаружи
const sign = (name, exp) => crypto.createHmac("sha256", SECRET).update(`${name}:${exp}`).digest("hex");

function signedUrl(name, ttl = 3600) {
  const exp = Math.floor(Date.now() / 1000) + ttl;
  return `${PUBLIC_URL}/files/${encodeURIComponent(name)}?exp=${exp}&sig=${sign(name, exp)}`;
}

const app = express();
app.get("/files/:name", (req, res) => {
  const { exp, sig } = req.query;
  if (sig !== sign(req.params.name, exp) || Number(exp) < Date.now() / 1000) return res.sendStatus(403);
  res.sendFile(req.params.name, { root: "/var/invoices" });
});
app.listen(3000);

export async function sendInvoice(to, invoiceNo) {
  const res = await fetch("https://wapi.kz/api/v1/messages/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WAPI_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      to,
      from: 1,
      file: {
        url: signedUrl(`${invoiceNo}.pdf`),
        filename: `Счёт №${invoiceNo}.pdf`,
        caption: `Счёт №${invoiceNo} на оплату. Вопросы — в ответ на это сообщение.`,
      },
    }),
    signal: AbortSignal.timeout(60000), // мы сначала скачиваем файл — дайте запросу время
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.code}: ${data.error}`);
  return data.id; // по нему GET /messages/{id} покажет статус доставки
}

Пример: счёт PDF из Python

Ссылку на PDF уже выдало ваше хранилище или учётная система. Функция повторяет отправку при rate_limited и понятно объясняет ошибки вложения.

Python
import os
import time

import requests

API = "https://wapi.kz/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['WAPI_KEY']}"}


def send_invoice(to, invoice_no, pdf_url, from_number=1):
    body = {
        "to": to,
        "from": from_number,
        "file": {
            "url": pdf_url,
            "filename": f"Счёт №{invoice_no}.pdf",
            "caption": f"Счёт №{invoice_no} на оплату",
        },
    }
    for _ in range(3):
        r = requests.post(f"{API}/messages/send", headers=HEADERS, json=body, timeout=60)
        data = r.json()
        if r.status_code == 200:
            return data["id"]
        code = data.get("code")
        if code == "rate_limited":
            time.sleep(2)
            continue
        if code == "media_too_large":
            raise ValueError("Файл больше 2 ГБ: WhatsApp такой не примет — отправьте ссылку текстом")
        if code in ("invalid_media", "media_fetch_failed"):
            raise ValueError(f"Ссылка не подошла: {data['error']}")
        raise RuntimeError(f"{r.status_code} {code}: {data['error']}")
    raise RuntimeError("Номер занят другими отправками, повторите позже")


url = "https://files.example.kz/invoices/1209.pdf?exp=1792160000&sig=9f2c41d7"
print("id в журнале:", send_invoice("77011234567", 1209, url))

Через MCP: файл отправляет ИИ-агент

Инструмент send_message MCP-сервера принимает image_url, file_url с filename, voice_url или video_url; text при вложении становится подписью. Агенту достаточно сказать обычными словами: «Отправь клиенту 77011234567 счёт по этой ссылке с подписью „Счёт №1209 на оплату“» — он сам вызовет инструмент примерно так:

JSON
{
  "name": "send_message",
  "arguments": {
    "to": "77011234567",
    "text": "Счёт №1209 на оплату",
    "file_url": "https://files.example.kz/invoices/1209.pdf?exp=1792160000&sig=9f2c41d7",
    "filename": "Счёт №1209.pdf"
  }
}

Требования к ссылке те же. Как подключить MCP-сервер — в гайде про ИИ-агентов и MCP и в документации.

Чего пока нет

  • Вложения, как и текст, уходят только в личные чаты, не в группы.
  • Одно сообщение — одно вложение, у голосового нет подписи.

Входящие файлы от клиентов сохраняются сами: в вебхуке приходит hasMedia: true, а через несколько секунд файл появляется в поле media сообщения — ссылка на скачивание, см. документацию.

Подключить номер можно после регистрации. Номер стоит 9 900 ₸ в месяц, за отправку фото и файлов отдельно не платите. Отправленные и полученные файлы хранятся: 5 ГБ на аккаунт бесплатно, сверх — 1 000 ₸ в месяц за каждые 5 ГБ, см. цены.

Частые вопросы

Можно ли отправить файл без ссылки, прямо из программы?

Нет, только по ссылке: загрузки файла в теле запроса нет. Положите файл на свой сервер или в хранилище и передайте временную ссылку.

Какой максимальный размер файла?

Своего лимита у нас нет — действует лимит WhatsApp: до 2 ГБ на файл. Файл больше отклоняется с кодом media_too_large.

Можно ли отправить несколько файлов одним сообщением?

Нет: одно сообщение — одно вложение. Несколько файлов — несколько запросов, с учётом лимита 1 сообщение в секунду с номера.

Увидит ли клиент мою ссылку на файл?

Нет. Мы скачиваем файл и отправляем его как вложение — клиент получает сам документ или фото, а ссылка нигде не показывается.

Как отправить голосовое сообщение или видео?

Полем voice или video с публичной ссылкой. MP3, M4A, WAV, OGG, AAC и AMR мы перекодируем в голосовое WhatsApp, MP4 уходит как есть, MOV, WebM и 3GP перекодируем в MP4. Подробнее — в документации.

Подключите WhatsApp к своим системам

9 900 ₸ в месяц за номер, без платы за сообщения. Сервер в Казахстане, оплата Kaspi или счётом на ТОО/ИП. Подключение — около 5 минут.