MailSharks API — то же самое, что делает приложение, но из вашей программы: отправить письмо, подключить таблицу, завести рассылку, забрать статистику. Всё поверх обычного HTTPS и JSON, без SDK.
Базовый адрес: https://api.mailsharks.org/v1
Пишете не программу, а запрос к ИИ? Тогда вам нужен не HTTP-API, а MCP-сервер MailSharks: Claude, ChatGPT и любой другой клиент с поддержкой MCP управляют рассылками сами, без единой строки кода.
GET /v1/account это видно по полю
shared_with_app.Ключ передаётся заголовком в каждом запросе:
Authorization: Bearer ms_live_ВАШ_КЛЮЧ
Ключ даёт полный доступ к вашей почте для API. Держите его на сервере: из браузера и мобильного приложения его видно всем, кто откроет исходники.
X-RateLimit-Remaining и
X-RateLimit-Reset, так что узнавать о лимите по ошибке 429 не обязательно.| Метод | Путь | Что делает |
|---|---|---|
| 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}}. Имена
нечувствительны к регистру. Значения берутся, по возрастанию важности, из:
variables шаблона — значения по умолчанию;variables запроса — общие для всей отправки;{{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();Поле 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 запросов в минуту."}}
| HTTP | code | Когда |
|---|---|---|
400 | invalid_request, invalid_email, missing_variables, empty_body | Запрос разобран, но им нельзя воспользоваться |
401 | missing_key, invalid_key | Ключа нет или он отозван |
404 | sheet_not_found, template_not_found, campaign_not_found | Объекта нет или он принадлежит другому аккаунту |
409 | google_not_connected, unsubscribed, not_running, file_error | Запрос верный, но состояние аккаунта не позволяет его выполнить |
429 | rate_limited, daily_limit_reached | Исчерпан лимит запросов в минуту или дневной лимит почты |
502 | gmail_error | Gmail отказался принять письмо; текст ошибки — в message |