Документация API

Отправка писем, таблицы, шаблоны и статистика по HTTP

MailSharks API — то же самое, что делает приложение, но из вашей программы: отправить письмо, подключить таблицу, завести рассылку, забрать статистику. Всё поверх обычного HTTPS и JSON, без SDK.

Базовый адрес: https://api.mailsharks.org/v1

Пишете не программу, а запрос к ИИ? Тогда вам нужен не HTTP-API, а MCP-сервер MailSharks: Claude, ChatGPT и любой другой клиент с поддержкой MCP управляют рассылками сами, без единой строки кода.

С чего начать

  1. Откройте приложение → API.
  2. Подключите почту для API. Это отдельный Google-аккаунт: у него свой дневной лимит, поэтому интеграция не съедает лимит вашей живой работы. Один и тот же ящик нельзя подключить дважды — ни в два слота, ни в два аккаунта.
    Второго аккаунта нет? Там же есть кнопка «Использовать основную почту»: API начнёт слать из того же ящика, что и приложение. Дневной лимит у них тогда общий — в ответе GET /v1/account это видно по полю shared_with_app.
  3. Выпустите ключ. Он показывается один раз: в базе хранится только его SHA-256, и подсмотреть ключ позже нельзя — только перевыпустить.

Аутентификация

Ключ передаётся заголовком в каждом запросе:

Authorization: Bearer ms_live_ВАШ_КЛЮЧ

Ключ даёт полный доступ к вашей почте для API. Держите его на сервере: из браузера и мобильного приложения его видно всем, кто откроет исходники.

Лимиты

Эндпоинты

МетодПутьЧто делает
GET/accountПочта, дневной лимит и остаток на сегодня
POST/messagesОтправить одно письмо
GET/messagesИстория одиночных отправок
GET/messages/{id}Одно письмо со статусом
GET/repliesОтветы получателей на письма API
GET/messages/{id}/repliesОтветы на одно письмо
POST/filesЗагрузить файл на Диск и приложить его к письму
GET/sheets/inspectЛисты и заголовки таблицы по ссылке
POST/sheetsПодключить таблицу
GET/sheetsПодключённые таблицы
DELETE/sheets/{id}Отключить таблицу
GET/templatesШаблоны писем
POST/templatesСоздать шаблон
PATCH/templates/{id}Изменить шаблон
POST/templates/{id}/renderСобрать письмо, не отправляя
POST/campaignsРассылка по таблице — стартует сразу
GET/campaigns/{id}Статус и статистика рассылки
POST/campaigns/{id}/pauseПауза
POST/campaigns/{id}/resumeПродолжить
GET/campaigns/{id}/recipientsПолучатели постранично
GET/eventsЧто произошло с указанного момента
GET/unsubscribesСтоп-лист аккаунта
POST/unsubscribesДобавить адрес в стоп-лист

Отправить письмо

Одиночная отправка синхронная: ответ приходит, когда письмо уже принято Gmail. Заголовок Idempotency-Key необязателен, но с ним повтор запроса после оборвавшейся сети вернёт прежний ответ, а не отправит письмо второй раз.

curl -X POST https://api.mailsharks.org/v1/messages \
  -H "Authorization: Bearer ms_live_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-confirm" \
  -d '{
    "to": "client@example.com",
    "subject": "Заказ {{order}} принят",
    "body_html": "<p>Здравствуйте, {{name}}! Мы получили заказ {{order}}.</p>",
    "variables": {"name": "Анна", "order": "A-1042"}
  }'
import httpx

response = httpx.post(
    "https://api.mailsharks.org/v1/messages",
    headers={
        "Authorization": "Bearer ms_live_ВАШ_КЛЮЧ",
        "Idempotency-Key": "order-1042-confirm",
    },
    json={
        "to": "client@example.com",
        "subject": "Заказ {{order}} принят",
        "body_html": "<p>Здравствуйте, {{name}}! Мы получили заказ {{order}}.</p>",
        "variables": {"name": "Анна", "order": "A-1042"},
    },
    timeout=30,
)
response.raise_for_status()
print(response.json()["id"])
const response = await fetch("https://api.mailsharks.org/v1/messages", {
  method: "POST",
  headers: {
    "Authorization": "Bearer ms_live_ВАШ_КЛЮЧ",
    "Content-Type": "application/json",
    "Idempotency-Key": "order-1042-confirm",
  },
  body: JSON.stringify({
    to: "client@example.com",
    subject: "Заказ {{order}} принят",
    body_html: "<p>Здравствуйте, {{name}}! Мы получили заказ {{order}}.</p>",
    variables: { name: "Анна", order: "A-1042" },
  }),
});
if (!response.ok) throw new Error((await response.json()).error.message);
const { id } = await response.json();

Вложения

Файл к письму прикладывается так же, как в приложении: он уезжает на Google Диск отправителя, получает доступ по ссылке, а в конец письма встаёт карточка со ссылкой на него. Вложением в почтовом смысле письмо не уходит и не будет: рассылка с вложением — это один и тот же файл, отправленный тысячу раз, и почтовые серверы такое режут.

Загрузка — POST /files, multipart/form-data, поле file. Предел — 25 МБ. В ответе приходит номер файла; его передают в поле attach у POST /messages и POST /campaigns. Загруженный один раз файл прикладывается к скольким угодно письмам — грузить его заново на каждую отправку не нужно.

# 1. Файл уезжает на Диск отправителя
curl -X POST https://api.mailsharks.org/v1/files   -H "Authorization: Bearer ms_live_ВАШ_КЛЮЧ"   -F "file=@price-2026.pdf"
# {"id": "1AbC...", "name": "price-2026.pdf", "size": 184320, "shared": true, ...}

# 2. Его номер идёт в attach — карточка встанет в конец письма
curl -X POST https://api.mailsharks.org/v1/messages   -H "Authorization: Bearer ms_live_ВАШ_КЛЮЧ"   -H "Content-Type: application/json"   -d '{
    "to": "client@example.com",
    "subject": "Прайс на 2026",
    "body_html": "<p>Прайс во вложении.</p>",
    "attach": ["1AbC..."]
  }'
import httpx

headers = {"Authorization": "Bearer ms_live_ВАШ_КЛЮЧ"}

with open("price-2026.pdf", "rb") as handle:
    uploaded = httpx.post(
        "https://api.mailsharks.org/v1/files",
        headers=headers,
        files={"file": ("price-2026.pdf", handle, "application/pdf")},
        timeout=120,
    )
uploaded.raise_for_status()

response = httpx.post(
    "https://api.mailsharks.org/v1/messages",
    headers=headers,
    json={
        "to": "client@example.com",
        "subject": "Прайс на 2026",
        "body_html": "<p>Прайс во вложении.</p>",
        "attach": [uploaded.json()["id"]],
    },
    timeout=30,
)
response.raise_for_status()
const headers = { Authorization: "Bearer ms_live_ВАШ_КЛЮЧ" };

const form = new FormData();
form.append("file", file);   // File из <input type="file"> или Blob

const uploaded = await fetch("https://api.mailsharks.org/v1/files", {
  method: "POST",
  headers,
  body: form,
}).then((r) => r.json());

await fetch("https://api.mailsharks.org/v1/messages", {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    to: "client@example.com",
    subject: "Прайс на 2026",
    body_html: "<p>Прайс во вложении.</p>",
    attach: [uploaded.id],
  }),
});

В attach кладут либо номер из POST /files, либо обычную ссылку на файл Диска — номер из неё разбирается сам. Ссылка должна вести на файл, загруженный этим же ключом: к остальному содержимому вашего Диска у API доступа нет, и на чужой файл придёт 409 file_error.

Диск берётся тот же, которым API отправляет письма. Если в панели включено «использовать основную почту», файлы лягут на основной Диск — иначе получатель открывал бы ссылку не того владельца.

Поле shared в ответе показывает, выдан ли доступ по ссылке. Обычно true; false означает, что доступ запретила политика вашей организации, и получатель упрётся в «запросить доступ» — это видно до отправки.

Карточки встают в конец письма в том порядке, в каком переданы, и, если включён track_clicks, считаются наравне с остальными ссылками. У A/B-теста файл получают оба варианта.

Несколько таблиц в одной рассылке

Вместо sheet_id можно передать sheet_ids — список номеров таблиц. Списки складываются в одну рассылку, и письмо уходит по одному разу на адрес: повторы снимаются сами.

При совпадении адреса побеждает строка из таблицы, указанной раньше, — её имя и её подстановки. Поэтому порядок в sheet_ids имеет значение: ставьте первой ту таблицу, данные которой точнее.

Подстановка должна существовать во всех таблицах списка. Если {{city}} есть в одной и нет в другой, рассылка не создастся: иначе часть писем ушла бы с пустым местом вместо города. То же с {{name}} — столбец имени нужен у каждой таблицы.

Стоп-листы складываются: адрес, отписавшийся в любой из таблиц, писем не получит. Отписка по такому письму ложится в стоп-лист той таблицы, из которой человек пришёл, а не первой в списке.

В ответе к обычным полям добавляется skipped_duplicates — сколько строк слиплось как повторы. Поле sheets со списком таблиц есть у любой рассылки, в том числе в GET /v1/campaigns: у рассылки по одной таблице там один номер, разбирать два случая не придётся. Без первого «адресов меньше, чем строк» читалось бы как потеря писем.

Таблиц в одной рассылке не больше десяти: каждая — это поход в Google, и десяток уже превращает создание кампании в минутное ожидание.

Переменные

В теме и в теле работают подстановки вида {{name}}. Имена нечувствительны к регистру. Значения берутся, по возрастанию важности, из:

  1. поля variables шаблона — значения по умолчанию;
  2. поля variables запроса — общие для всей отправки;
  3. колонок таблицы, если это рассылка: {{city}} берётся из столбца city и у каждого получателя своё.

Если подстановку нечем закрыть — ни переменной, ни колонки — запрос отклоняется с 400 missing_variables. Проверка делается один раз, при создании: пустая ячейка у отдельной строки подставляется пустой и рассылку не рушит.

Посмотреть результат, ничего не отправляя, можно через POST /templates/{id}/render.

Шаблоны заводятся в приложении: API → Шаблоны. У каждого есть короткий номер вида a7Kd93Xz — его копируют кнопкой и передают как template_id. Шаблоны из обычного раздела «Шаблоны писем» этим ключом не работают: они принадлежат другой почте, с другим лимитом и своими таблицами.

Рассылка по таблице

Таблица должна быть открыта почте для API — это отдельный аккаунт, и доступ, выданный вашей основной почте, ему не наследуется. Кампания стартует сразу после создания, поэтому все проверки — почта, лимит, доступ к таблице, полнота переменных — делаются до первого письма.

# 1. Посмотреть, какие листы и столбцы есть в таблице
curl -G https://api.mailsharks.org/v1/sheets/inspect \
  -H "Authorization: Bearer ms_live_ВАШ_КЛЮЧ" \
  --data-urlencode "link=https://docs.google.com/spreadsheets/d/1AbC.../edit"

# 2. Подключить её
curl -X POST https://api.mailsharks.org/v1/sheets \
  -H "Authorization: Bearer ms_live_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{"link": "https://docs.google.com/spreadsheets/d/1AbC.../edit",
       "email_column": "email", "name_column": "name"}'

# 3. Запустить рассылку (стартует сразу)
curl -X POST https://api.mailsharks.org/v1/campaigns \
  -H "Authorization: Bearer ms_live_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{"sheet_id": 42, "template_id": "a7Kd93Xz",
       "variables": {"sale": "20%"}, "daily_limit": 300}'
import httpx

api = httpx.Client(
    base_url="https://api.mailsharks.org/v1",
    headers={"Authorization": "Bearer ms_live_ВАШ_КЛЮЧ"},
    timeout=60,
)

sheet = api.post("/sheets", json={
    "link": "https://docs.google.com/spreadsheets/d/1AbC.../edit",
    "email_column": "email",
    "name_column": "name",
}).json()

campaign = api.post("/campaigns", json={
    "sheet_id": sheet["id"],
    "template_id": "a7Kd93Xz",
    "variables": {"sale": "20%"},   # общие для всех; колонки таблицы важнее
    "daily_limit": 300,
}).json()

print(campaign["id"], campaign["recipients"])
const headers = {
  "Authorization": "Bearer ms_live_ВАШ_КЛЮЧ",
  "Content-Type": "application/json",
};

const sheet = await (await fetch("https://api.mailsharks.org/v1/sheets", {
  method: "POST", headers,
  body: JSON.stringify({
    link: "https://docs.google.com/spreadsheets/d/1AbC.../edit",
    email_column: "email",
    name_column: "name",
  }),
})).json();

const campaign = await (await fetch("https://api.mailsharks.org/v1/campaigns", {
  method: "POST", headers,
  body: JSON.stringify({
    sheet_id: sheet.id,
    template_id: "a7Kd93Xz",
    variables: { sale: "20%" },
    daily_limit: 300,
  }),
})).json();

A/B-тест

Поле variant_b в POST /campaigns заводит второй вариант письма: обязательная subject и необязательный body_html (пусто — текст тот же, проверяется только тема). Адреса делятся ровно пополам случайным образом в момент создания рассылки, поэтому паузы и дневные лимиты половин не перекашивают. Нужен тариф Pro и хотя бы два адреса вне стоп-листа.

GET /campaigns/{id} возвращает у такой рассылки блок ab: статистика по каждому варианту (sent, opened, clicked, replied) и verdict — доли открытий, победитель, уверенность (двусторонний z-тест долей) и significant, которое становится true с 95%. У получателей в GET /campaigns/{id}/recipients появляется поле variant.

# Половине списка уйдёт тема A, половине — B
curl -X POST https://api.mailsharks.org/v1/campaigns \
  -H "Authorization: Bearer ms_live_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{"sheet_id": 42, "template_id": "a7Kd93Xz",
       "variant_b": {"subject": "Три минуты вашего времени"}}'

# Кто выигрывает
curl https://api.mailsharks.org/v1/campaigns/128 \
  -H "Authorization: Bearer ms_live_ВАШ_КЛЮЧ"

# "ab": {"variants": [{"label": "A", "subject": "...",
#                      "stats": {"sent": 500, "opened": 96, ...}}, ...],
#        "verdict": {"winner": "B", "rate": {"A": 0.192, "B": 0.244},
#                    "lift": 0.271, "confidence": 0.978, "significant": true}}
import httpx

api = httpx.Client(base_url="https://api.mailsharks.org/v1",
                   headers={"Authorization": "Bearer ms_live_ВАШ_КЛЮЧ"})

campaign = api.post("/campaigns", json={
    "sheet_id": 42,
    "template_id": "a7Kd93Xz",
    # Только тема: без body_html текст письма у обоих вариантов общий.
    "variant_b": {"subject": "Три минуты вашего времени"},
}).json()

ab = api.get(f"/campaigns/{campaign['id']}").json()["ab"]
if ab["verdict"]["significant"]:
    print("Победил вариант", ab["verdict"]["winner"])
const headers = {
  "Authorization": "Bearer ms_live_ВАШ_КЛЮЧ",
  "Content-Type": "application/json",
};

const campaign = await (await fetch("https://api.mailsharks.org/v1/campaigns", {
  method: "POST", headers,
  body: JSON.stringify({
    sheet_id: 42,
    template_id: "a7Kd93Xz",
    variant_b: { subject: "Три минуты вашего времени" },
  }),
})).json();

const { ab } = await (await fetch(`https://api.mailsharks.org/v1/campaigns/${campaign.id}`, {
  headers,
})).json();
if (ab.verdict.significant) console.log("Победил вариант", ab.verdict.winner);

События вместо вебхуков

Открытия, переходы, отписки и ошибки собираются в одну ленту. Спрашивайте её с курсором since: ответ отсортирован по времени, и его next_since — курсор для следующего вызова.

curl -G https://api.mailsharks.org/v1/events \
  -H "Authorization: Bearer ms_live_ВАШ_КЛЮЧ" \
  --data-urlencode "since=2026-08-20T09:00:00Z"

# Ответ:
# {"data": [{"at": "2026-08-20T09:04:11Z", "type": "opened",
#             "email": "client@example.com", "campaign_id": 42,
#             "message_id": null}],
#  "next_since": "2026-08-20T09:04:11Z"}
import time, httpx

api = httpx.Client(base_url="https://api.mailsharks.org/v1",
                   headers={"Authorization": "Bearer ms_live_ВАШ_КЛЮЧ"})
since = "2026-08-20T09:00:00Z"

while True:
    page = api.get("/events", params={"since": since}).json()
    for event in page["data"]:
        handle(event)          # ваша обработка
    since = page["next_since"]  # курсор на следующий вызов
    time.sleep(60)              # раз в минуту: чаще нет смысла
let since = "2026-08-20T09:00:00Z";

setInterval(async () => {
  const url = new URL("https://api.mailsharks.org/v1/events");
  url.searchParams.set("since", since);
  const page = await (await fetch(url, {
    headers: { "Authorization": "Bearer ms_live_ВАШ_КЛЮЧ" },
  })).json();
  page.data.forEach(handle);
  since = page.next_since;
}, 60_000);

Отписки

У одиночных писем нет таблицы, поэтому их отписки попадают в стоп-лист аккаунта и действуют на всю почту этого отправителя, включая рассылки. Стоп-лист читается и пополняется через /unsubscribes.

Футер отписки и заголовок List-Unsubscribe включены по умолчанию; для транзакционных писем их можно выключить полями unsubscribe_link и check_suppression. Выключать их в рассылках не стоит: у Gmail это прямой путь в спам, и страдает репутация вашего же домена.

Ошибки

Формат один на все ошибки — разбирайте code, а не текст:

{"error": {"code": "rate_limited", "message": "Не больше 100 запросов в минуту."}}
HTTPcodeКогда
400invalid_request, invalid_email, missing_variables, empty_bodyЗапрос разобран, но им нельзя воспользоваться
401missing_key, invalid_keyКлюча нет или он отозван
404sheet_not_found, template_not_found, campaign_not_foundОбъекта нет или он принадлежит другому аккаунту
409google_not_connected, unsubscribed, not_running, file_errorЗапрос верный, но состояние аккаунта не позволяет его выполнить
429rate_limited, daily_limit_reachedИсчерпан лимит запросов в минуту или дневной лимит почты
502gmail_errorGmail отказался принять письмо; текст ошибки — в message
MailSharks