API reference

Sending, spreadsheets, templates and statistics over HTTP

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

Getting started

  1. Open the app → API.
  2. Connect the API mailbox. It is a separate Google account with its own daily cap, so an integration never eats into the quota you use by hand. The same mailbox cannot be connected twice — neither into both slots nor into two Telegram accounts.
  3. Issue a key. It is shown once: only its SHA-256 is stored, so the key cannot be looked up later — only reissued.

Authentication

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.

Limits

Endpoints

MethodPathWhat it does
GET/accountMailbox, daily cap and what is left today
POST/messagesSend a single message
GET/messagesHistory of single sends
GET/messages/{id}One message with its status
GET/sheets/inspectTabs and header row of a spreadsheet
POST/sheetsConnect a spreadsheet
GET/sheetsConnected spreadsheets
DELETE/sheets/{id}Disconnect a spreadsheet
GET/templatesMessage templates
POST/templatesCreate a template
PATCH/templates/{id}Update a template
POST/templates/{id}/renderRender a template without sending
POST/campaignsCampaign over a sheet — starts immediately
GET/campaigns/{id}Campaign status and statistics
POST/campaigns/{id}/pausePause
POST/campaigns/{id}/resumeResume
GET/campaigns/{id}/recipientsRecipients, paginated
GET/eventsEverything that happened since a given moment
GET/unsubscribesAccount suppression list
POST/unsubscribesAdd an address to the suppression list

Send a message

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

Variables

Both the subject and the body support {{name}} placeholders. Names are case-insensitive. Values are taken, in increasing order of precedence, from:

  1. the template's variables — defaults;
  2. the request's variables — shared across the whole send;
  3. spreadsheet columns, for campaigns: {{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.

Campaigns over a sheet

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

Events instead of webhooks

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

Unsubscribes

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.

Errors

Every error uses one shape — branch on code, never on the text:

{"error": {"code": "rate_limited", "message": "No more than 100 requests per minute."}}
HTTPcodeWhen
400invalid_request, invalid_email, missing_variables, empty_bodyThe request parsed, but cannot be used
401missing_key, invalid_keyNo key, or the key was revoked
404sheet_not_found, template_not_found, campaign_not_foundNo such object, or it belongs to another account
409google_not_connected, unsubscribed, not_runningValid request, but the account state does not allow it
429rate_limited, daily_limit_reachedPer-minute request cap or daily mailbox cap is used up
502gmail_errorGmail refused the message; its text is in message
MailSharks