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
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 | /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();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();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 | 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 |