Overview
Mail Service Now hosts mailboxes and forwarding on a domain you own, and exposes that mail over HTTPS.
You call https://mailservicenow.com/api. You do not open SMTP yourself.
Always set from to an address on a verified domain you own, for example support@example-domain.com. If it is omitted and the account has exactly one sendable hosted mailbox, that mailbox is used as a fallback.
Inbound MX is mail.mailservicenow.com. The shared inbox@mailservicenow.com mailbox stays with Mail Service Now. A hosted mailbox on a verified domain opens in the web app.
| Surface | URL |
|---|---|
| sendMail | POST https://mailservicenow.com/api/sendMail |
| MCP | POST https://mailservicenow.com/api/mcp |
| inbox | GET https://mailservicenow.com/api/inbox |
| drafts | https://mailservicenow.com/api/drafts |
| credits | GET or POST https://mailservicenow.com/api/credits |
| OpenAPI | /docs/openapi.json |
The legacy page /docs/agents.html still works and points here.
Credits
- One agent send costs 1 credit. The default key starts at 100 (
agent_credits/default.remaining). - Humans signed in with Firebase are not charged.
- Inbox reads,
get_credits, andGET /creditsare free. A failed sign-in is 401 and does not spend a credit. - Zero credits returns 429
Insufficient credits. The rate limit uses the same status with a differenterrorstring. - The credit is taken after validation and before SMTP. An MTA failure does not refund it. A 400, a rate-limit 429, or a kill-switch 503 does not spend a credit.
- Signed-in Settings on /app.html shows credits remaining, the agent key’s last four characters, and whether that key is active. That page is a status view, not billing. The same payload is
GET /creditswith a Firebase ID token or the agent key.
curl -sS "$MSN_API_BASE/credits" \
-H "Authorization: Bearer $MSN_AGENT_API_KEY"
MCP support
Mail Service Now has an MCP server named mailservicenow, version 0.7.0, protocol 2024-11-05.
GET needs no key and returns the tool names. POST is one JSON-RPC body.
https://us-central1-mailservicenow.cloudfunctions.net/mcphttps://mailservicenow.com/api/mcphttps://mailservicenow.web.app/api/mcp
GET /mcp lists
request_access, verify_access_request, send_mail, get_credits,
list_inbox, get_message, mark_read, list_domain_allowlist,
add_domain_allowlist, remove_domain_allowlist, check_domain_allowlist,
add_mailbox, issue_mailbox_credential, set_mailbox_mode, delete_mailbox,
and operator_remove_domain.
Full MCP docs: Agent API. The tool reference on this page is MCP. Agent summary: llms.txt.
Quickstart
Ask Mail Service Now for an agent key. Put it in the environment. Do not commit it.
export MSN_API_BASE=https://mailservicenow.com/api
export MSN_AGENT_API_KEY='msn_<your-key>'
Send with curl
curl -sS -X POST \
"$MSN_API_BASE/sendMail" \
-H "Authorization: Bearer $MSN_AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "recipient@example.com",
"from": "support@example-domain.com",
"subject": "Hello from an agent",
"text": "Sent via MailServiceNow agent API."
}'
Agent success:
{
"ok": true,
"messageId": "<id@mail.mailservicenow.com>",
"creditsRemaining": 99
}
Node.js
const res = await fetch(`${process.env.MSN_API_BASE}/sendMail`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MSN_AGENT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: "recipient@example.com",
from: "support@example-domain.com",
subject: "Hello from an agent",
text: "Sent via MailServiceNow.",
}),
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(`${res.status} ${data.error}`);
Python
import json, os, urllib.request
req = urllib.request.Request(
os.environ["MSN_API_BASE"] + "/sendMail",
data=json.dumps({
"to": "recipient@example.com",
"from": "support@example-domain.com",
"subject": "Hello from an agent",
"text": "Sent via MailServiceNow.",
}).encode(),
headers={
"Authorization": "Bearer " + os.environ["MSN_AGENT_API_KEY"],
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req) as res:
print(json.load(res)["messageId"])
MCP send is in MCP. Check credits before a batch with get_credits. A standard key does not add your domain. After ready, follow Domain and mailboxes.
Auth
| Caller | Credential | Header | Routes |
|---|---|---|---|
| Agent | msn_… key |
Authorization: Bearer msn_<your-key> or X-Api-Key: msn_<your-key> |
sendMail, inbox, mcp, webhooks, credits |
- If
X-Api-Keyis present, that header is the only credential checked. A bad key is 401 even whenAuthorizationis also set. - A Bearer value with three dot-separated segments is checked as a Firebase ID token. Any other Bearer value is compared to the agent key.
- MCP refuses Firebase tokens:
401MCP requires agent API key (Bearer msn_…). - Mail routes (
sendMail,inbox,attachment,drafts) accept a Firebase ID token only for an admin account. Any other Firebase user is403This mailbox requires an operator account. - Missing credentials:
401Missing Authorization Bearer token or X-Api-Key.
Keys are issued by Mail Service Now and stored server-side. Public docs never contain a live key.
REST API
Base: https://mailservicenow.com/api.
Machine-readable contract: OpenAPI 0.7.0.
POST /sendMail
JSON body to, subject, text, and from. The body is plain text. Always set from to an address on a verified domain you own, for example support@example-domain.com. If it's omitted and the account has exactly one sendable hosted mailbox, that mailbox is used as a fallback; with more than one, omitting from is an error. The shared agent key sends as noreply@mailservicenow.com. Optional attachments on this route only (see the AI Markdown). MCP send_mail uses the same from rule and stays text-only.
tomay be one address, a comma- or semicolon-separated list, or a JSON array. Maximum 5.textmaximum 100KB UTF-8.- Agent
200:{ "ok": true, "messageId", "creditsRemaining" }. - Human
200omitscreditsRemaining. - Optional
inReplyToandreferencescopy onto the SMTP message and file the send on that inbox thread. A success may includethreadId. - Optional
attachments: up to 3 files, 2MB each, 4MB total. JPEG, PNG, GIF, WebP, PDF, text, CSV, docx, xlsx, pptx. A threaded send stores the blobs and may returnattachmentCount.
Order of checks: global kill switch (503), agent-key disable (503, agents only), rate limit (429), validation (400), credit decrement, SMTP. Auth runs first. A 401 or a 503 does not spend a credit.
GET /inbox
List with ?limit=25 (default 25, max 50) and optional mailbox
(@mailservicenow.com). Response is { "ok": true, "messages": [...], "unreadInPage": N }.
unreadInPage is the unread count in that page, not the whole mailbox.
List rows include id, messageId, from, to, subject, preview, date, ingestedAt, unread, folder (inbox when the stored field is missing), source, and attachments (metadata only, possibly empty). They omit the body and the file bytes.
curl -sS "$MSN_API_BASE/inbox?limit=25" \
-H "Authorization: Bearer $MSN_AGENT_API_KEY"
Read one with ?id= or ?messageId=. That marks the row read and adds text
(and html when the message had HTML). If both query params are set, id wins.
Unknown id: 404 Message not found. No credit cost.
?view=threads lists conversations in Inbox unless folder is archive or trash.
?thread=<64-hex> returns that thread oldest first and marks it read.
POST /inbox with {"thread","unread"} or {"thread","folder"} marks the thread unread or moves it. No credit.
?q= searches subject, from, and body (newest 200 inbound and 200 outbound). It does not mark mail read.
Optional folder on q= limits hits to that folder.
If id or messageId is also set, the single-message read wins, then thread, then q.
Download a stored file with GET /attachment?id=&index=0&box=inbound (or box=outbound). Same auth as inbox. No credit.
Drafts
Firebase users only. POST /drafts saves to, subject, text, and optional thread fields.
The response draft.id is the Firestore id. GET /drafts lists that user's drafts.
Send with POST /sendMail and read messageId, then DELETE /drafts?id=.
An agent key is 403. No credit.
Inbound webhooks
POST /webhooks with the agent key stores one HTTPS URL for the default key.
A newly stored message (not a duplicate Message-ID) POSTs inbound.received there.
The JSON has id, messageId, from, to, subject, preview, timestamp, and mailbox.
It does not include the body.
X-MSN-Signature is sha256= plus HMAC-SHA256 of <unix seconds>.<raw body> using the whsec_… secret returned by this route.
One retry after 750ms on a non-2xx or a dropped connection. Each attempt times out at 5 seconds. A failed delivery is logged and ingest still returns 200.
No credit. GET reads the URL and secret. DELETE clears them. rotateSecret: true replaces the secret.
Private and loopback URLs are rejected.
curl -sS -X POST "$MSN_API_BASE/webhooks" \
-H "Authorization: Bearer $MSN_AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/msn"}'
MCP
JSON-RPC 2.0 on POST /mcp, protocol 2024-11-05, server mailservicenow version 0.7.0.
Each call is one HTTP request and one JSON body. This is not an SSE or streamable-HTTP session.
A JSON array is a batch. request_access, verify_access_request, and get_request_status need no key. get_account_status needs the account key and returns only that account. A standard-access customer key can call the customer tools. add_mailbox, issue_mailbox_credential, set_mailbox_mode, and delete_mailbox also accept a Firebase ID token once account setup is complete. The shared agent key is refused on those mailbox tools. Admin allowlist tools stay on an admin Firebase token.
Each customer mailbox is hosted (stored; webmail and IMAP), forward (relay only; no webmail or IMAP login), or both (stored and a copy forwarded).
forwardTo is an array of at most 5 addresses, required for forward and both. A mailbox cannot forward to itself or into a loop.
The mail server syncs mode changes from mtaMailboxExport about every 60 seconds. mtaApplied and its timestamp confirm the change is live. Switching mode does not delete stored mail.
GET /mcp needs no key and returns the tool names:
curl -sS "$MSN_API_BASE/mcp"
{
"ok": true,
"name": "mailservicenow",
"version": "0.7.0",
"transport": "json-rpc-2.0",
"protocolVersion": "2024-11-05",
"tools": ["request_access", "verify_access_request", "get_request_status", "get_account_status", "send_mail", "get_credits", "list_inbox", "get_message", "mark_read", "add_mailbox", "issue_mailbox_credential", "set_mailbox_mode", "delete_mailbox"],
"auth": "Authorization: Bearer <MSN_AGENT_API_KEY>"
}
| Tool | Arguments | Result |
|---|---|---|
request_access |
contactEmail, domain, agentName, intendedUse |
No key. Emails a one-time code. See How an agent requests access. |
verify_access_request |
requestId, code |
No key. Returns a standard apiKey once, or setting_up. |
get_request_status |
requestId, email |
No key. 10 per hour per IP and 6 per hour per email, counted separately from request_access. See Check if my account is ready. |
get_account_status |
none. The key selects the account. | Account key only. Status, accessLevel, domain verification, mailbox readiness, and canSend. |
send_mail |
to, subject, text. Always set from to an address on a verified domain you own, for example support@example-domain.com. If it's omitted and the account has exactly one sendable hosted mailbox, that mailbox is used as a fallback; with more than one, omitting from is an error. |
messageId and creditsRemaining. Costs 1 credit. Omitting from with more than one mailbox is 400 Choose the address to send from. |
get_credits |
none | { "ok": true, "remaining": 100 } |
list_inbox |
optional limit, mailbox |
Same list as GET /inbox. Free. |
get_message |
id or messageId |
One message, marked read. Free. |
mark_read |
id or messageId, optional unread |
Sets the inbound unread flag. Free. Omit unread to mark read. |
add_mailbox |
domainId, localPart, mode (hosted, forward, or both), forwardTo (max 5) |
Creates a mailbox. Returns mode, forwardTo, and delivery. Account setup must be complete. A standard key is accepted. The shared agent key is refused. No credit. Add the domain with POST /addDomain first. See Domain and mailboxes. An admin sign-in may add a hosted mailbox on mailservicenow.com. |
issue_mailbox_credential |
address |
Issues or resets the IMAP/SMTP app password. Returns the password once and stores a SHA512-CRYPT hash. A standard key is accepted for a mailbox on that account. The shared agent key is refused. Forward-only is refused. |
set_mailbox_mode |
domainId, localPart or mailboxId, mode, forwardTo |
Updates delivery. Same output. Does not delete stored mail. Forward-only has no IMAP login. |
delete_mailbox |
address, or domainId and localPart |
Removes the mailbox record. Routing drops on the next sync, about every 60 seconds. The Maildir is kept. Returns a delivery removal with mtaApplied false. A protected address cannot be deleted (409). The last active mailbox on a verified domain is 409: This is the last mailbox on <domain>. Add another mailbox first, or contact support to remove the domain. There is no force flag. |
send_mail
curl -sS -X POST "$MSN_API_BASE/mcp" \
-H "Authorization: Bearer $MSN_AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "send_mail",
"arguments": {
"to": "recipient@example.com",
"from": "support@example-domain.com",
"subject": "Hello from MCP",
"text": "Sent via MailServiceNow MCP."
}
}
}'
Success is HTTP 200 with result.structuredContent.ok === true.
Rate limit, empty credits, not found, and the kill switch are also HTTP 200, with result.isError: true and structuredContent.status.
Read structuredContent.error. Do not treat every HTTP 200 as a sent message.
Other methods: initialize, notifications/initialized, initialized, ping, tools/list.
Full request and response bodies are in the Download .md guide.
Errors & rate limits
| Status | error | When |
|---|---|---|
| 400 | to, subject, and text are required | Empty send fields |
| 400 | Max 5 recipients per request | Too many recipients |
| 400 | Body too large (max 100KB) | sendMail text |
| 401 | Missing Authorization Bearer token or X-Api-Key | No credential |
| 400 | url must be https / url host is not allowed | Webhook URL |
| 401 | Invalid API key | Wrong msn_… key |
| 401 | Invalid or expired ID token | Bad Firebase JWT |
| 403 | Webhooks require an agent API key | Firebase user on /webhooks |
| 403 | Operator control requires the agent key or an operator account | Other signed-in user on POST /credits |
| 404 | Message not found | inbox read |
| 405 | Method not allowed | Wrong HTTP verb |
| 429 | Rate limit exceeded | Burst or daily cap |
| 429 | Insufficient credits | Agent balance is 0 |
| 503 | Sending temporarily disabled | All sending paused |
| 503 | Agent key disabled | Agent key frozen. Humans can still send. |
There is no HTTP 402 on the live send path. Branch on the error string when the status is 429.
Limits
- Send (
sendMailandsend_mail): 5 per minute and 50 per UTC day per caller. The agent key shares one bucket,agent:default. Each Firebase user has a separate bucket with the same numbers. - Inbox, list, get, credits, mark_read, webhooks: no credit charge and no send rate limit.
Kill switch
Freeze sends without a redeploy. Settings shows the controls when the signed-in account is an admin.
The agent key can freeze itself with POST /credits.
Pausing every sender (sendingEnabled) requires an admin Firebase account.
config/send.enabled === falsepauses every sender. 503Sending temporarily disabled. A missing document means sending is on.agent_keys/default.disabled: true, orfrozenUntilstill in the future, blocks agentsendMailand MCPsend_mailonly. 503Agent key disabled.- Both checks run before the rate limit and before a credit is spent. Reads keep working.
curl -sS -X POST "$MSN_API_BASE/credits" \
-H "Authorization: Bearer $MSN_AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"disabled":true}'
Unfreeze with {"disabled":false}. The next agent send works again and spends one credit.
On MCP, 429 and 503 arrive as HTTP 200 tool errors (isError: true, structuredContent.status).
Validation failures arrive as JSON-RPC error.code -32000 with data.status 400.
Auth failures stay HTTP 401.
Partner integrators
A generic recipe for any product is Any agent product. It uses the same send, inbox, and key steps as the rest of this page.
Download for AI
One Markdown file covers base URLs, auth, every public endpoint and MCP tool, request and response examples, error codes, rate limits, and credits.
After deploy:
curl -fsSL https://mailservicenow.com/docs/mailservicenow-for-ai.md
How an agent requests access
No API key is required. The code is emailed and is not in the response. The first response does not say whether an account is already on file.
- Call
request_accessorPOST /requestAccesswithcontactEmail,domain,agentName, andintendedUse. - Wait for the email at
contactEmail. The code is 8 digits and expires in 24 hours. - Call
verify_access_requestorPOST /verifyAccessRequestwithrequestIdandcode. status: "ready"includesaccess: "standard"andapiKeyonce. That key can call send, the domain and mailbox routes, webhooks, and the customer MCP tools. It does not add the domain or create mailboxes. Continue with Domain and mailboxes. Default quotas are 100 credits, 5 sends per minute, and 50 per UTC day.status: "setting_up"has no key. The message is "We're setting up your account. We'll email you when it's ready." An account already on file is left unchanged. If it can already send, the response is a standard key once. If setup is still in progress, the response issetting_up. Otherwisestatus: "unavailable"and the message is "This request could not be completed." There is no key.- Store the key in a secret store. It is shown once. Send it as
Authorization: Bearer msn_…orX-Api-Key: msn_…, the same way as any othermsn_…key. IfX-Api-Keyis set, it is the only credential checked. Do not commit the key.
A standard key gets 403 on admin routes and on the admin MCP tools. request_access allows 5 requests per hour per IP and 3 per hour per email. 429 includes retryAfter seconds. Wrong code is 401. Five failures lock the code (401). An expired code is 410. An unknown requestId is 404. Completing the same request again is 409. Invalid input is 400.
curl
curl -sS -X POST "$MSN_API_BASE/requestAccess" \
-H "Content-Type: application/json" \
-d '{
"contactEmail": "ops@example-domain.com",
"domain": "example-domain.com",
"agentName": "ExampleAgent",
"intendedUse": "Send a notification from an agent"
}'
curl -sS -X POST "$MSN_API_BASE/verifyAccessRequest" \
-H "Content-Type: application/json" \
-d '{"requestId":"ar_0123456789abcdef01234567","code":"12345678"}'
MCP JSON-RPC
curl -sS -X POST "$MSN_API_BASE/mcp" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "request_access",
"arguments": {
"contactEmail": "ops@example-domain.com",
"domain": "example-domain.com",
"agentName": "ExampleAgent",
"intendedUse": "Send a notification from an agent"
}
}
}'
Then call verify_access_request with requestId and code. Read structuredContent. status is ready or setting_up. Full contract: Markdown guide (How an agent requests access), llms.txt, OpenAPI.
Follow the agent onboarding sequence. An account can hold one domain.
Check if my account is ready
Keep the requestId from request_access. If verify returned setting_up, no key exists yet. Mail Service Now emails that address when the account is ready.
Once the account is ready, get the key with a fresh request_access plus verify_access_request. The key is shown once. "Do NOT resend the same requestId" applies only while the result is pending.
You can also poll. Poll about every 20 minutes. get_request_status allows 10 per hour per IP and 6 per hour per email, counted separately from request_access, so that poll has room for an extra check.
get_request_status or POST /getRequestStatus takes requestId and email (the contactEmail you sent). No API key.
Unknown email, a real email with the wrong requestId, and an unknown requestId all return the same HTTP 200 body:
{ "ok": true, "status": "pending", "ready": false }
A matching pair adds requestId. status is ready, setting_up, pending, or unavailable. ready is true only when the account is ready.
A limited response is HTTP 429 { "ok": false, "error": "Rate limit exceeded", "retryAfter": 3600 } whether or not the email is on file.
curl -sS -X POST "$MSN_API_BASE/getRequestStatus" \
-H "Content-Type: application/json" \
-d '{"requestId":"ar_0123456789abcdef01234567","email":"ops@example-domain.com"}'
When the account is ready, the body looks like this:
{ "ok": true, "status": "ready", "ready": true, "requestId": "ar_0123456789abcdef01234567" }
After verify_access_request, call get_account_status or POST /getAccountStatus with that account's own key.
It returns status, accessLevel, each domain's verification (mx, spf, dkim, dmarc), mailbox ready, and canSend.
When canSend is false, reason is a short next step. The shared agent key, the mail-server sync token, and another account's key are refused. The call returns only the account that owns the key.
A hosted address needs a domain you own. Publish MX, SPF, DKIM, and DMARC, then create the mailbox. DNS steps: Domain setup / deliverability.
{
"ok": true,
"status": "ready",
"accessLevel": "standard",
"ready": true,
"domains": [{
"name": "example-domain.com",
"status": "verified",
"verification": { "mx": "verified", "spf": "verified", "dkim": "verified", "dmarc": "unchecked" }
}],
"mailboxes": [],
"canSend": false,
"reason": "Add a fully hosted mailbox on a verified domain before sending."
}
After you have a standard key: set up your domain and mailboxes
DNS for each record, including DMARC, is in Domain setup / deliverability. This list is the order of API calls.
status: "ready" and apiKey do not add the hostname or create mailboxes.
domain on the access request is only the name you sent.
intendedUse is a note. Writing support@ or orders@ there does not provision them.
Longer copy, including the false code_sent pending row: Markdown guide.
-
readywith a standardmsn_…key means you can send and call the domain routes. It does not add your domain or create mailboxes.setting_uphas no key. The message is "We're setting up your account. We'll email you when it's ready." Domain and mailbox calls then return 403Account setup is not complete yet. Once you have the key, the remaining work is the rest of this list. A send before a hosted mailbox on a verified domain returns 403Add a fully hosted mailbox on a verified domain before sending. -
POST /addDomainwith the standard key, or Add domain on /app.html. Body fielddomainis the customer hostname. The live call checks DNS immediately.created: truewithdomain.statusfailed, before MX is published, is expected. Readdomain.dnsInstructions.records. The same normalized name again returns 200created: false. A second domain is 409one_domain_per_account. A domain owned by another account is 409domain_claimed. MCPadd_domainuses the same rule. -
Publish the returned records. MX
mail.mailservicenow.com(priority 10), one SPF TXT copied from the dashboard (SPF), and the DKIM TXT atmail._domainkey(copyvaluefrom the response). The preferred MX has to bemail.mailservicenow.com. If Cloudflare Email Routing is still preferred, verification reportsMX points at route1.mx.cloudflare.net. Cut over when you mean to. Replacing that MX, or any previous host, stops mail that host delivers today. -
POST /verifyDomainwithdomainIdafter DNS propagates, or Check DNS in the web app.verified: truesetsstatustoverified.transient: truemeans call again. -
POST /addMailboxfor each address (domainId,localPart,modehosted,forward, orboth), or Add mailbox in the web app. The standard key works on this REST call and on MCPadd_mailbox. A Firebase ID token also works. The shared agent key is refused withFirebase sign-in required. MCPadd_domainadds the one domain. There is no MCP tool forverifyDomain. - Wait about 60 seconds.
mtaAppliedflips to true after the mail server sync. IMAP, SMTP, and webmail needstatus: "verified"and modehostedorboth. - Optional:
POST /webhooks, a proof send,GET /credits.
An admin row with status: "pending" and storedStatus: "code_sent" (including an expired code) has decidable: false.
A decision returns 409 This request is not ready for a decision.
That row is not why mailboxes are missing.
setting_up is the case with no key. After ready, finish this list.
curl -sS -X POST "$MSN_API_BASE/addDomain" \
-H "Authorization: Bearer msn_<your-key>" \
-H "Content-Type: application/json" \
-d '{"domain":"example-domain.com"}'
curl -sS -X POST "$MSN_API_BASE/verifyDomain" \
-H "Authorization: Bearer msn_<your-key>" \
-H "Content-Type: application/json" \
-d '{"domainId":"example-domain.com"}'
curl -sS -X POST "$MSN_API_BASE/addMailbox" \
-H "Authorization: Bearer msn_<your-key>" \
-H "Content-Type: application/json" \
-d '{"domainId":"example-domain.com","localPart":"orders","mode":"hosted"}'