The MailSharks API does what the app does, but from your own code: send a message, connect a spreadsheet, start a campaign, pull statistics. Plain HTTPS and JSON, no SDK required.
Base URL: https://api.mailsharks.org/v1
Writing a prompt rather than a program? Then you want the MailSharks MCP server instead of this HTTP API: Claude, ChatGPT and any other MCP client run campaigns themselves, with no code.
Send the key as a header on every request:
Authorization: Bearer ms_live_YOUR_KEY
The key grants full access to your API mailbox. Keep it on a server: in a browser or a mobile app it is visible to anyone who opens the sources.
X-RateLimit-Remaining and
X-RateLimit-Reset, so you never have to learn about the cap from a 429.| Method | Path | What it does |
|---|---|---|
| GET | /account | Mailbox, daily cap and what is left today |
| POST | /messages | Send a single message |
| GET | /messages | History of single sends |
| GET | /messages/{id} | One message with its status |
| GET | /replies | Recipient replies to API messages |
| GET | /messages/{id}/replies | Replies to one message |
| POST | /files | Upload a file to Drive to attach it to a message |
| GET | /sheets/inspect | Tabs and header row of a spreadsheet |
| POST | /sheets | Connect a spreadsheet |
| GET | /sheets | Connected spreadsheets |
| DELETE | /sheets/{id} | Disconnect a spreadsheet |
| GET | /templates | Message templates |
| POST | /templates | Create a template |
| PATCH | /templates/{id} | Update a template |
| POST | /templates/{id}/render | Render a template without sending |
| POST | /campaigns | Campaign over a sheet — starts immediately |
| GET | /campaigns/{id} | Campaign status and statistics |
| POST | /campaigns/{id}/pause | Pause |
| POST | /campaigns/{id}/resume | Resume |
| GET | /campaigns/{id}/recipients | Recipients, paginated |
| GET | /events | Everything that happened since a given moment |
| GET | /unsubscribes | Account suppression list |
| POST | /unsubscribes | Add an address to the suppression list |
Single sends are synchronous: the response arrives once Gmail has accepted the
message. Idempotency-Key is optional, but with it a retry after a dropped
connection returns the original response instead of sending a second copy.
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();Files are attached the same way the app does it: the file goes to the sender's Google Drive, gets link access, and a card pointing at it is appended to the message. Nothing is attached in the postal sense, and nothing will be: a campaign with an attachment is the same file sent a thousand times, and mail servers cut that off.
Upload with POST /files, multipart/form-data, field
file. The cap is 25 MB. The response carries the file id; pass it in the
attach field of POST /messages and POST /campaigns.
A file uploaded once can be attached to any number of messages — there is no need to
upload it again for every send.
# 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 takes either an id from POST /files or a plain Drive
link — the id is parsed out of it. The link must point at a file uploaded with this same
key: the API has no access to the rest of your Drive, and anything else comes back as
409 file_error.
The Drive is the one the API sends from. With "use the main mailbox" switched on in the panel, files land on the main Drive — otherwise the recipient would open a link owned by a different account.
The shared field of the response says whether link access was granted.
It is normally true; false means your organisation's policy
refused it and the recipient will hit "request access" — visible before you send.
Cards are appended in the order given and, with track_clicks on, are
counted like any other link. In an A/B test both variants carry the file.
Both the subject and the body support {{name}} placeholders. Names are
case-insensitive. Values are taken, in increasing order of precedence, from:
variables — defaults;variables — shared across the whole send;{{city}} comes from the
city column and differs per recipient.If a placeholder has nothing behind it — no variable and no column — the request is
rejected with 400 missing_variables. The check runs once, at creation time:
an empty cell in a single row renders as empty and does not break the campaign.
To see the result without sending anything, use
POST /templates/{id}/render.
Templates are created in the app: API → Templates. Each gets a short
number like a7Kd93Xz — copy it there and pass it as template_id.
Templates from the ordinary "Templates" section do not work with this key: they belong
to the other mailbox, with its own cap and its own spreadsheets.
The spreadsheet must be shared with the API mailbox — it is a separate account and does not inherit access granted to your main one. A campaign starts as soon as it is created, so every check — mailbox, quota, sheet access, variable coverage — happens before the first message goes out.
# 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();Instead of sheet_id you may pass sheet_ids, a list of
sheet numbers. The lists are merged into one campaign and each address is mailed
once: duplicates are dropped automatically.
When an address appears twice, the row from the sheet listed earlier wins — its name and its placeholders. Order matters: put the sheet with better data first.
A placeholder must exist in every sheet of the list. If
{{city}} is in one sheet and missing from another, the campaign
is refused: otherwise part of the mail would go out with a blank where the city
should be. The same holds for {{name}} — every sheet needs a
name column.
Suppression lists add up: an address that unsubscribed in any of the sheets gets nothing. An unsubscribe from such a campaign is recorded against the sheet the person came from, not the first one in the list.
The response adds skipped_duplicates — how many rows collapsed.
The sheets field lists the sheets of any campaign, including in
GET /v1/campaigns: a single-sheet campaign simply has one number
there, so there is no second case to handle. Without the former, "fewer
addresses than rows" would read as lost mail.
At most ten sheets per campaign: each one is a round trip to Google.
The variant_b field of POST /campaigns adds a second
version of the message: a required subject and an optional
body_html (leave it out and only the subject is tested). Addresses are
split into two equal halves at random when the campaign is created, so pauses and
daily limits never skew the halves. Requires the Pro plan and at least two addresses
outside the suppression list.
For such a campaign GET /campaigns/{id} returns an ab
block: per-variant statistics (sent, opened,
clicked, replied) and a verdict — open rates,
the winner, the confidence (two-sided z-test of proportions) and
significant, which turns true at 95%. Recipients in
GET /campaigns/{id}/recipients gain a variant field.
# Половине списка уйдёт тема 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);Opens, clicks, unsubscribes and failures come back as one feed. Poll it with a
since cursor: the response is ordered by time, and its
next_since is the cursor for the next call.
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);Single messages have no spreadsheet behind them, so their unsubscribes go to the
account suppression list and apply to everything that mailbox sends,
campaigns included. Read and extend it through /unsubscribes.
The unsubscribe footer and the List-Unsubscribe header are on by default;
for transactional mail you can turn them off with unsubscribe_link and
check_suppression. Turning them off for bulk mail is a bad trade: with Gmail
that is a direct route to the spam folder, and it is your own domain's reputation.
Every error uses one shape — branch on code, never on the text:
{"error": {"code": "rate_limited", "message": "No more than 100 requests per minute."}}
| HTTP | code | When |
|---|---|---|
400 | invalid_request, invalid_email, missing_variables, empty_body | The request parsed, but cannot be used |
401 | missing_key, invalid_key | No key, or the key was revoked |
404 | sheet_not_found, template_not_found, campaign_not_found | No such object, or it belongs to another account |
409 | google_not_connected, unsubscribed, not_running, file_error | Valid request, but the account state does not allow it |
429 | rate_limited, daily_limit_reached | Per-minute request cap or daily mailbox cap is used up |
502 | gmail_error | Gmail refused the message; its text is in message |