Mailcheer

Отправка транзакционных писем и новостных рассылок от агента: подписчики, сегменты, список подавления, отправка и статистика — на одном API-ключе. Работает на Amazon SES, хостинг в Европе.

Размещённый MCP-сервер

npx add-mcp 'https://mailcheer.com/api/mcp'

Устанавливается в Claude Code, Codex, Cursor и другие

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

Email API and MCP server

The Mailcheer REST API and MCP server: send transactional emails, manage subscribers, create and send campaigns, all from your application or AI agent.

Mailcheer is controllable from the outside: from your application, from a script, or from an agent like Claude Code, Codex or Cursor. Two entry points, one key.

For whomAddress
REST APICode — any language that can make an HTTP request.https://mailcheer.com/api/v1
MCP serverAI agents, which discover available tools on their own.https://mailcheer.com/api/mcp

Your first key

In your Mailcheer workspace: Account → API & AI agents → New key. Give it a name and check what it is allowed to do.

The full key is shown once only. We keep only a fingerprint: if you lose it, no one can recover it for you — create a new one and revoke the old one. This is the price of ensuring that a stolen copy of our database yields no usable key, and it is the right price.

Store it like a password: in your service's environment variables, never in shared code or a public page.

Two kinds of key: a live key (mch_live_…) sends for real; a test key (mch_test_…) checks everything and sends nothing — see Test mode.

Key permissions

PermissionWhat it unlocks
emails:sendSend transactional emails and read their status.
subscribers:readRead subscribers and the suppression list.
subscribers:writeAdd, update and unsubscribe subscribers.
campaigns:readRead campaigns and their statistics.
campaigns:writeCreate and send campaigns, delete a draft.
webhooks:readRead event subscriptions and their log.
webhooks:writeCreate, edit and delete event subscriptions.

Only check what you need. A call outside the key's scope returns 403, and nothing bypasses it — it is the only guard that holds against an autonomous agent: you do not count on its caution, you take away the button.

Permissions are chosen at creation and never change. A key whose scope can be expanded after the fact means nothing: the person who received it believes they hold read-only access and ends up with send rights, without being told.

A key's monthly limit and language

Two settings of a key can change after it is created — Settings → API, Adjust — because neither gives more power to whoever holds it.

The monthly limit (optional): "this key cannot send more than N emails a month". It counts the key's emails and the campaigns it launches (POST /api/v1/campaigns/{id}/send), every address including copies, what is still queued included, over the UTC month — the month of the quota. It comes on top of the workspace quota, never instead of it: it keeps one use from eating another's room. A CRM that sends its sign-in codes and its newsletter from the same workspace puts a limit on the newsletter key, and the codes always have room.

Beyond it, the send is refused whole before anything leaves — 402 key_quota_exceeded, distinct from the workspace's quota_exceeded: another key of the workspace can still send.

{
  "error": {
    "code": "key_quota_exceeded",
    "message": "Key limit reached: the key “Newsletter” cannot send more than 2,400 emails a month. …",
    "details": {
      "key_id": "cmu8k1a2b00001n7nkey0news",
      "key_name": "Newsletter",
      "limit": 2400,
      "used": 2380,
      "remaining": 20,
      "requested": 551,
      "resets_at": "2026-10-01T00:00:00.000Z"
    }
  },
  "statusCode": 402,
  "message": "…",
  "name": "key_quota_exceeded"
}

A campaign's dry_run announces it the same way (would_send: false, blocked_code: "key_quota_exceeded"). A key with a limit also carries three headers on every response, next to the workspace's Mailcheer-Quota-*:

HeaderValue
Mailcheer-Key-Quota-LimitThe key's monthly limit.
Mailcheer-Key-Quota-UsedWhat the key sent this month, plus what it still has queued.
Mailcheer-Key-Quota-RemainingWhat it can still send; never negative.

The key's counter resets with the workspace's (Mailcheer-Quota-Reset), and GET /api/v1/me returns it in key.monthly_limit. A test key has no limit: it sends nothing.

The response language: English (default) or French — the language of the messages when a call asks for none. See The language of responses.

Send an email

The most common entry point: the invoice, the alert, the password reset — everything your application writes to one person at a time.

curl -X POST https://mailcheer.com/api/v1/emails \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-2026-0412" \
  -d '{
    "from": "Your brand <hello@yourdomain.com>",
    "to": "customer@example.com",
    "subject": "Your September invoice",
    "html": "<p>Here it is.</p>"
  }'

The response comes back as 202:

{
  "id": "cmu651xf200021n7nm68sikfw",
  "object": "email",
  "from": "hello@yourdomain.com",
  "to": ["customer@example.com"],
  "subject": "Your September invoice",
  "created_at": "2026-09-18T07:12:44.102Z"
}

202, not 200: our sending provider has accepted the message; it is not yet in an inbox. Delivery is confirmed a few seconds later:

curl https://mailcheer.com/api/v1/emails/cmu651xf200021n7nm68sikfw \
  -H "Authorization: Bearer mch_live_…"

The status field moves from sent to delivered, or to bounced if the address does not exist, or to complained if the person marked the message as spam. In both of these last cases, the address is automatically added to the suppression list — your application does not need to handle that.

With several addresses, status follows the recipients in to: a copy (cc, bcc) that bounces does not make your recipient's email bounced. Each address has its own state in recipients: email, type (to, cc or bcc), status (sent until Amazon reports on it, then delivered, bounced or complained; queued or failed while the email itself is) and reason (the bounce type Amazon reports: Permanent, Transient or Undetermined, null otherwise). Webhooks follow the same rule: every email.* event names ONE address in email, and says in recipient_type whether it is a recipient (to) or a copy (cc, bcc). Opens stay on the email (opened_at): the tracking pixel is the same in every copy, so it cannot tell who opened.

One-click unsubscribe

If you write to people who did not write to you first — a newsletter, an alert someone subscribed to, a status page — Gmail and Yahoo expect an unsubscribe link in the message headers, not just at the foot of the page. They have required it since February 2024.

Pass the address in unsubscribe_url, and Mailcheer sets both headers, which always go together:

curl -X POST https://mailcheer.com/api/v1/emails \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Your brand <alerts@yourdomain.com>",
    "to": "customer@example.com",
    "subject": "Your monthly report",
    "html": "<p>Here it is.</p>",
    "unsubscribe_url": "https://yourdomain.com/unsubscribe/abc123"
  }'

Your endpoint must accept a POST and unsubscribe without asking for confirmation — that is what one-click means. A GET on the same address may lead to a readable page, for mail clients that do either.

Without this header, the only way out you offer is the Spam button — and it is your sending domain's reputation that pays for it, not the message's.

⚠️ List-Unsubscribe is still rejected inside headers: Mailcheer writes it, you only provide the target. That guarantees List-Unsubscribe-Post always comes with it — without that second header, Gmail shows no button.

unsubscribe_url also says what kind of send it is. With it, the email is a newsletter or prospecting: an address that unsubscribed from your workspace is refused (422 suppressed_recipient). Without it, the email is individual — a reply, an invoice, an appointment — and an unsubscribe does not stop it. A bounce, a complaint or a manual suppression stops both.

Attachments

A quote, an invoice, a brochure: pass them in attachments, in Resend's format — filename and content, the file encoded in base64.

curl -X POST https://mailcheer.com/api/v1/emails \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Your brand <hello@yourdomain.com>",
    "to": "client@example.com",
    "subject": "Your quote",
    "text": "The quote is attached.",
    "attachments": [
      {
        "filename": "quote-2026-09.pdf",
        "content": "JVBERi0xLjQKJcfsj6IK…",
        "content_type": "application/pdf"
      }
    ]
  }'

In Node, the content takes one line:

import { readFileSync } from "node:fs";

const quote = {
  filename: "quote-2026-09.pdf",
  content: readFileSync("./quote-2026-09.pdf").toString("base64"),
};

content_type is optional: it is inferred from the extension (.pdf → application/pdf), falling back to application/octet-stream. Twenty attachments per message at most.

The limit is our sending provider's: 40 MB per message once encoded, which is roughly 30 MB of actual files — base64 adds a third. Beyond that the call is rejected with a 422, naming the file, its size and the size reached. That is deliberate: a refusal that explains beats a message that leaves without its attachment.

Rejected the same way, and always out loud:

  • extensions inbox providers reject — .exe, .bat, .js, .vbs, .scr … Put the file in a .zip, or send a download link;
  • a content that is not valid base64;
  • a path field pointing at a URL to fetch: our server does not follow an address you choose. Encode the file.

When a message carries an attachment it goes out as a full MIME message rather than a simple one. Everything else is unchanged: cc, bcc, reply_to, your headers, one-click unsubscribe and your tags all behave identically — and bcc still appears in no header of the received message.

Inline images

A logo or a product photo that must show inside the message, not as a download: give the attachment a content_id, and reference it in the HTML with cid:.

{
  "html": "<p><img src=\"cid:logo\" alt=\"Your brand\" width=\"120\"></p><p>Thank you for your order.</p>",
  "attachments": [
    { "filename": "logo.png", "content": "iVBORw0KGgo…", "content_type": "image/png", "content_id": "logo" }
  ]
}

The attachment goes out with a Content-ID header, next to the HTML, and inbox providers show it in place. content_id (or contentId) takes letters, digits, ., _, - and @, no spaces; two attachments cannot share one. The same limits as other attachments apply: it is a file of the message like any other.

Copies

cc and bcc accept one address or an array of 1 to 50, just like to — but 50 addresses at most in all, to, cc and bcc together: that is our sending provider's limit per message. Beyond it, 422 validation_error and nothing is sent; split into several calls, or use a campaign. Both count against your quota and go through the same checks — an address the suppression list refuses rejects the whole call, whether it is a recipient or a copy.

Open tracking

An HTML email carries an invisible one-pixel image that counts opens. Without track_opens in the request, your workspace's Track opens setting decides (Workspace screen of the app); track_opens: true or false overrides it for that email. A plain-text email carries no pixel. GET /api/v1/emails/{id} returns track_opens: when it is false, opened_at stays null because opens are not tracked, not because nobody opened.

In France, the CNIL's recommendation of 14 April 2026 makes measuring opens with a pixel, to track campaign performance, subject to the recipient's prior consent. A login code or a password reset has no reason to be tracked: send track_opens: false.

The five rules of every send

These cannot be bypassed, and they are the same as for a campaign sent through the interface.

1. from must be on a verified domain in your workspace. Otherwise 422 unverified_from_domain, with a list of your verified domains in the message. GET /api/v1/me also returns them.

2. An address on the suppression list is refused, with its reason — dead address, complaint, removed by hand, and unsubscription when the send carries unsubscribe_url. An individual email, without unsubscribe_url, goes to an unsubscribed address: leaving your newsletter is not refusing the answer to one's own request. The entire call fails, including other recipients: a partial send you do not know about is the worst possible outcome, because you would think you had notified everyone.

3. Your plan's monthly quota counts these sends the same as campaigns — and what is already waiting in the queue. It is the same send count, the same invoice. A send that does not fit returns 402 quota_exceeded (see below). On the free plan, a sending domain's free emails serve one workspace a month: elsewhere, it is 402 free_plan_domain_used.

4. A bounce or complaint rate that is too high suspends sending. The thresholds are Amazon's: 5% bounces, 0.1% complaints. An application writing to invented addresses causes the same damage as a campaign on a purchased list.

5. Nothing bypasses double opt-in. A subscriber added via the API receives a confirmation, unless double_opt_in: false is explicit — and then you bear responsibility for the consent. The same address gets at most one confirmation every 2 minutes and 3 per 24 hours, all channels combined (form, API, MCP): a new call updates the record without sending another email, and the response says why (confirmation_sent: false, confirmation_not_sent_reason, confirmation_retry_at). Beyond that, every reminder is a potential complaint against your domain.

Test mode

Create a test key: Settings → API → New key, Test key switched on. It starts with mch_test_. With it, every send is checked exactly like a real one — a verified from, the recipients, the suppression list, the workspace's sending status and daily limit, your monthly quota, the free plan's domain rule, Idempotency-Key — and gets the same response, refusals included. But nothing goes out, and your quota is not touched: the Mailcheer-Quota-* headers show the real figures, unchanged.

curl -X POST https://mailcheer.com/api/v1/emails \
  -H "Authorization: Bearer mch_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Your brand <hello@yourdomain.com>",
    "to": "bounced@simulator.mailcheer.com",
    "subject": "Your September invoice",
    "html": "<p>Here it is.</p>"
  }'

Same 202, same body. Every authenticated response says which kind of key answered — Mailcheer-Mode: test or Mailcheer-Mode: live — and GET /api/v1/me returns it in key.mode. Check it once when your service starts: a test key deployed to production by mistake believes it sends, and nothing goes out.

What happens next

Two seconds later the email gets its events, as a real one would: GET /api/v1/emails/{id} moves from sent to its outcome, address by address, and your webhooks receive the events — real calls, signed, retried — marked "test": true next to type (see Events in test mode).

Any address behaves as delivered. For the other outcomes, write to:

AddressEvents
delivered@simulator.mailcheer.comemail.delivered
bounced@simulator.mailcheer.comemail.bounced, with reason: "Permanent"
complained@simulator.mailcheer.comemail.delivered, then email.complained

A label can follow a + to tell your tests apart: bounced+signup@simulator.mailcheer.com. These addresses only work with a test key: a live key is refused with a 422, since a real email would bounce there.

A simulated bounce or complaint does not add the address to your suppression list: test mode changes nothing real.

What a test key cannot do

A test key has a single permission, emails:send, and it is simulated. It neither reads nor changes any real data in the workspace — subscribers, campaigns, suppression list, webhooks: those calls return 403 insufficient_scope, with details.mode: "test". A test key ends up in repositories, CI settings and shared examples: it must not be able to leak anyone's address, nor slip a test subscriber into a real campaign. Campaigns have their own way of trying without sending: dry_run and POST /api/v1/audience.

With a test key, GET /api/v1/emails/{id} only finds test emails; a live key never sees them. Test emails are kept 30 days.

Two differences with a real send, both deliberate:

  • the content safety review that a new workspace's first sends go through does not run: nothing leaves, so there is nothing to protect;
  • at most 1,000 test addresses per 24 hours per workspace, to, cc and bcc included. Beyond that: 429 rate_limit_exceeded, with Retry-After.

The MCP server follows the key: with a test key, send_email is checked and simulated, and get_account returns mode: "test".

Where your workspace stands

GET /api/v1/me is the first call to make, and the right reflex before an important send: it says who the key belongs to, which addresses to write from — and whether the send will fit. No particular permission is required.

curl https://mailcheer.com/api/v1/me -H "Authorization: Bearer mch_live_…"
{
  "object": "account",
  "organization": { "id": "org_3f9", "name": "Your brand", "slug": "your-brand" },
  "key": {
    "id": "cmu8k1a2b00001n7nkey0prod",
    "name": "Production",
    "scopes": ["emails:send", "subscribers:read"],
    "mode": "live",
    "language": "en",
    "monthly_limit": null
  },
  "plan": { "id": "free", "name": "Découverte", "emails_per_month": 3000 },
  "usage": {
    "period": "2026-09",
    "emails_sent": 2410,
    "emails_in_flight": 120,
    "emails_remaining": 470,
    "resets_at": "2026-10-01T00:00:00.000Z"
  },
  "billing": {
    "status": "none",
    "subscribed_plan": null,
    "current_period_end": null,
    "cancel_at_period_end": false,
    "trial_ends_at": null,
    "scheduled_change": null,
    "manage_url": "https://mailcheer.com/reglages/facturation"
  },
  "limits": {
    "members": { "used": 1, "pending_invitations": 0, "max": 1 },
    "sending_domains": { "used": 1, "max": 1 },
    "daily": null
  },
  "subscribers": 551,
  "sending_domains": [{ "domain": "yourdomain.com", "verified": true }],
  "senders": [{ "id": "snd_71a", "from": "hello@yourdomain.com", "name": "Your brand", "default": true }]
}
  • key — the key that made the call: its scopes, its mode (live, or test for a test key), its language (the language of its messages when a call asks for none) and its monthly_limit — { "limit", "used", "remaining", "resets_at" }, or null without a limit.
  • usage — emails_sent: what went out this month, all channels together. emails_in_flight: what is waiting in the queue (a campaign in progress, reserved automation sends) — already promised. emails_remaining: what can still be sent, queue deducted, never negative (null on an unlimited plan). resets_at: when the counter resets, the 1st of next month at 00:00 UTC.
  • billing — status is none without a paid subscription; otherwise the payment status: trialing (the free week of a first move to a paid plan: the plan applies, nothing is charged yet; trial_ends_at says when the first charge falls), active, past_due (a charge failed, the plan stays open while it retries), unpaid (retries abandoned: the workspace runs on the Discovery limits), canceled … subscribed_plan names the plan being billed — it can differ from plan.id after a failed payment. scheduled_change announces a scheduled downgrade or cancellation ({ "plan": "free", "effective_at": "…" }). manage_url is the screen where the owner or an administrator changes plan.
  • limits — members (a pending invitation takes a seat), sending domains, and daily: a new workspace's limit over a rolling 24 hours (100 emails for the first three days, 500 until the seventh), null when it does not apply.

Software connected to Mailcheer — a CRM that sends for its users, for example — can then show "470 emails left until October 1" instead of discovering the refusal. The workspace owner and administrators receive an email at 50%, 80% and 95% of the quota, once a month each — no email at 100%: the refusal says it.

The quota in every response

No need to call GET /api/v1/me before each send: every authenticated response from the API — success or error, 402 included — and from the MCP server carries the state of this month's quota.

HeaderValue
Mailcheer-Quota-LimitEmails a month on your plan, or unlimited.
Mailcheer-Quota-UsedSent this month plus what is waiting in the queue (emails_sent + emails_in_flight).
Mailcheer-Quota-RemainingWhat can still be sent, queue deducted, never negative — or unlimited.
Mailcheer-Quota-ResetWhen the counter resets, in ISO 8601: the 1st of next month, 00:00 UTC.
curl -i https://mailcheer.com/api/v1/emails -H "Authorization: Bearer mch_live_…" …
# HTTP/1.1 202 Accepted
# Mailcheer-Quota-Limit: 3000
# Mailcheer-Quota-Used: 2531
# Mailcheer-Quota-Remaining: 469
# Mailcheer-Quota-Reset: 2026-10-01T00:00:00.000Z

The response to an accepted send already counts that send. A response without a valid key (401) carries none: it does not know your workspace. If the quota cannot be read at that moment, the response still goes out, without these headers.

A key with its own monthly limit also carries Mailcheer-Key-Quota-Limit, -Used and -Remaining — see A key's monthly limit and language.

Getting notified: the quota.threshold_reached webhook

Subscribe an address to the quota.threshold_reached event (POST /api/v1/webhooks, or Settings → API): Mailcheer calls it when this month's quota reaches 50, 80, 95 and 100%, once a month per threshold, at the moment the email that crosses the threshold is accepted. If a single send crosses several, only the highest is sent. The body is signed and retried like every event (see Webhooks):

{
  "id": "evt_3kT9xQ2mV7aB1cD4",
  "type": "quota.threshold_reached",
  "created_at": "2026-09-24T16:02:11.000Z",
  "data": {
    "threshold": 80,
    "plan": "free",
    "quota": 3000,
    "sent": 2400,
    "in_flight": 35,
    "remaining": 565,
    "resets_at": "2026-10-01T00:00:00.000Z",
    "period": "2026-09"
  }
}

threshold is the threshold reached, in percent; the other fields mean what they mean in the details of a 402 refusal. At 100%, only the webhook fires (no email): from then on, sends return 402 quota_exceeded until resets_at.

When the quota does not cover a send

A send that does not fit in what is left this month is refused whole, before anything leaves: nothing is sent, nothing is queued. The response is a 402 with code quota_exceeded, on POST /api/v1/emails as on POST /api/v1/campaigns/CAMP_ID/send, and the MCP tool making the same move returns the same error:

{
  "error": {
    "code": "quota_exceeded",
    "message": "…",
    "details": {
      "plan": "free",
      "quota": 3000,
      "sent": 2940,
      "in_flight": 20,
      "remaining": 40,
      "requested": 250,
      "resets_at": "2026-10-01T00:00:00.000Z"
    }
  },
  "statusCode": 402,
  "message": "…",
  "name": "quota_exceeded"
}

remaining is quota − sent − in_flight; requested is what the call asked for (recipients, copies included). Two ways forward: change plan (billing.manage_url), or wait for resets_at. Retrying the same call before either will get the same refusal.

The free plan: one domain, one workspace per month

On the free plan, the month's 3,000 emails are tied to the sending domain, not to the account. A registered domain (acme.com, subdomains included) serves the free plan to a single workspace per UTC month: the first one that sends with it. Another free workspace sending from that domain, or one of its subdomains, in the same month gets a different 402, refused whole as well:

{
  "error": {
    "code": "free_plan_domain_used",
    "message": "The free plan is per sending domain, not per account: this domain (news.acme.com, part of acme.com) has already used it this month in another workspace. Upgrade to a paid plan to send from several workspaces, or wait until October 1 at 00:00 UTC.",
    "details": {
      "domain": "news.acme.com",
      "root_domain": "acme.com",
      "period": "2026-09",
      "resets_at": "2026-10-01T00:00:00.000Z"
    }
  },
  "statusCode": 402,
  "message": "…",
  "name": "free_plan_domain_used"
}

Tell the two 402 s apart by error.code. Upgrading to a paid plan lifts this one immediately; paid plans are not affected.

Never send twice

An HTTP library that did not receive our response will replay the call. That is its job, and without a precaution your customer receives the same invoice twice.

Add the Idempotency-Key header with a unique value per send — the invoice number, the order ID, a UUID:

Idempotency-Key: invoice-2026-0412

Replaying a call that succeeded returns the same response, with the same id, without a second send. The Idempotent-Replay: true header tells you it was a replay. A key is kept for 24 hours after the first call; after that, the same call is a new send.

A refusal is not kept. A 402 (quota), 423 (safety review), 429 (daily limit) or 422 (invalid field) sent nothing: once the cause is lifted, send the same call with the same key, and it goes out. That is what your retry logic should do, and it needs no new key.

Two identical calls at the same moment send once: the second waits for the first and returns its response. If the first is still running after a few seconds, the second gets 409 idempotency_key_in_use with Retry-After: send it again, unchanged. The same key with a different body returns 409 idempotency_key_reused: that is not a retry, it is an error on your side, and returning the other send's response would be worse than saying so.

Subscribers

# Add — the person receives a confirmation and enters "pending"
curl -X POST https://mailcheer.com/api/v1/subscribers \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{"email":"marie@example.com","firstName":"Marie","tags":["customers"]}'

# List, page by page
curl "https://mailcheer.com/api/v1/subscribers?limit=50&status=subscribed" \
  -H "Authorization: Bearer mch_live_…"

# Unsubscribe
curl -X DELETE https://mailcheer.com/api/v1/subscribers/marie%40example.com \
  -H "Authorization: Bearer mch_live_…"

DELETE does not erase the record: the person moves to unsubscribed and their address enters the suppression list. Deleting the record would let them reappear at the next file import — you would have respected the HTTP verb and betrayed the person.

Once unsubscribed, the person no longer receives your campaigns, your automations, or API sends that carry unsubscribe_url. Your individual emails — a reply, an invoice — still reach them.

An unsubscribed address cannot re-subscribe via the API. Only the person can return, through a form. An unsubscription that a program can undo is worth nothing.

Pagination

Lists return { data, has_more, next_cursor }. Pass next_cursor as ?cursor= for the next page.

No page number, by design: on a list where writes happen at the same time as reads — which is exactly the case for an API — page=2 skips rows and shows others twice. A cursor does not move.

Only what changed: updated_since

To keep a copy of your list up to date without reading it all again, pass updated_since (ISO 8601): you only get the records created or changed since then — status (confirmation, unsubscribe, bounce), name, custom fields, tags.

curl "https://mailcheer.com/api/v1/subscribers?updated_since=2026-09-25T08:00:00Z" \
  -H "Authorization: Bearer mch_live_…"

Every record carries updated_at. Keep the largest one you received and, next time, pass it minus one minute: it comes from our clock, not yours, and the minute gives a second chance to a change being written at the very moment of your read. Records seen twice are simply updates.

POST /api/v1/subscribers also sets tags, but it is a subscription: on a person still pending, it sends the confirmation email again. To change tags, and only tags, use these calls — no subscription, no email, no status touched:

# One subscriber: add and remove in one call
curl -X POST https://mailcheer.com/api/v1/subscribers/marie%40example.com/tags \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "add": ["customer"], "remove": ["lead"] }'

# One tag, many subscribers: up to 500 addresses in each list
curl -X POST https://mailcheer.com/api/v1/tags/customer/subscribers \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "add": ["marie@example.com", "paul@example.com"], "remove": ["old@example.com"] }'

The first answers with the record and what really changed (added, removed): adding a tag already there, or removing one that is absent, is not an error — it is where a typo shows. The record must exist: an unknown address answers 404 and nothing is created. The second answers with counts (added, removed, unchanged) and the addresses that are not subscribers of the workspace (not_found), never created either. A new tag is created when add names it.

A tag is designated by its id or by its name, URL-encoded in the path (/api/v1/tags/VIP%20customers).

CallWhat it does
GET /api/v1/tagsEvery tag, with subscribers (how many carry it) and subscribed (how many of those are active).
PATCH /api/v1/tags/{tag}Renames it: { "name": "…" }. Subscribers keep it under its new name; a name already taken returns 409.
DELETE /api/v1/tags/{tag}Deletes it. Subscribers stay, only the tag is taken off them. Refused with 409 while an automation, a segment or a signup form uses it — details.used_by names them: without the tag they would carry on silently, reaching nobody.

Adding in batches

POST /api/v1/subscribers/batch adds or updates up to 500 subscribers in one call. Each entry goes through exactly the same path as POST /api/v1/subscribers — double opt-in by default, the confirmation guard, the suppression list, unsubscribed people who are not brought back.

curl -X POST https://mailcheer.com/api/v1/subscribers/batch \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "subscribers": [
        { "email": "marie@example.com", "firstName": "Marie", "tags": ["customers"] },
        { "email": "paul@example.com", "tags": ["customers"] }
      ] }'

A refused line does not fail the others. The response gives one line per entry, in order: created or updated with the fields of the single add (subscriber, confirmation_sent …), or rejected with the error the single add would have returned — code, message, details. A summary counts the three. The same address twice in a batch: the second line is rejected.

A batch of N counts as N requests against the rate limit: it saves round trips, it does not raise the limit. A batch that does not fit in the minute is refused whole, consuming nothing (429, Retry-After).

Erasing a person (GDPR)

When a person asks for their data to be erased — not just to stop receiving emails — use:

curl -X POST https://mailcheer.com/api/v1/subscribers/marie%40example.com/erase \
  -H "Authorization: Bearer mch_live_…"
# → { "object": "subscriber", "email": "marie@example.com", "erased": true, "suppressed": false }

Irreversible. The record, the name, the custom fields, the proof of consent and the tags are deleted. What was promised to the person and not sent will not be sent; their automation journeys stop. The statistics of campaigns already sent do not move: each message stays counted, with nothing left that links it to them.

It is a route of its own, not a flag on DELETE: that DELETE unsubscribes and keeps the record, and a mistyped flag would silently do the other, irreversible, thing. The suppression list is not touched — an address on it stays there (suppressed: true), which is what guarantees nothing is sent to it again. Removing an address from that list is done by hand, in your workspace.

Campaigns

Creating and sending are two separate actions. This is not bureaucracy: it is what lets you proofread a letter before it goes to three thousand people.

# 1. The draft — nothing is sent
curl -X POST https://mailcheer.com/api/v1/campaigns \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "September newsletter",
    "subject": "What we learned this summer",
    "text": "# Hello\n\nHere is this month'\''s news."
  }'

# 2. Send — irreversible
curl -X POST https://mailcheer.com/api/v1/campaigns/CAMP_ID/send \
  -H "Authorization: Bearer mch_live_…"

# 3. Track
curl https://mailcheer.com/api/v1/campaigns/CAMP_ID \
  -H "Authorization: Bearer mch_live_…"

In text, a blank line separates two paragraphs and # at the start of a line creates a heading. For full layout (images, buttons, dividers), pass content with the editor's blocks.

The send returns 202 with queued: recipients are locked, messages are then sent at the rate our provider allows. A send of fifty thousand emails does not fit in one HTTP request, and claiming otherwise would give you a "sent" for a job that is just beginning.

Open and click rates are calculated on delivered messages, never on the total number of recipients: a dead address must not pull down the rate of those who did receive it.

Opens only count on messages that carried the tracking pixel. When the workspace's Track opens setting was off for the whole send, opens_tracked is false and open_rate and human_open_rate are null — never a misleading 0%.

Every rate comes twice: open_rate and click_rate include bots, as most tools count them; human_open_rate and human_click_rate set them aside. A bot is an open or a click within two minutes of delivery (the security gateways of business mailboxes visit every link as the message arrives, privacy relays preload images), or one from a robot that declares itself in its user agent, or from an address range its operator publishes (Google, Bing). Privacy relays (Apple Mail, Gmail, Yahoo) are not bots in themselves. The rule is deliberately strict: a person who opens within the minute is counted as a bot — a slightly low rate rather than an inflated one.

Deleting a draft

curl -X DELETE https://mailcheer.com/api/v1/campaigns/CAMP_ID \
  -H "Authorization: Bearer mch_live_…"

Returns { "object": "campaign", "id": "…", "deleted": true }. Only a draft (draft) can be deleted, and it cannot be undone. A scheduled, sending, sent or archived campaign answers 409 conflict, with its status in details.status: what has gone out, or will, keeps its sends and its statistics.

Editing a draft

curl -X PATCH https://mailcheer.com/api/v1/campaigns/CAMP_ID \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "subject": "What we learned this summer (take 2)", "text": "# Hello\n\nThe new version." }'

The fields of the creation, all optional: name, subject, preheader, kind, from or sender_id, and the content — text or content, not both. New content replaces the old one; without preheader, it keeps the draft's. The response is the campaign, as GET returns it. Nothing is sent.

Only a draft can be edited: any other status answers 409 conflict with details.status, and nothing is changed. An unknown field is refused (422), unlike the creation: a misspelled subjet ignored in silence would let the old subject go to the whole list.

Writing to a group instead of the whole list

Without a body, the send goes to the campaign's whole audience: every active subscriber of the workspace (or of the segment chosen in the interface), minus the suppression list. To write to only part of it — people who haven't replied, your customers, the people who signed up for one workshop — pass their addresses in to:

curl -X POST https://mailcheer.com/api/v1/campaigns/CAMP_ID/send \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "to": ["claire@example.com", "marc@example.com", "former@example.com"] }'

to can only narrow. The letter goes to the requested addresses that are also active subscribers of the audience, and never to an address on the suppression list: someone who unsubscribed, bounced or complained receives nothing, even if their address is in to. The response says who is kept, who is excluded, and why:

{
  "id": "cmp_8d2", "object": "campaign", "status": "sending", "queued": 2,
  "audience": {
    "requested": 3, "duplicates": 0, "retained": 2, "excluded": 1,
    "reasons": { "invalid": 0, "not_in_list": 0, "pending": 0, "unsubscribed": 1,
                 "bounced": 0, "complained": 0, "suppressed": 0, "outside_segment": 0 },
    "excluded_addresses": [ { "email": "former@example.com", "reason": "unsubscribed" } ]
  },
  "note": "2 recipient(s) queued. …"
}

The count always adds up: requested = duplicates + retained + excluded. Case and surrounding spaces are ignored (Claire@Example.com is the same person). The reasons: invalid (not an address), not_in_list (no subscriber of the workspace has it), pending (sign-up not confirmed yet), unsubscribed, bounced, complained, suppressed (subscribed, but on the suppression list) and outside_segment (outside the segment the campaign targets).

Three rules worth knowing:

  • An empty list is not "no list". "to": [] writes to nobody: the send is refused (422). To write to the whole list, send no to field at all.
  • No to on a campaign with an A/B test. The winning version goes out hours later, computed on the campaign's whole audience; the address list would not survive that. The send is refused rather than going to everyone.
  • Unknown fields are ignored, except look-alikes of dry_run and to. dryRun, dry-run, DRY_RUN, test, simulate, preview … and To, TO, recipients, emails, to_emails, destinataires, adresses, audience … are refused (422), naming the right field: ignored, the former would send the campaign for real, the latter would send it to the whole list. POST /api/v1/audience refuses any unknown field.

Up to 50,000 addresses per call.

Preview before sending

"dry_run": true runs every check of a real send — sender, content, reputation, quota, recipients — and queues nothing. The response is a 200:

{ "id": "cmp_8d2", "object": "send_preview", "dry_run": true,
  "would_send": true, "blocked_reason": null, "recipients": 2,
  "audience": { "requested": 3, "retained": 2, "excluded": 1, … } }

If the real send would be refused, would_send is false and blocked_reason gives the exact message it would return. That is the number to show the person before they confirm.

To ask the same question before the campaign exists — while someone is choosing who to write to, in your own software — POST /api/v1/audience returns the same breakdown without creating anything (scope subscribers:read):

curl -X POST https://mailcheer.com/api/v1/audience \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "to": ["claire@example.com", "marc@example.com"] }'
# → { "object": "audience", "recipients": 2, "audience": { … } }

Without to, it returns how many subscribers a campaign sent to the whole list would reach. Campaign-specific checks (subject, content, quota) remain those of dry_run.

The full report

GET /api/v1/campaigns/CAMP_ID/stats returns everything the Mailcheer campaign view shows, so you can display it in your own software — a CRM, a dashboard:

{
  "campaign": { "id": "…", "name": "…", "subject": "…", "status": "sent", "kind": "newsletter",
                "fromName": "…", "fromEmail": "…", "sentAt": "…", "scheduledAt": null, "updatedAt": "…" },
  "counts": { "recipients": 66, "delivered": 60, "opened": 30, "clicked": 10,
              "humanOpened": 22, "humanClicked": 7,
              "bounced": 6, "complained": 1, "unsubscribed": 0 },
  "rates": { "delivered": 0.9091, "opened": 0.5, "clicked": 0.1667,
             "humanOpened": 0.3667, "humanClicked": 0.1167, "bounced": 0.0909 },
  "bots": { "opened": 9, "clicked": 4, "delay": 12, "scanner": 1 },
  "timeline": [ { "label": "+0h", "opened": 12, "clicked": 5, "humanOpened": 4, "humanClicked": 1 }, … ],
  "audience": { "total": 30, "proxiedShare": 0.4,
                "device": [ { "label": "Phone", "count": 15, "share": 0.5 }, … ],
                "os": [ … ], "client": [ … ] },
  "links": [ { "url": "https://…", "clicks": 6 }, … ],
  "html": "<!doctype html>…"
}

Rates (rates, share, proxiedShare) are between 0 and 1, and null as long as there is nothing to divide. opened and clicked include bots, humanOpened and humanClicked set them aside; untracked counts delivered messages that carried no open-tracking pixel — open figures and rates cover only the others, and rates.opened is null when none did; bots says how many opens and clicks were set aside, counted as events, and why (delay: within two minutes of delivery; scanner: a robot that declares itself or an address its operator publishes). timeline counts opens and clicks in six-hour windows during the first 48 hours after the send, with and without bots — empty until the campaign has gone out. audience counts only opens by people and keeps the top five rows of each breakdown; proxiedShare is the share of those opens coming from a privacy relay (Apple Mail, Gmail): received, not necessarily read. links is sorted from most to least clicked, by people. html is the message as it was sent, empty string otherwise.

Who opened: the recipients

GET /api/v1/campaigns/CAMP_ID/recipients returns one row per person the campaign went to, with cursor pagination like /subscribers (?limit= up to 100, ?cursor= = the previous page's next_cursor):

curl "https://mailcheer.com/api/v1/campaigns/CAMP_ID/recipients?status=opened" \
  -H "Authorization: Bearer mch_live_…"
{
  "object": "list",
  "data": [
    { "object": "recipient", "id": "…", "email": "claire@example.com",
      "first_name": "Claire", "last_name": "Martin", "status": "clicked",
      "sent_at": "…", "delivered_at": "…", "opened_at": "…", "clicked_at": "…",
      "human_opened": true, "human_clicked": true, "unsubscribed": false },
    { "object": "recipient", "id": "…", "email": null, … }
  ],
  "has_more": true,
  "next_cursor": "…"
}

?status= filters on opened, clicked, not_opened (sent, not bounced, never opened), bounced, complained or unsubscribed. opened_at and clicked_at include bots, like the report's opened and clicked; human_opened and human_clicked say whether a person opened or clicked. email is null when the contact was deleted after the send: the row stays, it still counts in the campaign's figures. unsubscribed is true when the person unsubscribed through this email's link: the unsubscribe link identifies the email that carries it, and an unsubscribe made elsewhere (through the API, in the app, from an automation) counts on no campaign. For an email sent before September 24, 2026, whose link only identified the person, the unsubscribe is still attributed to the last email received before it. The report (/stats) counts the same people in counts.unsubscribed.

Webhooks

The API answers when you ask it; a webhook tells you without being asked. Subscribe an address of your own — POST /api/v1/webhooks with a key that has webhooks:write, or Settings → API — choose its events, and Mailcheer sends it a POST with a JSON body each time one of them happens.

curl -X POST https://mailcheer.com/api/v1/webhooks \
  -H "Authorization: Bearer mch_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My CRM",
    "url": "https://your-service.com/mailcheer/events",
    "events": [
      "email.bounced",
      "email.complained",
      "subscriber.unsubscribed"
    ]
  }'

The response carries the subscription's signing secret (whsec_…). Keep it: it is how your service knows a call comes from Mailcheer. The address must be https, and never a private-network address.

Every event

EventSent when
email.sentAn email sent through the API (POST /api/v1/emails, the MCP send_email) was accepted by our sending provider — one event per address, to, cc and bcc. API emails only.
email.failedAn email sent through the API was recorded but did not go out; reason says why. A refusal that recorded nothing (quota, domain, suppression list…) produces no event: there is no email, only an error response.
email.deliveredThe recipient's mail server accepted the email.
email.openedThe email was opened — the tracking pixel loaded, robots included, like opened_at.
email.clickedA link in the email was clicked; url says which.
email.bouncedThe email bounced; reason gives the type. A Permanent bounce adds the address to the suppression list.
email.complainedThe recipient marked the email as spam. The address joins the suppression list.
email.receivedA recipient replied, on reply.<domain> of a domain where reply receiving is turned on — see below.
subscriber.createdA subscriber was added: signup form, API or MCP, or by hand in the app.
subscriber.unsubscribedA subscriber left: the unsubscribe link of an email, the API or MCP, or the app.
quota.threshold_reachedThis month's quota reached 50, 80, 95 or 100% — see Getting notified.

Every body has the same envelope: id (evt_…, the same on every attempt and every replay of the event), type, created_at (when the event occurred, ISO 8601 UTC — not when it was delivered) and data, whose fields depend on the event. An event produced by a test key also carries "test": true, between created_at and data — see Events in test mode.

The fields of email.* events

FieldPresentMeaning
message_idalwaysWith source: "api", the id returned by POST /api/v1/emails. With source: "campaign", the id of the recipient's row in GET /api/v1/campaigns/{id}/recipients.
emailalwaysThe address the event concerns. For a campaign email whose contact was deleted after the send, an empty string.
sourcealwayscampaign or api.
campaign_id, campaign_namecampaign emailsThe campaign that sent the email.
subscriber_idcampaign emails whose contact still existsThe subscriber who received it.
subjectwhen the email has oneThe subject of the campaign or of the email.
urlemail.clickedThe link that was clicked.
reasonemail.bounced, email.failedFor a bounce, the type reported by Amazon SES: Permanent (the address does not exist), Transient (full inbox, server unavailable) or Undetermined. For a failure: provider_rejected (our sending provider refused the message), safety_review (handed to a human safety review — the call answered 423: send it again after the decision), suspended (the workspace was suspended by the review) or interrupted (a failure on our side before the send).
recipient_typeAPI emails (source: "api")to if the address was a recipient, cc or bcc if it was a copy.
{
  "id": "evt_Ls2kPq8Rw4Tn6Vx1",
  "type": "email.sent",
  "created_at": "2026-09-25T08:31:05.118Z",
  "data": {
    "message_id": "cmu651xf200021n7nm68sikfw",
    "email": "customer@example.com",
    "source": "api",
    "subject": "Your September invoice",
    "recipient_type": "to"
  }
}
{
  "id": "evt_Fm9sQw3Er7Ty1Ui4",
  "type": "email.failed",
  "created_at": "2026-09-25T08:40:12.604Z",
  "data": {
    "message_id": "cmu652bq900031n7n0x9h2rst",
    "email": "customer@example.com",
    "source": "api",
    "subject": "Your September invoice",
    "reason": "provider_rejected",
    "recipient_type": "to"
  }
}
{
  "id": "evt_8fQm2LxT0aVb7KcN",
  "type": "email.delivered",
  "created_at": "2026-09-25T08:31:07.412Z",
  "data": {
    "message_id": "cmu651xf200021n7nm68sikfw",
    "email": "customer@example.com",
    "source": "api",
    "subject": "Your September invoice"
  }
}
{
  "id": "evt_Jr4uWc9ZpQe1sHa2",
  "type": "email.opened",
  "created_at": "2026-09-25T09:02:44.108Z",
  "data": {
    "message_id": "cmu7a2k9q000b1n7n3v5c8xyz",
    "email": "claire@example.com",
    "source": "campaign",
    "campaign_id": "cmu79z1ab00001n7nqk2d4efg",
    "campaign_name": "October newsletter",
    "subscriber_id": "cmu5x0p3r00041n7n8m2t6hij",
    "subject": "What's new this month"
  }
}
{
  "id": "evt_Tn7bKx2VdMf0gLq5",
  "type": "email.clicked",
  "created_at": "2026-09-25T09:03:12.550Z",
  "data": {
    "message_id": "cmu7a2k9q000b1n7n3v5c8xyz",
    "email": "claire@example.com",
    "source": "campaign",
    "campaign_id": "cmu79z1ab00001n7nqk2d4efg",
    "campaign_name": "October newsletter",
    "subscriber_id": "cmu5x0p3r00041n7n8m2t6hij",
    "subject": "What's new this month",
    "url": "https://yourdomain.com/offer"
  }
}
{
  "id": "evt_Ab3cD9eF0gH1iJ2k",
  "type": "email.bounced",
  "created_at": "2026-09-25T08:31:09.020Z",
  "data": {
    "message_id": "cmu651xf200021n7nm68sikfw",
    "email": "nobody@example.com",
    "source": "api",
    "subject": "Your September invoice",
    "reason": "Permanent"
  }
}
{
  "id": "evt_Pw6yRz1Xc0Vb8Nm3",
  "type": "email.complained",
  "created_at": "2026-09-25T10:15:31.774Z",
  "data": {
    "message_id": "cmu7a2k9q000c1n7n0w4d7abc",
    "email": "marc@example.com",
    "source": "campaign",
    "campaign_id": "cmu79z1ab00001n7nqk2d4efg",
    "campaign_name": "October newsletter",
    "subscriber_id": "cmu5x0p3r00051n7n2k9s1klm",
    "subject": "What's new this month"
  }
}

The fields of email.received

A recipient's reply, received on reply.<your domain>. The event only exists for a domain where reply receiving is turned on: in the app, the domain's screen → Receive replies, one DNS record to add.

TypeNameValuePriority
MXreply.yourdomain.cominbound-smtp.eu-west-1.amazonaws.com10

This record does not touch your mailboxes: it only applies to the reply subdomain. Once it is read, every API email from this domain sent without reply_to goes out with the reply address <local part>+<id>@reply.<domain> — the id is the one POST /api/v1/emails returns. A reply_to given in the call is always kept, and any reply.<domain> address you pick yourself (case-4821@reply.yourdomain.com) is received too. Until the record is read, nothing changes: replies go to the sending address. Turning receiving off stops the automatic reply address on new emails; replies to emails already sent keep arriving as long as the record exists.

FieldPresentMeaning
reply_idalwaysThe reply's id in Mailcheer.
emailalwaysThe address of the person who replied — the same as from, so that data.email names the contact in every email.* event.
fromalwaysThe sender of the reply.
from_namewhen they have oneTheir display name.
toalwaysThe reply.<domain> addresses that received the reply.
ccwhen there are anyThe visible copy addresses.
subjectalwaysThe subject, decoded.
textwhen the reply has a text versionThe text, as is, including the quote of your email.
htmlwhen the reply has an HTML versionThe HTML, as is. It comes from a stranger: never display it without sanitizing it.
received_atalwaysWhen Amazon received the reply, ISO 8601 UTC.
headersalwaysmessage_id, in_reply_to, references (array), date and auto_submitted — null when missing.
attachmentsalways, possibly emptyFor each attachment: filename, content_type, size (bytes), content_id (inline image), url — a signed download link that needs no key — and url_expires_at: the link is valid for 7 days.
message_idwhen the original email is foundThe id of the Mailcheer email being replied to, as in the other email.* events. Found through the reply address, or through In-Reply-To and References — never guessed.
sourcewith message_idapi, campaign or automation.
campaign_idreply to a campaign emailThe campaign.
automation_idreply to an automation emailThe automation.
tagsreply to an API email that carried someThe tags of the original email, to file the reply on your side.
spamalwaystrue when Amazon's filter judges the reply to be spam.
automaticalwaystrue for an automatic reply: out of office, acknowledgement, delivery report (Auto-Submitted, Precedence …).
authenticationalwaysspf, dkim and dmarc: Amazon's verdict on the sender (PASS, FAIL, GRAY …).
{
  "id": "evt_R3pL9yQ2wE5tY8uI",
  "type": "email.received",
  "created_at": "2026-09-25T15:02:11.520Z",
  "data": {
    "reply_id": "cmu8r2d5k00031n7nq4w9pxyz",
    "email": "claire@example.com",
    "from": "claire@example.com",
    "from_name": "Claire Martin",
    "to": [
      "billing+cmu651xf200021n7nm68sikfw@reply.yourdomain.com"
    ],
    "subject": "Re: Your September invoice",
    "text": "Hello, can you send it to our accounting team too?\n\nOn 25 Sept 2026, Your company wrote:\n> Please find your invoice attached.",
    "received_at": "2026-09-25T15:02:11.482Z",
    "headers": {
      "message_id": "CAF3k9x@mail.gmail.com",
      "in_reply_to": "0102019a7c3e1b2f-3d1e-000000@eu-west-1.amazonses.com",
      "references": [
        "0102019a7c3e1b2f-3d1e-000000@eu-west-1.amazonses.com"
      ],
      "date": "Thu, 25 Sep 2026 17:02:08 +0200",
      "auto_submitted": null
    },
    "attachments": [
      {
        "filename": "purchase-order.pdf",
        "content_type": "application/pdf",
        "size": 48213,
        "url": "https://mailcheer.com/api/reponses/pj/Y211OHIy…~kQ3v…",
        "url_expires_at": "2026-10-02T15:02:11.520Z"
      }
    ],
    "message_id": "cmu651xf200021n7nm68sikfw",
    "source": "api",
    "tags": {
      "customer": "4821"
    },
    "spam": false,
    "automatic": false,
    "authentication": {
      "spf": "PASS",
      "dkim": "PASS",
      "dmarc": "PASS"
    }
  }
}

Three limits to know:

  • 150 KB at most per reply, headers and encoded attachments included. Above that, Amazon refuses it and its sender gets an error message: it never arrives.
  • A message Amazon's antivirus condemns is neither kept nor announced.
  • Replies stay 30 days on the app's Replies screen; after that, only your software keeps a copy.

The fields of subscriber.* events

FieldPresentMeaning
subscriber_idalwaysThe subscriber's identifier.
emailalwaysTheir address.
statusalwayspending (waiting for confirmation), subscribed or unsubscribed.
first_name, last_nameon creation, when knownFirst name and last name.
sourceon creationform — a signup form, status: "pending" until the person confirms. api — the API or MCP: pending, or subscribed with double_opt_in: false. manual — added in the app: subscribed, or unsubscribed if the address is on the suppression list.
reasonon unsubscribeunsubscribe (the link of an email, DELETE /api/v1/subscribers/{email}, the app), or the reason given when the address was added to the suppression list: manual, unsubscribe, bounce or complaint.
campaign_id, message_idunsubscribe through a campaign email's linkThe campaign, and the recipient's row (message_id) whose link was used.
automation_idunsubscribe through an automation email's linkThe automation that sent it.
{
  "id": "evt_Hs5tUv6Wx7Yz8Ab9",
  "type": "subscriber.created",
  "created_at": "2026-09-25T11:20:05.301Z",
  "data": {
    "subscriber_id": "cmu7c4n2p00071n7n5r8t2def",
    "email": "lea@example.com",
    "status": "pending",
    "first_name": "Léa",
    "last_name": "Morel",
    "source": "form"
  }
}
{
  "id": "evt_Kq1wE2rT3yU4iO5p",
  "type": "subscriber.unsubscribed",
  "created_at": "2026-09-25T12:41:58.866Z",
  "data": {
    "subscriber_id": "cmu5x0p3r00041n7n8m2t6hij",
    "email": "claire@example.com",
    "status": "unsubscribed",
    "reason": "unsubscribe",
    "campaign_id": "cmu79z1ab00001n7nqk2d4efg",
    "message_id": "cmu7a2k9q000b1n7n3v5c8xyz"
  }
}

Three things these events do not do:

  • Confirming a pending subscriber produces no event. Read GET /api/v1/subscribers/{email}: its status moves to subscribed.
  • A bounce or a complaint does not produce subscriber.unsubscribed. It arrives as email.bounced or email.complained.
  • The same person can be reported unsubscribed more than once — a second click on the link, for instance. Handle subscriber.unsubscribed as idempotent.

What each call carries

A POST with Content-Type: application/json, User-Agent: Mailcheer-Webhooks/1.0 (+https://mailcheer.com/docs/api), and these headers:

HeaderValue
Mailcheer-Signaturet=<unix timestamp>,v1=<hex> — see below.
Mailcheer-TimestampThe same t, in seconds.
Mailcheer-Event-IdThe event's id: the same on every attempt and every replay.
Mailcheer-Event-TypeThe event's type.
Mailcheer-Delivery-IdThis delivery. A manual replay gets a new one.
Mailcheer-AttemptThe attempt number, starting at 1.

Verifying the signature

v1 is the HMAC-SHA256, in hexadecimal, of "<t>.<raw body>", keyed with the subscription's secret. Three rules:

  1. Compute it on the raw body, exactly as received. A body parsed then re-serialized changes by one space or one key order, and the signature with it.
  2. Compare in constant time.
  3. Reject a t more than 300 seconds away from your clock. The signature stays valid forever, the timestamp does not: that is what makes an intercepted call impossible to replay later.

The function Mailcheer itself uses to check its signatures, ready to copy (Node.js):

import { createHmac, timingSafeEqual } from "node:crypto";

// secret: "whsec_…"
// header: the Mailcheer-Signature header
// rawBody: the body exactly as received, untouched
export function verifySignature(
  secret,
  header,
  rawBody,
  nowS = Math.floor(Date.now() / 1000),
) {
  if (!header) return false;
  const parts = new Map();
  for (const piece of header.split(",")) {
    const i = piece.indexOf("=");
    if (i > 0) {
      parts.set(piece.slice(0, i).trim(), piece.slice(i + 1));
    }
  }
  const t = Number(parts.get("t"));
  const v1 = parts.get("v1");
  if (!Number.isFinite(t) || !v1) return false;
  if (Math.abs(nowS - t) > 300) return false;
  const expected = Buffer.from(
    createHmac("sha256", secret)
      .update(\`${t}.${rawBody}\`, "utf8")
      .digest("hex"),
    "utf8",
  );
  const received = Buffer.from(v1, "utf8");
  if (expected.length !== received.length) return false;
  return timingSafeEqual(expected, received);
}

In a Next.js route, read the body as text before anything else:

export async function POST(req) {
  const raw = await req.text();
  const ok = verifySignature(
    process.env.MAILCHEER_WEBHOOK_SECRET,
    req.headers.get("mailcheer-signature"),
    raw,
  );
  if (!ok) return new Response("invalid signature", { status: 401 });
  const event = JSON.parse(raw);
  if (event.data.test === true) {
    return new Response(null, { status: 204 }); // the test event
  }
  // ignore an event.id you have already processed,
  // then handle event.type
  return new Response(null, { status: 204 });
}

With Express, the same thing needs express.raw({ type: "application/json" }) on that route.

Answering, retries and duplicates

Answer 2xx in under 15 seconds. Anything else is a failure: another status, a redirect (it is not followed), no answer after 15 seconds, a network error. Do the work in the background and answer straight away — a long job looks exactly like an outage.

After a failure, Mailcheer tries again: 5 attempts in all. The first goes out immediately, the next ones 30 s, 2 min, 10 min and 1 h after the previous failure. Retries are picked up every 15 seconds, so one can arrive a few seconds late. After the fifth failure the delivery is failed and is no longer retried on its own — and the workspace owner receives an email saying which subscription, which event and the last answer, at most one per subscription per day: replay it from Settings → API (Replay), or with POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay. GET /api/v1/webhooks/{id}/deliveries lists the deliveries: status, attempts, your response code, the first 500 characters of your response, the next attempt.

The same event can reach you more than once: a retry after an answer that got lost, a manual replay. Its id — also in Mailcheer-Event-Id — does not change: store it and ignore an id you have already processed. Mailcheer-Delivery-Id changes with a replay; it cannot be used for this.

Events can arrive out of order: an event retried for an hour arrives after the ones that followed it. Order them by created_at.

The test event

POST /api/v1/webhooks/{id}/test — or the Send a test button in Settings → API — sends a test event down exactly the same path as a real one: same signature, same headers, same log, same retries. Mailcheer waits for your answer, so the response tells you straight away (status, response_status, error).

⚠️ The test event carries none of the fields of the real event. Its type is the first event of the subscription, but its data holds only test: true and a message:

{
  "id": "evt_Zx9cV8bN7mL6kJ5h",
  "type": "subscriber.unsubscribed",
  "created_at": "2026-09-25T14:07:22.190Z",
  "data": {
    "test": true,
    "message": "Test event sent from Mailcheer."
  }
}

No email, no subscriber_id, no message_id. Check data.test === true before reading anything else, and answer 2xx without processing it. Otherwise your code looks for fields that are not there, fails, and the test looks like a broken connection. The message follows the language of the request (Accept-Language) or of the screen it was sent from.

Events in test mode

An email sent with a test key (see Test mode) produces its events like a real one: same path, same signature, same retries. They differ in one field, "test": true at the top of the body, next to type:

{
  "id": "evt_Qp3rS8tU1vW4xY7z",
  "type": "email.bounced",
  "created_at": "2026-09-25T09:41:07.512Z",
  "test": true,
  "data": {
    "message_id": "cmu7t3kq80004mc0t9e1vx2ab",
    "email": "bounced@simulator.mailcheer.com",
    "source": "api",
    "subject": "Your September invoice",
    "reason": "Permanent",
    "recipient_type": "to"
  }
}

Unlike the test event above, its data is exactly that of a real event: your handler runs as in production, which is the point. A real event has no test field. To keep test events out of your records, check event.test === true — never data.test, which only the test event carries.

The language of responses

Error messages follow your Accept-Language header: English by default, French if you ask for it.

curl https://mailcheer.com/api/v1/me
# {"error":{"code":"missing_api_key","message":"Missing API key. Add the header …"}}

curl https://mailcheer.com/api/v1/me -H "Accept-Language: fr"
# {"error":{"code":"missing_api_key","message":"Clé d'API absente. Ajoutez l'en-tête …"}}

This covers everything a program reads: API error messages, the MCP server's tools (their names, what they do, their parameters), the reference served at mailcheer://docs, the map at GET /api/v1 and the message of the test webhook event.

Only the first preference is read: fr-FR,fr;q=0.9,en;q=0.8 asks for French, even though English is listed — and en-US,fr;q=0.9 asks for English.

A key can have its own language — Settings → API, Adjust. It answers when the call asks for none: no Accept-Language, or one whose first preference is neither English nor French (*, de-DE …). An Accept-Language in English or French always wins over it. GET /api/v1/me returns it in key.language.

⚠️ The code never changes language — write your logic against it, never against the message.

Errors

Always the same shape, readable two ways from the same content. The message follows the Accept-Language header, English by default — your logic should read code (or name), never message.

{
  "error": {
    "code": "unverified_from_domain",
    "message": "Domain “example.com” is not verified in this workspace. Verified domains: yourdomain.com.",
    "details": { "from": "hello@example.com", "verifiedDomains": ["yourdomain.com"] }
  },
  "statusCode": 422,
  "message": "Domain “example.com” is not verified in this workspace. Verified domains: yourdomain.com.",
  "name": "unverified_from_domain"
}

error is Mailcheer's format: structured, with details containing what you need to fix the problem. The three flat fields — statusCode, message, name — match the Resend format, so code written against the old API shows a correct message without being rewritten.

Write your logic against code (or name — they are the same value), never against message. The message is for a human to read, and we reserve the right to rephrase it.

CodeStatusWhat it means
missing_api_key401No Authorization header.
invalid_api_key401Unknown key.
revoked_api_key401Key revoked in settings.
insufficient_scope403The key does not have the requested permission.
reputation_blocked403Your sends are blocked: too many bounces or complaints.
sending_blocked403Sending is stopped for the workspace (suspended, blocked or banned): nothing goes out, through any channel. details.reason says which.
commitment_required403The anti-spam commitment is not accepted: it opens at the first send from the workspace, or on the /engagement page.
sending_paused423The workspace is paused for a safety review. Nothing is wrong with the call: send it again, unchanged, after the decision. Retry-After gives an interval to try again; while the review lasts, the call answers 423 again and writes nothing.
daily_quota_exceeded429A new workspace reached its daily limit: 100 emails per rolling 24 hours for its first three days, 500 until the seventh. Retry-After and details say when to try again.
quota_exceeded402The plan's monthly quota does not cover the send: nothing went out. details gives plan, quota, sent, in_flight, remaining, requested and resets_at.
free_plan_domain_used402Free workspace: the sending domain has already served the free plan this month in another workspace. Nothing went out. details gives domain, root_domain, period and resets_at.
key_quota_exceeded402This key reached its own monthly limit (Settings → API); the workspace may still have room. Nothing went out. details gives key_id, key_name, limit, used, remaining, requested and resets_at.
not_found404The object does not exist in this workspace.
conflict409Incompatible state: campaign already sent, subscriber already gone.
idempotency_key_reused409Same Idempotency-Key, different body.
idempotency_key_in_use409An identical request with the same Idempotency-Key is still in progress. Send it again, unchanged, after Retry-After: you get its response, without a second send.
validation_error422A field is missing or malformed.
unverified_from_domain422The from domain is not verified.
suppressed_recipient422A recipient is suppressed: bounce, complaint, removed by hand, or unsubscribed when the send carries unsubscribe_url.
rate_limit_exceeded429More than 600 requests per minute — a batch of N subscribers counts as N (see Retry-After).
send_failed502Our sending provider refused the message.
internal_error500A failure on our side.

Every 429 and every 423 carries Retry-After, in seconds: wait that long before sending the same call again.

Rate limit

600 requests per minute per key. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a refusal also carries Retry-After, in seconds. A batch of N subscribers (POST /api/v1/subscribers/batch) counts as N requests. Do not confuse these headers with the monthly quota ones (Mailcheer-Quota-*, above): the rate is counted per minute, the quota per month.

Need more? Write to us: we look at your case rather than leaving you to retry in a loop.

The MCP server

MCP — Model Context Protocol — is how an AI agent discovers a product's tools and uses them. Mailcheer exposes a hosted MCP server: nothing to install, one address and your key in the Authorization header.

Claude Code

claude mcp add --transport http mailcheer https://mailcheer.com/api/mcp \
  --header "Authorization: Bearer mch_live_…"

Add --scope user for the connection to apply across all your projects, not just the current folder.

Codex — it reads the key from an environment variable and sends it in the Authorization header itself:

export MAILCHEER_API_KEY="mch_live_…"
codex mcp add mailcheer \
  --url https://mailcheer.com/api/mcp \
  --bearer-token-env-var MAILCHEER_API_KEY

The command writes this to ~/.codex/config.toml, which you can also write by hand:

[mcp_servers.mailcheer]
url = "https://mailcheer.com/api/mcp"
bearer_token_env_var = "MAILCHEER_API_KEY"

Cursor — in .cursor/mcp.json (one project) or ~/.cursor/mcp.json (all your projects):

{
  "mcpServers": {
    "mailcheer": {
      "url": "https://mailcheer.com/api/mcp",
      "headers": { "Authorization": "Bearer mch_live_…" }
    }
  }
}

Then, in your agent: "What Mailcheer workspace do you see, and which sending domains are verified?" It will call get_account, which modifies nothing — the right way to confirm a connection.

Exposed tools

ToolWhat it does
get_accountThe workspace, permissions, this month's usage (queue and reset date included), billing, limits, verified domains.
send_emailSends a transactional email. Irreversible.
get_emailThe status of a sent email.
list_subscribersLists subscribers, page by page.
add_subscriberAdds or updates a subscriber.
remove_subscriberUnsubscribes and blocks the address. Irreversible.
list_tagsThe tags, with how many subscribers carry each.
tag_subscriberAdds and removes tags on one subscriber, with no subscription and no email.
erase_subscriberErases a person at their request (GDPR). Irreversible.
list_suppressionSuppressed addresses, with their reason.
add_suppressionBlocks an address. Irreversible.
list_campaignsThe workspace's campaigns.
create_campaignCreates a draft. Nothing is sent.
update_campaignEdits a draft. Nothing is sent.
preview_campaign_sendSays whether the campaign would go out and to how many people, address by address with to. Nothing is sent.
send_campaignSends to all active subscribers, or only to the active subscribers among the addresses in to. Irreversible.
get_campaign_statsNumbers and status for a campaign.

Every tool is a call to the API above, nothing more: same permissions, same quota, same suppression list, same refusals. A second access path with its own logic would be a second set of rules, and the day one of them changed, MCP would become the back door.

No tool removes an address from the suppression list. It is the one product action that suspends a send capability, and an agent told to "clean the list" would do it without hesitation. It is done by hand, in your workspace.

The mailcheer://docs resource gives the agent the full reference: it does not need to know it in advance.

Node.js SDK

The official package for Node.js and TypeScript wraps this API: types for every call and every event, stable error codes, retries that never send twice, the quota read from every response, and webhook signatures checked in one line. No dependency; ESM and CommonJS. mailcheer on npm.

npm install mailcheer   # Node.js 20+, Bun, Deno
import { Mailcheer } from "mailcheer";

const mailcheer = new Mailcheer(process.env.MAILCHEER_API_KEY);

const { data, error, meta } = await mailcheer.emails.send(
  {
    from: "Acme <billing@acme.com>",
    to: "jane@example.com",
    subject: "Your September invoice",
    html: "<p>Here it is.</p>",
  },
  { idempotencyKey: "invoice-2026-0042" },
);
if (error) console.error(error.code, error.message);
else console.log(data.id, meta.quota?.remaining);

What it handles for you:

  • Retries that never send twice. After a network failure, a timeout, a 429 or a 5xx, the SDK tries again — twice by default — and waits for Retry-After when the API gives it. A call that changes something is only retried when it carries an idempotency key, or when the API refused it before reading it (rate_limit_exceeded).
  • Errors you can branch on. Every method returns { data, error, meta } and never throws; error is a MailcheerError with the stable code, the details and retryAfter.
  • Your quota. meta.quota and mailcheer.lastQuota hold the Mailcheer-Quota-* figures; meta.keyQuota those of a key with its own monthly limit; meta.mode says test for a test key (mch_test_…).
  • Webhooks. constructWebhookEvent(secret, header, rawBody) checks the signature and the five-minute window exactly as described in Verifying the signature, then returns the event, typed by its type. On an edge runtime without node:crypto, constructWebhookEventAsync() does the same with Web Crypto.
  • Every endpoint on this page — emails, subscribers and batches, tags, suppression, campaigns and drafts, webhooks — and request() for a route newer than the version you run.

Python SDK

The official package for Python wraps the same API: a blocking client and an asyncio client with the same methods, typed responses and typed errors, retries that never send twice, the quota read from every response, and webhook signatures checked in one line. One dependency, httpx. mailcheer on PyPI.

pip install mailcheer   # Python 3.9+
from mailcheer import Mailcheer, MailcheerError

mailcheer = Mailcheer()  # reads the MAILCHEER_API_KEY variable

try:
    email = mailcheer.emails.send(
        {
            "from": "Acme <billing@acme.com>",
            "to": "jane@example.com",
            "subject": "Your September invoice",
            "html": "<p>Here it is.</p>",
        },
        idempotency_key="invoice-2026-0042",
    )
    print(email["id"], mailcheer.last_quota)
except MailcheerError as error:
    print(error.code, error.message)

What it handles for you:

  • Retries that never send twice. After a network failure, a timeout, a 429 or a 5xx, the SDK tries again — twice by default (max_retries) — and waits for Retry-After when the API gives it. A call that changes something is only retried when it carries an idempotency key, or when the API refused it before reading it (rate_limit_exceeded).
  • One exception per kind of refusal. Unlike the Node.js SDK, a method raises: MailcheerError, or the subclass for its status — AuthenticationError (401), QuotaExceededError (402), ValidationError (422), RateLimitError (429)… Each carries the stable code, the details and retry_after.
  • Your quota. mailcheer.last_quota holds the Mailcheer-Quota-* figures; mailcheer.last_key_quota those of a key with its own monthly limit; mailcheer.last_response.mode says "test" for a test key (mch_test_…).
  • asyncio. AsyncMailcheer has the same methods, awaited; list_all() walks every page, with for or async for.
  • Every endpoint on this page — emails, subscribers and batches, tags, suppression, campaigns and drafts, webhooks — and request() for a route newer than the version you run.

For webhooks, construct_webhook_event() checks the signature and the five-minute window exactly as described in Verifying the signature, then returns the event — or raises WebhookSignatureError. Give it the raw body, never the parsed JSON, which it refuses: request.get_data() with Flask, request.body with Django, await request.body() with FastAPI.

from mailcheer import WebhookSignatureError, construct_webhook_event, is_test_event

@app.post("/webhooks/mailcheer")  # Flask
def mailcheer_webhook():
    try:
        event = construct_webhook_event(
            os.environ["MAILCHEER_WEBHOOK_SECRET"],
            request.headers.get("Mailcheer-Signature"),
            request.get_data(),
        )
    except WebhookSignatureError:
        return "invalid signature", 401
    if is_test_event(event):
        return "", 204  # the test event
    # skip an event["id"] you already handled, then act on event["type"]
    return "", 204

If you are migrating from Resend

The fields of POST /v1/emails and the { id } response are the same. In practice: the base URL and the key.

Three ways to switch.

With the SDK — npm install mailcheer: the same { data, error } shape as the Resend SDK, plus types, retries and the quota (see Node.js SDK).

- const resend = new Resend(process.env.RESEND_API_KEY);
+ const mailcheer = new Mailcheer(process.env.MAILCHEER_API_KEY);

With the minimal client — one file to copy, no dependency, the same signature as the Resend SDK. Get it: mailcheer.com/mailcheer-client.ts.

// before
const resend = new Resend(process.env.RESEND_API_KEY);

// after
const mailcheer = new Mailcheer(process.env.MAILCHEER_API_KEY);

// the rest of your code stays the same
const { data, error } = await mailcheer.emails.send({ from, to, subject, html, text });
if (error) throw new Error(\`Email delivery failed: ${error.message}\`);
return { providerId: data?.id ?? null };

It never throws: a network failure also becomes an error, with name: "network_error". This is intentional — a method that throws where the old one returned an object would turn "change two lines" into "review every call site", and the call sites you forget to review are exactly the error paths.

Without copying anything — a bare fetch is enough:

const res = await fetch("https://mailcheer.com/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: \`Bearer ${process.env.MAILCHEER_API_KEY}\`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ from, to, subject, html }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.message); // the message is human-readable
const id = body.id;

Three things to know:

  • The from domain must be verified in your Mailcheer workspace, not in Resend. Add it in Domains and publish the DNS records.
  • One workspace is enough for your transactional email and your newsletter. Unsubscribing from the newsletter only stops bulk sends: your invoices and password resets still go to that address. A bounce or a complaint stops everything. If you also send a newsletter through the API, pass unsubscribe_url: an unsubscribed address will not receive it.
  • The monthly quota is shared with your campaigns.

Where these emails live

Emails sent via the API do not join your campaigns: they live separately, and this is not a technical detail.

An invoice recipient is not a subscriber. Grouping them with your subscribers would have enrolled them in your list without their ever consenting to receive your newsletter — counted on your dashboard, and targeted by your next campaign. Your subscriber numbers remain those of your real subscribers.

What is shared: the monthly quota, the suppression list, and bounce monitoring. These are the three things that commit your sender reputation, and that reputation is the same on both sides.

The technical spec

The OpenAPI 3.1 file is served as-is: mailcheer.com/openapi.json. It describes every endpoint, every field and every error — enough to generate a client in your language, or to hand to an agent.