Mailcheer

Send transactional email and newsletter campaigns from an agent: subscribers, segments, suppression list, sending and stats — on one API key. Runs on Amazon SES, hosted in Europe.

Hosted MCP Server

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

Installs into Claude Code, Codex, Cursor and more

Documentation

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, ChatGPT or Codex. 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.

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.

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.

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.

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.

Copies

cc and bcc accept one address or an array of 1 to 50, just like to. Both count against your quota and go through the same checks — a suppressed address 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 — unsubscription, dead address, complaint. 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.

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": { "name": "Production", "scopes": ["emails:send", "subscribers:read"] },
  "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,
    "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 }]
}
  • 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: 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.

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:

{
  "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 the same call returns the same response, with the same id, without a second send. The Idempotent-Replay: true header tells you it was a replay. The same key with a different body returns 409: 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.

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.

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.

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.

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) and the reference served at mailcheer://docs.

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.

⚠️ 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 is shown the next time you sign in to the workspace.
sending_paused423The workspace is paused for a safety review. Nothing is wrong with the call: send it again, unchanged, after the decision.
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.
not_found404The object does not exist in this workspace.
conflict409Incompatible state: campaign already sent, subscriber already gone.
idempotency_key_reused409Same Idempotency-Key, different body.
validation_error422A field is missing or malformed.
unverified_from_domain422The from domain is not verified.
suppressed_recipient422A recipient is on the suppression list.
rate_limit_exceeded429More than 600 requests per minute (see Retry-After).
send_failed502Our sending provider refused the message.
internal_error500A failure on our side.

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. 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.

Claude Code

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

ChatGPT, Cursor, Codex, Claude Desktop — all read the same connector config:

{
  "mcpServers": {
    "mailcheer": {
      "type": "http",
      "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_suppressionAddresses that will receive nothing further.
add_suppressionBlocks an address. Irreversible.
list_campaignsThe workspace's campaigns.
create_campaignCreates 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.

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.

Two ways to switch.

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.
  • The suppression list protects transactional sends too. An address that unsubscribed from your newsletter will not receive your transactional emails from the same workspace either — if that is not what you want, separate the two into two workspaces.
  • 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.