API and MCP server
Send emails, manage subscribers and campaigns from your code or from an AI agent.
Plumail 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://plumail.fr/api/v1 |
| MCP server | AI agents, which discover available tools on their own. | https://plumail.fr/api/mcp |
Your first key
In your Plumail workspace: Settings → API → 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. |
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://plumail.fr/api/v1/emails \
-H "Authorization: Bearer plm_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://plumail.fr/api/v1/emails/cmu651xf200021n7nm68sikfw \
-H "Authorization: Bearer plm_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.
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://plumail.fr/api/v1/subscribers \
-H "Authorization: Bearer plm_live_…" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","firstName":"Marie","tags":["customers"]}'
# List, page by page
curl "https://plumail.fr/api/v1/subscribers?limit=50&status=subscribed" \
-H "Authorization: Bearer plm_live_…"
# Unsubscribe
curl -X DELETE https://plumail.fr/api/v1/subscribers/marie%40example.com \
-H "Authorization: Bearer plm_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://plumail.fr/api/v1/campaigns \
-H "Authorization: Bearer plm_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://plumail.fr/api/v1/campaigns/CAMP_ID/send \
-H "Authorization: Bearer plm_live_…"
# 3. Track
curl https://plumail.fr/api/v1/campaigns/CAMP_ID \
-H "Authorization: Bearer plm_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.
The full report
GET /api/v1/campaigns/CAMP_ID/stats returns everything the Plumail 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.
Errors
Always the same shape, readable two ways from the same content. Error messages (message) are currently returned in French — your logic should read code (or name), never message.
{
"error": {
"code": "unverified_from_domain",
"message": "Le domaine « example.com » n'est pas vérifié dans cet espace. Domaines vérifiés : yourdomain.com.",
"details": { "from": "[email protected]", "verifiedDomains": ["yourdomain.com"] }
},
"statusCode": 422,
"message": "Le domaine « example.com » n'est pas vérifié dans cet espace. Domaines vérifiés : yourdomain.com.",
"name": "unverified_from_domain"
}error is Plumail'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. Plumail exposes a hosted MCP server: nothing to install, one address and your key.
Claude Code
claude mcp add plumail \
--transport http \
--url https://plumail.fr/api/mcp \
--header "Authorization: Bearer plm_live_…"ChatGPT, Cursor, Codex, Claude Desktop — all read the same connector config:
{
"mcpServers": {
"plumail": {
"type": "http",
"url": "https://plumail.fr/api/mcp",
"headers": { "Authorization": "Bearer plm_live_…" }
}
}
}Then, in your agent: "What Plumail 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. |
send_campaign | Sends to all active subscribers. 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 plumail://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: plumail.fr/plumail-client.ts.
// before
const resend = new Resend(process.env.RESEND_API_KEY);
// after
const plumail = new Plumail(process.env.PLUMAIL_API_KEY);
// the rest of your code stays the same
const { data, error } = await plumail.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://plumail.fr/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PLUMAIL_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 differences to know, all intentional:
- The
fromdomain must be verified in your Plumail workspace, not in Resend. Add it in Domains and publish the DNS records. - The suppression list is applied to transactional sends. Resend does not do this. 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: plumail.fr/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.