API and MCP server
Send emails, manage subscribers and campaigns from your code or from an 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 whom | Address | |
|---|---|---|
| REST API | Code — any language that can make an HTTP request. | https://mailcheer.com/api/v1 |
| MCP server | AI 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
| Permission | What it unlocks |
|---|---|
emails:send | Send transactional emails and read their status. |
subscribers:read | Read subscribers and the suppression list. |
subscribers:write | Add, update and unsubscribe subscribers. |
campaigns:read | Read campaigns and their statistics. |
campaigns:write | Create and send campaigns. |
webhooks:read | Read event subscriptions and their log. |
webhooks:write | Create, 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 <[email protected]>",
"to": "[email protected]",
"subject": "Your September invoice",
"html": "<p>Here it is.</p>"
}'The response comes back as 202:
{
"id": "cmu651xf200021n7nm68sikfw",
"object": "email",
"from": "[email protected]",
"to": ["[email protected]"],
"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 <[email protected]>",
"to": "[email protected]",
"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 <[email protected]>",
"to": "[email protected]",
"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
contentthat is not valid base64; - a
pathfield 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.
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. It is the same send count, the same invoice.
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.
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-0412Replaying 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":"[email protected]","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.
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": ["[email protected]", "[email protected]", "[email protected]"] }'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": "[email protected]", "reason": "unsubscribed" } ]
},
"note": "2 recipient(s) queued. …"
}The count always adds up: requested = duplicates + retained + excluded. Case and surrounding spaces are ignored ([email protected] 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 notofield at all. - No
toon 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_runandto.dryRun,dry-run,DRY_RUN,test,simulate,preview… andTo,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/audiencerefuses 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": ["[email protected]", "[email protected]"] }'
# → { "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,
"bounced": 6, "complained": 1, "unsubscribed": 0 },
"rates": { "delivered": 0.9091, "opened": 0.5, "clicked": 0.1667, "bounced": 0.0909 },
"timeline": [ { "label": "+0h", "opened": 12, "clicked": 5 }, … ],
"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. timeline counts opens and clicks in six-hour windows during the first 48 hours after the send — empty until the campaign has gone out. audience keeps only the top five rows of each breakdown; proxiedShare is the share of opens coming from a privacy relay (Apple Mail, Gmail): received, not necessarily read. links is sorted from most to least clicked. html is the message as it was sent, empty string otherwise.
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": "[email protected]", "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.
| Code | Status | What it means |
|---|---|---|
missing_api_key | 401 | No Authorization header. |
invalid_api_key | 401 | Unknown key. |
revoked_api_key | 401 | Key revoked in settings. |
insufficient_scope | 403 | The key does not have the requested permission. |
reputation_blocked | 403 | Your sends are suspended: too many bounces or complaints. |
quota_exceeded | 402 | The plan's monthly quota has been reached. |
not_found | 404 | The object does not exist in this workspace. |
conflict | 409 | Incompatible state: campaign already sent, subscriber already gone. |
idempotency_key_reused | 409 | Same Idempotency-Key, different body. |
validation_error | 422 | A field is missing or malformed. |
unverified_from_domain | 422 | The from domain is not verified. |
suppressed_recipient | 422 | A recipient is on the suppression list. |
rate_limit_exceeded | 429 | More than 600 requests per minute (see Retry-After). |
send_failed | 502 | Our sending provider refused the message. |
internal_error | 500 | A 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.
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
| Tool | What it does |
|---|---|
get_account | The workspace, permissions, remaining quota, verified domains. |
send_email | Sends a transactional email. Irreversible. |
get_email | The status of a sent email. |
list_subscribers | Lists subscribers, page by page. |
add_subscriber | Adds or updates a subscriber. |
remove_subscriber | Unsubscribes and blocks the address. Irreversible. |
list_suppression | Addresses that will receive nothing further. |
add_suppression | Blocks an address. Irreversible. |
list_campaigns | The workspace's campaigns. |
create_campaign | Creates a draft. Nothing is sent. |
preview_campaign_send | Says whether the campaign would go out and to how many people, address by address with to. Nothing is sent. |
send_campaign | Sends to all active subscribers, or only to the active subscribers among the addresses in to. Irreversible. |
get_campaign_stats | Numbers 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
fromdomain 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.