Email Services & API for Private Clients · API 0.7.0

Developers

Hosted mailboxes and a sending API on a domain you own. Examples on this page call https://mailservicenow.com/api. A hosted mailbox on a verified domain opens in the web app.

Always set from to an address on a verified domain you own, for example support@example-domain.com. The Markdown file is the guide an agent can fetch. Keys in examples are msn_<your-key>.

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.

SurfaceURL
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, and GET /credits are 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 different error string.
  • 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 /credits with 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/mcp
  • https://mailservicenow.com/api/mcp
  • https://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

CallerCredentialHeaderRoutes
Agent msn_… key Authorization: Bearer msn_<your-key> or X-Api-Key: msn_<your-key> sendMail, inbox, mcp, webhooks, credits
  • If X-Api-Key is present, that header is the only credential checked. A bad key is 401 even when Authorization is 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: 401 MCP 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 is 403 This mailbox requires an operator account.
  • Missing credentials: 401 Missing 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.

  • to may be one address, a comma- or semicolon-separated list, or a JSON array. Maximum 5.
  • text maximum 100KB UTF-8.
  • Agent 200: { "ok": true, "messageId", "creditsRemaining" }.
  • Human 200 omits creditsRemaining.
  • Optional inReplyTo and references copy onto the SMTP message and file the send on that inbox thread. A success may include threadId.
  • 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 return attachmentCount.

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>"
}
ToolArgumentsResult
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

StatuserrorWhen
400to, subject, and text are requiredEmpty send fields
400Max 5 recipients per requestToo many recipients
400Body too large (max 100KB)sendMail text
401Missing Authorization Bearer token or X-Api-KeyNo credential
400url must be https / url host is not allowedWebhook URL
401Invalid API keyWrong msn_… key
401Invalid or expired ID tokenBad Firebase JWT
403Webhooks require an agent API keyFirebase user on /webhooks
403Operator control requires the agent key or an operator accountOther signed-in user on POST /credits
404Message not foundinbox read
405Method not allowedWrong HTTP verb
429Rate limit exceededBurst or daily cap
429Insufficient creditsAgent balance is 0
503Sending temporarily disabledAll sending paused
503Agent key disabledAgent 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 (sendMail and send_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 === false pauses every sender. 503 Sending temporarily disabled. A missing document means sending is on.
  • agent_keys/default.disabled: true, or frozenUntil still in the future, blocks agent sendMail and MCP send_mail only. 503 Agent 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.

  1. Call request_access or POST /requestAccess with contactEmail, domain, agentName, and intendedUse.
  2. Wait for the email at contactEmail. The code is 8 digits and expires in 24 hours.
  3. Call verify_access_request or POST /verifyAccessRequest with requestId and code.
  4. status: "ready" includes access: "standard" and apiKey once. 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 is setting_up. Otherwise status: "unavailable" and the message is "This request could not be completed." There is no key.
  5. Store the key in a secret store. It is shown once. Send it as Authorization: Bearer msn_… or X-Api-Key: msn_…, the same way as any other msn_… key. If X-Api-Key is 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.

  1. ready with a standard msn_… key means you can send and call the domain routes. It does not add your domain or create mailboxes. setting_up has 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 403 Account 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 403 Add a fully hosted mailbox on a verified domain before sending.
  2. POST /addDomain with the standard key, or Add domain on /app.html. Body field domain is the customer hostname. The live call checks DNS immediately. created: true with domain.status failed, before MX is published, is expected. Read domain.dnsInstructions.records. The same normalized name again returns 200 created: false. A second domain is 409 one_domain_per_account. A domain owned by another account is 409 domain_claimed. MCP add_domain uses the same rule.
  3. Publish the returned records. MX mail.mailservicenow.com (priority 10), one SPF TXT copied from the dashboard (SPF), and the DKIM TXT at mail._domainkey (copy value from the response). The preferred MX has to be mail.mailservicenow.com. If Cloudflare Email Routing is still preferred, verification reports MX 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.
  4. POST /verifyDomain with domainId after DNS propagates, or Check DNS in the web app. verified: true sets status to verified. transient: true means call again.
  5. POST /addMailbox for each address (domainId, localPart, mode hosted, forward, or both), or Add mailbox in the web app. The standard key works on this REST call and on MCP add_mailbox. A Firebase ID token also works. The shared agent key is refused with Firebase sign-in required. MCP add_domain adds the one domain. There is no MCP tool for verifyDomain.
  6. Wait about 60 seconds. mtaApplied flips to true after the mail server sync. IMAP, SMTP, and webmail need status: "verified" and mode hosted or both.
  7. 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"}'