Everything an MCP client does, a script can do with plain HTTPS. One endpoint, one API key, JSON in and JSON out. This guide covers authentication, the three calls you need, errors, retries, limits and webhooks for new mail.
Every request is a JSON-RPC 2.0 POST to https://mcpemails.com/api/mcp. This is the same endpoint claude.ai, ChatGPT and other MCP clients use, with the same tools and the same permissions, so a script and a chat assistant see one product.
Content-Type: application/json and Accept: application/json. The answer is one JSON object.Authorization header.structuredContent object, so you never have to parse prose. The same data is in content[0].text as a JSON string.mcpe_ followed by 64 hex characters. It is shown once, when you create it, and only a hash is stored. Copy it then; a lost key cannot be recovered, only replaced.Authorization: Bearer mcpe_.... Never put a key in a URL or a query string.Each key holds only the permissions you tick when you create it. You can change them later with Edit access, and the change applies to the next call without reconnecting.
| Scope | Allows |
|---|---|
read:email | List, read and search messages, download attachments, list inboxes and folders. |
search:email | Kept for older keys. Search is covered by read:email. |
send:email | Send, reply and forward. |
manage:drafts | Create, update and delete drafts. Sending a draft also needs send:email. |
manage:folders | Move, copy, flag and archive messages; create, rename and delete folders. |
delete:email | Delete and bulk-delete messages. |
manage:contacts | Search contacts from your recent mail. |
schedule:email | Schedule sends and manage scheduled emails. |
manage:automations | Create rules that run on a schedule with nobody watching. |
Start with read:email and add a write scope only when the workflow needs it. A call outside the key's scopes is refused before we contact your mail server. Workspace viewers can only hold read scopes.
A key reaches every inbox in the workspace by default. To pin it to some of them, open Edit access on the key and choose the inboxes under Inbox access. The limit is checked on every call, so a key pinned to the support inbox cannot read the finance one, whatever the request says.
Apps that people connect themselves, such as claude.ai, ChatGPT and other MCP clients, sign in with OAuth instead of a key: dynamic client registration, authorization code with PKCE, and a consent screen where the user picks the scopes and inboxes. See OAuth connection. For your own scripts, servers and workflows, an API key is simpler.
MCP clients start with initialize. A script can skip it, because the server keeps no session, but it is a cheap way to check that the key works and to read the server's instructions.
curl -s https://mcpemails.com/api/mcp \
-H "Authorization: Bearer $MCPEMAILS_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"clientInfo": { "name": "my-agent", "version": "1.0" },
"capabilities": {}
}
}'tools/list returns every tool this key may call, with its description, its input schema and its output schema. Read it at startup instead of hardcoding tool names or arguments: the list is filtered by the key's scopes, and tools gain arguments over time.
curl -s https://mcpemails.com/api/mcp \
-H "Authorization: Bearer $MCPEMAILS_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }'Call a tool by name with its arguments. Most tools take an action that picks what they do, such as list, read or search on email_read. Name the mailbox with inbox (its address) or inbox_id; inbox_list returns both.
curl -s https://mcpemails.com/api/mcp \
-H "Authorization: Bearer $MCPEMAILS_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "email_read",
"arguments": { "action": "list", "inbox": "you@example.com", "unread": true, "limit": 20 }
}
}'A successful result:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [{ "type": "text", "text": "{\"messages\":[...],\"total\":42, ...}" }],
"structuredContent": {
"messages": [{ "id": "...", "subject": "...", "from": { "name": "...", "email": "..." } }],
"total": 42,
"total_is_estimate": false,
"has_more": true,
"next_offset": 20
}
}
}The tool reference describes each tool, and the curl guide has more examples.
There are two kinds. A request that could not run at all comes back as a JSON-RPC error with an HTTP status. A request that reached a tool and was refused by it comes back as a normal result with isError: true, so an AI model reads the reason. Branch on data.error_code or the _meta code, not on the message text.
| Response | When | What to do |
|---|---|---|
HTTP 401, -32001 | Missing, malformed, revoked or expired key. | Fix the Authorization header or create a new key. Retrying will not help. |
HTTP 403, -32004, insufficient_scope | The key lacks a scope this tool action needs. data.required_scopes lists what would allow it. | Add the scope with Edit access, or use another key. |
HTTP 429, -32003, rate_limit_exceeded | Too many requests. data.window says which window filled up. | Wait Retry-After seconds (also in data.retry_after), then retry the same request. |
-32602 | Unknown tool name. data.available_tools lists the names this key can call. | Use a name from tools/list. |
isError: true, invalid_arguments | The arguments failed the tool's schema. Nothing ran. The text names each bad argument, and when an argument name was guessed, it names the one that exists. | Fix the arguments. The same arguments are rejected the same way. |
isError: true, usage_limit_reached | A Free workspace used its monthly email actions. | Wait for the reset date in _meta, or upgrade. Paid plans have no monthly cap. |
isError: true, other | The tool ran and failed: message not found, provider refused, invalid recipient and so on. | Read content[0].text. It says what happened and whether anything was sent. |
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", ...
{
"jsonrpc": "2.0",
"id": 4,
"error": {
"code": -32004,
"message": "Insufficient scope: the 'send:email' scope is required to call email_compose.",
"data": { "error_code": "insufficient_scope", "required_scopes": ["send:email"], ... }
}
}HTTP/1.1 429 Too Many Requests
Retry-After: 12
{
"jsonrpc": "2.0",
"id": 5,
"error": {
"code": -32003,
"message": "Rate limit exceeded",
"data": { "error_code": "rate_limit_exceeded", "window": "per_minute", "retry_after": 12, ... }
}
}{
"jsonrpc": "2.0",
"id": 6,
"result": {
"isError": true,
"content": [{ "type": "text", "text": "Invalid arguments for email_read. The call was rejected before it ran, ..." }],
"_meta": {
"com.mcpemails/invalid_arguments": {
"error_code": "invalid_arguments",
"retryable": false,
"errors": [{ "path": "arguments.since", "message": "..." }]
}
}
}
}Every list and search result ends with the same four fields:
has_more: true when another page exists. Only has_more: false means you have seen everything.next_offset: pass it back as offset, with every other argument unchanged, to get the next page. It is null when has_more is false.total: the number of matches, or null when the provider cannot count them. It never contradicts the page you are holding.total_is_estimate: true when total is an estimate or a floor rather than an exact count.Long message bodies are windowed the same way: when body_truncated is true, read again with body_next_offset as body_offset.
A dropped connection after a send leaves you not knowing whether the mail went out. Pass an idempotency_key and retry with the same key: the work runs once.
curl -s https://mcpemails.com/api/mcp \
-H "Authorization: Bearer $MCPEMAILS_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "email_compose",
"arguments": {
"action": "send",
"inbox": "you@example.com",
"to": ["customer@example.com"],
"subject": "Your order has shipped",
"body": "Hi, your order is on its way.",
"idempotency_key": "order-1042-shipped"
}
}
}'tools/list shows idempotency_key in the input schema of every tool that takes it.idempotent_replay: true, the original status, and the original outcome in result, including the message_id you may have lost.idempotency_key_conflict. Use a new key for a new operation.status: "processing". Retry shortly.status: "unknown". Check Sent before sending again under a new key.{
"idempotency_key": "order-1042-shipped",
"idempotent_replay": true,
"status": "succeeded",
"result": { "message_id": "...", "inbox_id": "...", "status": "sent" },
"message": "This logical request was already processed. ..."
}Any inbox can hold every send for a person to approve. A send from your script then returns an approval_id with status: "pending_approval" instead of going out, and it goes out when someone approves it in the dashboard. This works on every plan and is a good default while a new automation proves itself. A retry with the same idempotency_key reports the approval instead of creating a second one.
Each plan has a per-minute ceiling for the whole workspace. A single key can use the plan's full per-minute limit; several keys share it.
| Plan | Requests per minute | Webhooks |
|---|---|---|
| Free | 60 | No |
| Personal | 120 | No |
| Pro | 300 | Yes |
| Business | 1,000 | Yes |
tools/call responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (in seconds) for whichever window is closer to full, so a client can pace itself before it hits the limit.data.window.Retry-After in seconds. Wait that long, then retry the same request.On Pro and Business, MCPEmails can POST to your URL when mail arrives in an inbox, so a workflow starts on new mail instead of polling. Create an endpoint in the dashboard, choose its inboxes, and copy the signing secret, which is shown once. The URL must be HTTPS, on port 443 or 8443. Use the test button to send a signed ping event.
POST /your/webhook HTTP/1.1
Content-Type: application/json
MCPEmails-Event-Id: evt_...
MCPEmails-Event-Type: message.received
MCPEmails-Signature: t=1792843200,v1=5f2b...
{
"id": "evt_...",
"type": "message.received",
"created_at": "2026-10-24T09:20:00.000Z",
"workspace_id": "...",
"inbox_id": "...",
"folder": "INBOX",
"count": 1,
"message_ids": ["..."]
}type is message.received. message_ids lists the new messages in that inbox's INBOX folder, up to 10 per event; count is how many arrived, which can be more.messages list with the id, from and subject of each message.email_read (action: "read", the inbox_id and a message_id) using an API key. That key's scopes and inbox access apply as usual.Every delivery carries MCPEmails-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of the timestamp, a dot and the raw request body, keyed with the endpoint's secret. The key is the whole secret as shown, whsec_ prefix included. Recompute the signature over the raw body, compare in constant time, and reject a timestamp more than five minutes old.
import crypto from 'node:crypto';
// rawBody: the request body exactly as received, before JSON.parse.
export function verifyMcpemailsSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
String(header ?? '').split(',').map((part) => part.trim().split('=')),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest();
const received = Buffer.from(parts.v1 ?? '', 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}MCPEmails-Event-Id to ignore a delivery you have already processed. A retry carries the same event id.POST, and register its production URL as a webhook endpoint in the MCPEmails dashboard.POST, URL https://mcpemails.com/api/mcp, authentication Generic Credential Type → Header Auth with name Authorization and value Bearer mcpe_....result.structuredContent.NODE_FUNCTION_ALLOW_BUILTIN=crypto.{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "email_read",
"arguments": {
"action": "read",
"inbox_id": "{{ $json.body.inbox_id }}",
"message_id": "{{ $json.body.message_ids[0] }}"
}
}
}For an AI Agent node that reads and sends mail itself, see the n8n guide.
Add the server with a key in a header. This suits headless machines and CI, where there is no browser for the OAuth sign-in:
claude mcp add --transport http mcpemails https://mcpemails.com/api/mcp \
--header "Authorization: Bearer $MCPEMAILS_KEY"The Claude Code guide covers the OAuth sign-in and the scope choices.
Lists every unread message in one inbox, page by page, and waits out a 429:
// node agent.mjs (Node 18 or later, MCPEMAILS_KEY in the environment)
const ENDPOINT = 'https://mcpemails.com/api/mcp';
let nextId = 1;
async function callTool(name, args) {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MCPEMAILS_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ jsonrpc: '2.0', id: nextId++, method: 'tools/call', params: { name, arguments: args } }),
});
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') ?? 1);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
return callTool(name, args);
}
const reply = await res.json();
if (reply.error) throw new Error(`${reply.error.code}: ${reply.error.message}`);
if (reply.result.isError) throw new Error(reply.result.content[0].text);
return reply.result.structuredContent;
}
const inbox = 'you@example.com';
let offset = 0;
do {
const page = await callTool('email_read', { action: 'list', inbox, unread: true, limit: 50, offset });
for (const m of page.messages) console.log(m.id, m.from?.email, m.subject);
offset = page.has_more ? page.next_offset : null;
} while (offset !== null);OpenClaw signs in with OAuth by default; the OpenClaw guide walks through it. To use a scoped key instead, put it in the server entry's headers map and leave auth unset, because OpenClaw ignores a static Authorization header on an entry set to OAuth.
{
"mcp": {
"servers": {
"mcpemails": {
"url": "https://mcpemails.com/api/mcp",
"transport": "streamable-http",
"headers": { "Authorization": "Bearer mcpe_YOUR_KEY_HERE" }
}
}
}
}Create a key, call tools/list, and see your own mail come back as JSON.