# MailServiceNow for AI agents

Single system of record for integrating MailServiceNow as an outbound email backend.
An agent can fetch this file and implement against it without reading the website.

- Docs revision: **2026-09-28**
- API / MCP server version: **0.7.0**
- Protocol: MCP-flavored JSON-RPC **2024-11-05** (stateless HTTPS, not SSE)
- OpenAPI: <https://mailservicenow.com/docs/openapi.json>
- Developers: <https://mailservicenow.com/developers/>
- This file: <https://mailservicenow.com/docs/mailservicenow-for-ai.md>
- llms.txt: <https://mailservicenow.com/llms.txt>

Never put a real key in source, tickets, or prompts. Examples use `msn_<your-key>`.

A `ready` response and a standard key do not add your domain or create mailboxes. After the key, follow [After you have a standard key: set up your domain and mailboxes](#after-you-have-a-standard-key-set-up-your-domain-and-mailboxes).

## What this product is

Mail Service Now hosts mailboxes and forwarding on a domain you own, and lets software send and read that mail over HTTPS.
A hosted mailbox on a verified domain opens in the web app.
An agent requests its own key with `request_access` or `POST /api/requestAccess`.
The shared `inbox@mailservicenow.com` mailbox stays with Mail Service Now.

Callers do not open SMTP. Always set `from` on `sendMail` and `send_mail`.

## How mail is sent

| Fact | Value |
| --- | --- |
| Mail host | `mail.mailservicenow.com`. The sending address is the SPF value on the domain card |
| How you send | Call HTTPS. The service submits on port **587** with STARTTLS. |
| From | 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. The shared agent key sends as `"Mail Service Now" <noreply@mailservicenow.com>`. |
| Inbound MX | `mail.mailservicenow.com` |
| Shared mailbox that stays with Mail Service Now | `inbox@mailservicenow.com` |

Callers do not open SMTP. Always set `from` on the HTTPS send.

## Base URLs

Prefer the Cloud Functions host. Hosting rewrites hit the same functions.

| Surface | Functions (preferred) | Hosting rewrite |
| --- | --- | --- |
| sendMail | `POST https://us-central1-mailservicenow.cloudfunctions.net/sendMail` | `POST https://mailservicenow.com/api/sendMail` |
| MCP | `GET` or `POST https://us-central1-mailservicenow.cloudfunctions.net/mcp` | `https://mailservicenow.com/api/mcp` |
| inbox | `GET https://us-central1-mailservicenow.cloudfunctions.net/inbox` | `GET https://mailservicenow.com/api/inbox` |
| drafts | `GET`, `POST`, or `DELETE https://us-central1-mailservicenow.cloudfunctions.net/drafts` | `https://mailservicenow.com/api/drafts` |
| webhooks | `GET`, `POST`, or `DELETE https://us-central1-mailservicenow.cloudfunctions.net/webhooks` | `https://mailservicenow.com/api/webhooks` |
| credits | `GET` or `POST https://us-central1-mailservicenow.cloudfunctions.net/credits` | `https://mailservicenow.com/api/credits` |
| requestAccess | `POST https://us-central1-mailservicenow.cloudfunctions.net/requestAccess` | `POST https://mailservicenow.com/api/requestAccess` |
| verifyAccessRequest | `POST https://us-central1-mailservicenow.cloudfunctions.net/verifyAccessRequest` | `POST https://mailservicenow.com/api/verifyAccessRequest` |
| addDomain | `POST https://us-central1-mailservicenow.cloudfunctions.net/addDomain` | `POST https://mailservicenow.com/api/addDomain` |
| listDomains | `GET https://us-central1-mailservicenow.cloudfunctions.net/listDomains` | `GET https://mailservicenow.com/api/listDomains` |
| getDnsInstructions | `GET https://us-central1-mailservicenow.cloudfunctions.net/getDnsInstructions` | `GET https://mailservicenow.com/api/getDnsInstructions` |
| verifyDomain | `POST https://us-central1-mailservicenow.cloudfunctions.net/verifyDomain` | `POST https://mailservicenow.com/api/verifyDomain` |
| addMailbox | `POST https://us-central1-mailservicenow.cloudfunctions.net/addMailbox` | `POST https://mailservicenow.com/api/addMailbox` |

`https://mailservicenow.web.app/api/...` is the same rewrite set as the apex host.

Suggested integrator environment (names only; values are yours):

```bash
MSN_API_BASE=https://us-central1-mailservicenow.cloudfunctions.net
MSN_AGENT_API_KEY=msn_<your-key>
```

## Authentication

Two public caller types.

| Caller | Credential | Header | Works on |
| --- | --- | --- | --- |
| Agent | API key `msn_…` | `Authorization: Bearer msn_<your-key>` **or** `X-Api-Key: msn_<your-key>` | `sendMail`, `inbox`, `attachment`, `mcp`, `webhooks`, `credits` |
| Human | Admin Firebase ID token (JWT) | `Authorization: Bearer <ID_TOKEN>` | `sendMail`, `inbox`, `attachment`, `drafts`, `credits` (not `mcp`). Mail routes require an admin email. |

Rules implemented by the functions:

1. If `X-Api-Key` is present, that header is the only credential checked. A wrong key is **401** `Invalid API key` even if `Authorization` is also set.
2. Otherwise `Authorization: Bearer …` is required. **401** `Missing Authorization Bearer token or X-Api-Key` when it is absent.
3. A Bearer value of three dot-separated segments is verified as a Firebase ID token. Any other Bearer value is compared to the agent API key.
4. MCP accepts the agent key only. A Firebase JWT returns **401** `MCP requires agent API key (Bearer msn_…)`.
5. `sendMail`, `inbox`, `attachment`, and `drafts` accept a Firebase ID token only when the token email is an admin account (the same allowlist that can pause all sending). Any other Firebase user is **403** `This mailbox requires an operator account`. No mail is read and no credit is spent. `GET /credits` still returns the balance to any valid Firebase session (Phase 6). `POST /credits` stays limited to an admin or the agent key.
6. An agent requests its own key. Call `request_access` (MCP) or `POST /requestAccess`, read the emailed 8-digit code, then call `verify_access_request`. A domain on the domain list returns a standard key. A domain that is not on that list stays `setting_up`. Mail Service Now only finishes those requests that are still being set up. It does not hand an agent its key. Do not invent a key. Do not commit it. See [How an agent requests access](#how-an-agent-requests-access).

Browser clients may call the HTTPS functions: responses include `Access-Control-Allow-Origin: *`. Preflight `OPTIONS` returns **204**.

## Credits

- Each **successful agent** send (`sendMail` with an agent key, or MCP `send_mail`) costs **1 credit**.
- The default agent key starts at **100** credits (`agent_credits/default`). This file does not state a price.
- Admin users authenticated with a Firebase ID token are **not** charged. Other Firebase users are **403** on send and never reach the credit counter.
- `get_credits`, `GET /credits`, `list_inbox`, `get_message`, `mark_read`, `GET /inbox`, `POST /inbox`, and `/webhooks` do not spend credits.
- At 0 credits the send returns **429** with `error` exactly `Insufficient credits`. That is the same HTTP status as the rate limit; branch on the `error` string.
- The credit is decremented **after** input validation and **before** SMTP. If the MTA then fails, the credit stays spent. A **429** rate limit or **400** validation error does not spend a credit. A **503** kill switch does not spend a credit. An auth failure is **401** and does not spend a credit.
- Signed-in humans see the same agent balance in **Settings** on `/app.html`. That screen is a status view, not billing. `GET /credits` is the API behind it. MCP `get_credits` still returns only `{ "ok": true, "remaining" }`.

## Rate limits

Enforced per caller id. The agent key’s id is `agent:default` (one shared bucket for every agent send). Each Firebase user has their own bucket. Counters are UTC.

| Route | Burst | Daily | HTTP |
| --- | --- | --- | --- |
| `sendMail` and MCP `send_mail` | 5 per minute | 50 per UTC day | 429 `Rate limit exceeded` |
| `GET /inbox` (including `q=`), `POST /inbox`, `GET /attachment`, `/drafts`, `/webhooks`, `GET /credits`, `POST /credits`, MCP `list_inbox`, `get_message`, `get_credits`, `mark_read` | none | none | — |

On `sendMail` / `send_mail` the order is: global kill switch, then agent-key disable (agent callers only), then rate limit, then body validation, then credit, then SMTP. A request that later fails validation has already consumed one rate-limit slot. A kill-switch **503** has not.

`GET /mcp` (discovery) is unauthenticated and is not rate limited by this function.

## Kill switch

Admins freeze sends without a redeploy. Both flags are checked on `sendMail` and MCP `send_mail` after auth and **before** the rate limit and the credit decrement. A frozen send does not spend a credit. Inbox reads, `get_credits`, and `GET /credits` keep working. A bad credential is still **401** and never reaches these flags.

| Flag | Firestore | Who it blocks | HTTP |
| --- | --- | --- | --- |
| Pause all sending | `config/send.enabled === false`. A missing document means sending is on. | Humans and agents | **503** `Sending temporarily disabled` |
| Disable the agent key | `agent_keys/default.disabled === true`, or `frozenUntil` set to a time still in the future | Agent `sendMail` and MCP `send_mail` only. Human Firebase sends continue. | **503** `Agent key disabled` |

Signed-in admin accounts change both flags from **Settings** on `/app.html` (Agent credits). The response field `canControl` is true for the agent key and for those admin accounts. `canPauseAll` is true only for an admin Firebase account. Any other signed-in user can read the balance and the key status. Their `POST /credits` is **403** `Operator control requires the agent key or an operator account`. The agent key can freeze and unfreeze itself. It cannot pause every sender: that `POST` is **403** `Pausing all sending requires an operator account`.

`GET /credits` with the agent key or a Firebase ID token:

```bash
curl -sS "$MSN_API_BASE/credits" \
  -H "Authorization: Bearer $MSN_AGENT_API_KEY"
```

```json
{
  "ok": true,
  "credits": { "remaining": 100, "initial": 100, "humansCharged": false, "authFailureBurnsCredit": false },
  "agentKey": { "id": "default", "last4": "ab12", "status": "active", "disabled": false, "frozenUntil": null },
  "sending": { "enabled": true },
  "canControl": true,
  "canPauseAll": false,
  "docs": "https://mailservicenow.com/developers/#credits"
}
```

`last4` is the last four characters of the server-side key. The full key is not returned. `status` is `active` or `disabled`.

Freeze and unfreeze the default agent key:

```bash
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}`. A timed freeze is `{"frozenUntil":"2026-09-24T00:00:00.000Z"}`. Clear it with `{"frozenUntil":null}`. An admin Firebase token may also send `{"sendingEnabled":false}` to pause every sender, and `{"sendingEnabled":true}` to resume. While the key is disabled, agent `sendMail` and MCP `send_mail` return **503** `Agent key disabled` and the credit counter does not move.

## sendMail

`POST` `Content-Type: application/json`

```json
{
  "to": "recipient@example.com",
  "from": "support@example-domain.com",
  "subject": "Hello from an agent",
  "text": "Sent via MailServiceNow."
}
```

- `to` is one address, a comma- or semicolon-separated string, or a JSON array of addresses. Maximum **5** recipients.
- `subject` and `text` are required and must be non-empty after trim.
- `text` maximum is **100KB** UTF-8.
- `html` is not accepted on send. Bodies are 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 (**400** `Choose the address to send from.`). The shared agent key sends as `noreply@mailservicenow.com`.
- Optional `inReplyTo` (one Message-ID) and `references` (Message-IDs) are copied onto the SMTP message. When either is set, the sent copy is filed on that inbox thread. Invalid values are **400** `Invalid inReplyTo` or `Invalid references`. Omit both for a normal send.
- Optional `attachments` is an array of `{ "filename", "contentType", "contentBase64" }`. Limits: **3 files**, **2MB** each, **4MB** total decoded. Allowed types: JPEG, PNG, GIF, WebP, PDF, plain text, CSV, and Office Open XML (docx, xlsx, pptx). Bytes are not stored in Firestore. A threaded send stores them in Firebase Storage and returns `attachmentCount` plus `attachmentsStored`. A send with no thread link still attaches the files on the SMTP message but does not file a copy in the inbox. Set `from` on that send the same way as any other send. MCP `send_mail` rejects attachments.

Agent success **200**:

```json
{
  "ok": true,
  "messageId": "<id@mail.mailservicenow.com>",
  "creditsRemaining": 99
}
```

Human success **200** omits `creditsRemaining`:

```json
{
  "ok": true,
  "messageId": "<id@mail.mailservicenow.com>"
}
```

`messageId` is the SMTP Message-ID assigned by the MTA. A threaded send may also include `threadId` (64 hex chars). `list_inbox` stays a flat message list.

### curl

```bash
curl -sS -X POST \
  "$MSN_API_BASE/sendMail" \
  -H "Authorization: Bearer msn_<your-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."
  }'
```

Equivalent header: `-H "X-Api-Key: msn_<your-key>"` (do not send a wrong `X-Api-Key` alongside a good Bearer token; the key header wins).

### Node.js

```js
const base = process.env.MSN_API_BASE;
const key = process.env.MSN_AGENT_API_KEY; // msn_<your-key>
const res = await fetch(`${base}/sendMail`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${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 || "send failed"}`);
}
// data.messageId, data.creditsRemaining (agents only)
```

### Python

```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:
    data = json.load(res)
# data["messageId"], data.get("creditsRemaining")
```

## inbox

`GET` with the same agent key or an admin Firebase ID token. Any other Firebase user is **403**. No credits. Listing does not mark mail read. Fetching one message does.

List (default `limit` 25, max 50):

```bash
curl -sS \
  "$MSN_API_BASE/inbox?limit=25" \
  -H "Authorization: Bearer msn_<your-key>"
```

```json
{
  "ok": true,
  "messages": [
    {
      "id": "firestore-doc-id",
      "messageId": "<id@mailservicenow.com>",
      "mailbox": "inbox@mailservicenow.com",
      "from": "Person <person@example.com>",
      "to": ["inbox@mailservicenow.com"],
      "subject": "Re: order 1042",
      "preview": "First 180 characters of the plain text…",
      "date": "2026-09-22T18:29:10.000Z",
      "ingestedAt": "2026-09-22T18:31:13.049Z",
      "unread": true,
      "folder": "inbox",
      "source": "maildir"
    }
  ],
  "unreadInPage": 1
}
```

`unreadInPage` counts unread rows **in this page only**. It is not a mailbox total. List rows omit `text` and `html`. Optional `mailbox` must be an `@mailservicenow.com` address (the composite index must exist for that query).

Read one (marks `unread: false`). Pass `id` **or** `messageId`. If both are present, `id` is used.

```bash
curl -sS \
  "$MSN_API_BASE/inbox?id=firestore-doc-id" \
  -H "Authorization: Bearer msn_<your-key>"
```

```json
{
  "ok": true,
  "message": {
    "id": "firestore-doc-id",
    "messageId": "<id@mailservicenow.com>",
    "mailbox": "inbox@mailservicenow.com",
    "from": "Person <person@example.com>",
    "to": ["inbox@mailservicenow.com"],
    "subject": "Re: order 1042",
    "preview": "First 180 characters of the plain text…",
    "date": "2026-09-22T18:29:10.000Z",
    "ingestedAt": "2026-09-22T18:31:13.049Z",
    "unread": false,
    "source": "maildir",
    "text": "Full plain-text body.",
    "html": "<p>Present only when the stored message has HTML.</p>"
  }
}
```

`id` must match `^[A-Za-z0-9_-]{1,128}$`. Unknown message: **404** `Message not found`.

Stored messages may include optional `threadId`, `inReplyTo`, `references`, and `folder`. Every message row includes `attachments` (an array, empty when there are none). Each item is `{ "index", "filename", "contentType", "size", "sha256", "preview" }` with `preview` of `image`, `pdf`, `text`, or `download`. The bytes are not in this JSON. Clients that only read the fields above keep working. `GET /inbox` without `q`, `view`, `thread`, or `folder` is still `{ ok, messages, unreadInPage }`. `folder` is `inbox`, `archive`, or `trash`. A missing stored folder is returned as `inbox`. Optional `?folder=` on the flat list keeps only that folder, scanning the newest 200 rows before the limit. Omit `folder` and the flat list stays every folder. `list_inbox` does not send `folder`.

## Threads

Human Inbox (`/app.html`) lists threads. Same auth as inbox. No credits.

List:

```bash
curl -sS \
  "$MSN_API_BASE/inbox?view=threads&limit=25" \
  -H "Authorization: Bearer msn_<your-key>"
```

```json
{
  "ok": true,
  "threads": [
    {
      "threadId": "64-hex-chars",
      "mailbox": "inbox@mailservicenow.com",
      "subject": "P5-1 thread sample",
      "count": 2,
      "preview": "Latest plain-text preview",
      "from": "Ada <ada@example.com>",
      "latestAt": "2026-09-22T22:05:00.000Z",
      "unread": true,
      "unreadCount": 2,
      "folder": "inbox"
    }
  ],
  "unreadThreads": 1,
  "folder": "inbox",
  "inboxUnread": 1
}
```

Open one thread (ordered oldest first, marks inbound rows read):

```bash
curl -sS \
  "$MSN_API_BASE/inbox?thread=64-hex-chars" \
  -H "Authorization: Bearer msn_<your-key>"
```

`thread` must match `^[a-f0-9]{64}$`. Unknown: **404** `Thread not found`. Bad id: **400** `Invalid thread`. `view` other than `threads` is **400** `Invalid view`. If `id` or `messageId` is also set, that single-message read wins and the thread `unreadCount` is refreshed from the remaining unread inbound rows.

`view=threads` defaults to `folder=inbox`. Pass `folder=archive` or `folder=trash` for the other two. `unreadThreads` counts unread rows **in the returned page**. `inboxUnread` counts unread **inbox** threads among the newest 200 thread rows in the mailbox, including when you are listing Archive or Trash. Threads with no `folder` field stay in Inbox.

Opening a thread (`GET /inbox?thread=`) marks every inbound row in it read. `POST /inbox` changes that flag or the folder without spending credits. Same auth as `GET /inbox`. No rate limit.

```bash
curl -sS -X POST \
  "$MSN_API_BASE/inbox" \
  -H "Authorization: Bearer $ID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"thread":"64-hex-chars","unread":true}'
```

```bash
curl -sS -X POST \
  "$MSN_API_BASE/inbox" \
  -H "Authorization: Bearer $ID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"thread":"64-hex-chars","folder":"archive"}'
```

`unread` must be a JSON boolean. `folder` is `inbox`, `archive`, or `trash`. The response is `{ "ok": true, "thread", "inboxUnread" }`. Marking a thread unread sets **every inbound row** in that thread unread and sets `unreadCount` to that number. Marking it read clears those flags and sets `unreadCount` to 0. Pass `id` or `messageId` instead of `thread` to change one inbound row; `id` wins when both are present, same as GET. Outbound rows cannot be marked unread (**400** `Only inbound mail can be marked unread`). `folder` always moves the whole thread. A later inbound message files that thread back into Inbox. This is not Gmail: there are no nested labels, snooze, or multi-label threads.

Grouping rule: In-Reply-To / References first (including outbound Message-IDs from a threaded send). If the parent is not stored yet, the thread id is derived from the first reference so the root joins later. If there is no reference, messages in the same mailbox with the same normalized subject share a thread. Empty subjects do not merge. `list_inbox` does not switch to this shape.

## Search

Signed-in substring search. Same auth as inbox. No credits. Does not mark mail read. Not an MCP tool. Optional `folder=inbox|archive|trash` keeps hits in that folder. Omit `folder` and search still covers every folder. Each hit includes `folder`.

```bash
curl -sS \
  "$MSN_API_BASE/inbox?q=P5-1" \
  -H "Authorization: Bearer msn_<your-key>"
```

```json
{
  "ok": true,
  "query": "p5-1",
  "scanned": 3,
  "hits": [
    {
      "id": "998a48dd65455edb88f98ac5f1b60620172abf56d2f4bcbd28e8105d9a416cc3",
      "direction": "inbound",
      "threadId": "ff9b088e3dc69e06daa1346a1e020ffae7bac32de41df5a2c236f406557f4c82",
      "messageId": "<p5-1-root@mailservicenow.com>",
      "mailbox": "inbox@mailservicenow.com",
      "from": "qa@example.com",
      "subject": "P5-1 thread sample",
      "snippet": "Root of the P5-1 sample thread.",
      "matched": ["subject", "body"],
      "date": "2026-09-22T23:00:00.000Z"
    }
  ]
}
```

`q` matches `subject`, `from`, or the plain-text body (HTML only when `text` is empty). Matching is case-insensitive and ignores extra whitespace. The scan is the newest **200** inbound rows and **200** outbound rows in the mailbox (default `inbox@mailservicenow.com`). `hits[].id` is the Firestore document id. `hits[].threadId`, when present, opens that thread. A hit does not include the full body.

Query shorter than 2 characters: **400** `Query must be at least 2 characters`. Longer than 200: **400** `Query too long`. If `id`, `messageId`, or `thread` is also set, that read wins. A non-empty `q` wins over `view` and the flat list. Unauthenticated: **401**.

## Attachments

Human Compose can attach files. `sendMail` accepts the same `attachments` array for a Firebase user or an agent key. `send_mail` does not.

Download one stored file (same auth as inbox, no credit):

```bash
curl -sS -D - -o sample.png \
  "$MSN_API_BASE/attachment?id=FIRESTORE_DOC_ID&index=0&box=inbound" \
  -H "Authorization: Bearer msn_<your-key>"
```

`box` is `inbound` (default) or `outbound`. `index` is the `index` on that message’s `attachments` entry. `disposition=inline` is allowed for image, PDF, and text. Anything else is **400** `Invalid box` or `Invalid attachment index`. Missing file: **404** `Attachment not found`. Unauthenticated: **401**.

Bytes live in Firebase Storage under `mail-attachments/{docId}/{index}-{filename}`. Firestore keeps metadata only. Storage rules deny client SDK access.

## Drafts

Human compose only. Admin Firebase ID token. An agent key is **403** `Drafts require a signed-in user`. Any other Firebase user is **403** `This mailbox requires an operator account`. No credits. Each draft is a document in Firestore `drafts` with that user's `uid`. The response does not include `uid`. Drafts do not store attachments.

Create:

```bash
curl -sS -X POST \
  "$MSN_API_BASE/drafts" \
  -H "Authorization: Bearer <ID_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"to":"qa@example.com","subject":"P5-4 draft","text":"Saved before send"}'
```

```json
{
  "ok": true,
  "draft": {
    "id": "32-hex-chars",
    "to": "qa@example.com",
    "subject": "P5-4 draft",
    "text": "Saved before send",
    "inReplyTo": "",
    "references": "",
    "createdAt": null,
    "updatedAt": null
  }
}
```

`draft.id` is the Firestore document id. Send the same `to`, `subject`, and `text` with `POST /sendMail`, and always set `from` to an address on a verified domain you own, for example `support@example-domain.com`. The sent SMTP id is `messageId` on that **200**. Then `DELETE /drafts?id=<draft.id>`.

`GET /drafts` lists the newest 25. `GET /drafts?id=` reads one. `POST` with `id` updates that draft. At least one of `to`, `subject`, `text`, or `inReplyTo` must be non-empty. Cap is **50** drafts per user (**400** `Draft limit reached (50)`). Another user's id is **404** `Draft not found`. Unauthenticated: **401**.

## Inbound webhooks

An agent registers one HTTPS URL for the default agent key. When a **new** message is stored, Mail Service Now POSTs `inbound.received` to that URL. Registration does not spend a credit. A failed delivery does not remove the stored message.

```bash
curl -sS -X POST "$MSN_API_BASE/webhooks" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/msn"}'
```

```json
{
  "ok": true,
  "webhook": {
    "url": "https://example.com/hooks/msn",
    "enabled": true,
    "secret": "whsec_<generated>",
    "createdAt": "2026-09-23T16:40:00.000Z",
    "updatedAt": "2026-09-23T16:40:00.000Z"
  }
}
```

`GET /webhooks` returns the same object, including `secret`. `DELETE /webhooks` clears it and returns `{ "ok": true, "webhook": null }`. `POST` again with the same or a new `url` keeps the secret. `"rotateSecret": true` replaces it. `"enabled": false` keeps the row and skips delivery.

Rules:

- `url` is HTTPS, at most 2048 characters, with no username, password, or fragment.
- Loopback, link-local, private, CGNAT, and cloud-metadata hosts are rejected, including DNS answers that point at those addresses. **400** `url host is not allowed` or `url host could not be resolved`.
- A missing credential is **401**. A bad agent key is **401** `Invalid API key`. A bad Firebase JWT is **401** `Invalid or expired ID token`. A verified Firebase user is **403** `Webhooks require an agent API key`.
- There is one URL for the default key (`agent_webhooks/default`). `POST` replaces it.

Event body (compact JSON, this key order). No `text`, `html`, or attachment bytes:

```json
{
  "event": "inbound.received",
  "id": "firestore-doc-id",
  "messageId": "<id@example.com>",
  "from": "Person <person@example.com>",
  "to": ["inbox@mailservicenow.com"],
  "subject": "Hello",
  "preview": "Plain text",
  "timestamp": "2026-09-23T16:40:00.000Z",
  "mailbox": "inbox@mailservicenow.com"
}
```

Headers:

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `MailServiceNow-Webhook/1` |
| `X-MSN-Event` | `inbound.received` |
| `X-MSN-Timestamp` | Unix seconds |
| `X-MSN-Signature` | `sha256=` plus hex HMAC-SHA256 of `<timestamp>.<raw body>` using the registration secret |
| `X-MSN-Delivery-Attempt` | `1` or `2` |

Verify the signature over the raw body bytes. Do not re-serialize the JSON. Reject timestamps older than five minutes if you need replay protection. The sender does not follow redirects.

Delivery tries twice. A non-2xx response or a connection error waits **750ms**, then tries once more. Each attempt aborts after **5 seconds**. The second failure is written to `webhook_deliveries` (host, status, attempts, message id — not the secret and not the body). The stored message stays. The delivery record has `webhook.delivered: false` and a short `webhook.error`.

## Customer mailbox delivery

Once account setup is complete, the signed-in account chooses a delivery mode when it creates a mailbox and can change it later. `POST /addMailbox` and `POST /setMailboxMode` take `mode` and `forwardTo`. `GET /listDomains` and `GET /webmailMailboxes` return the same fields. MCP tools `add_mailbox` and `set_mailbox_mode` take the same arguments and return the mailbox. `POST /deleteMailbox` and MCP `delete_mailbox` remove the mailbox record. Removing the record does not delete mail stored on the server.

| `mode` | What happens | Login |
| --- | --- | --- |
| `hosted` | Mail is stored in Maildir. `forwardTo` must be empty. On the mail server: vmailbox only. | Webmail and IMAP/SMTP after the domain is verified. |
| `forward` | Mail is relayed to `forwardTo` and nothing is stored. On the mail server: virtual alias only. | No webmail login and no IMAP/SMTP credential. |
| `both` | Mail is stored and a copy is forwarded. On the mail server: vmailbox plus alias `address -> address, forwardTo...`. | Webmail and IMAP/SMTP, same as hosted. |

`forwardTo` is an array of email addresses. A single string is still accepted from older clients and is read back as a one-element array. Addresses are trimmed, lowercased, and de-duplicated. `hosted` rejects a non-empty list. `forward` and `both` require at least one address and reject more than **5**. A target equal to the mailbox itself is rejected. A target that is another mailbox on the same account is rejected when following its forwards leads back, including a direct two-hop loop. A target on the same domain that is not a mailbox is rejected. Forwarding into `mailservicenow.com` is rejected.

Existing mailbox documents are not migrated. `mode: "hosted"` still reads as hosted, including a protected address. A stored `forwardTo` string still reads as that one address. Switching to `forward` does not delete stored Maildir. Switching back to `hosted` or `both` makes the mailbox loginable again.

The mail server syncs mailbox modes from `mtaMailboxExport` about every 60 seconds. A create or mode change stores the plan immediately and sets `mtaApplied` to false. When that sync has picked up the change, `mtaApplied` is true and `mtaAppliedAt` is the timestamp that confirms it is live. New mailbox documents store `delivery` next to `mode` and `forwardTo`. Older documents do not have `delivery`; compute it from `mode` and `forwardTo` (a string `forwardTo` is one address). `delivery` on each API mailbox is what that sync reads:

```json
{
  "mode": "both",
  "forwardTo": ["ada@example.net"],
  "delivery": {
    "vmailbox": true,
    "virtualAlias": "sales@acme.example, ada@example.net"
  }
}
```

`hosted` is `{ "vmailbox": true, "virtualAlias": null }`. `forward` is `{ "vmailbox": false, "virtualAlias": "ada@example.net" }`.

An account that cannot send yet is **403** `Account setup is not complete yet`. The shared agent key (uid `agent:default`) on `addMailbox`, `setMailboxMode`, `deleteMailbox`, `mailboxClientCredential`, `add_mailbox`, `set_mailbox_mode`, `delete_mailbox`, or `issue_mailbox_credential` is **401** `Firebase sign-in required` (on MCP, JSON-RPC `-32000` with `data.status` 401 and that same message). A standard key from `status: "ready"` is accepted on those REST routes and on the mailbox MCP tools. A Firebase ID token may call only the mailbox MCP tools. Other MCP tools still take the shared agent key or a standard key. See [After you have a standard key](#after-you-have-a-standard-key-set-up-your-domain-and-mailboxes).

An admin sign-in can create a hosted mailbox on `mailservicenow.com` (for example `team@mailservicenow.com`). Customers cannot add that domain. `inbox@mailservicenow.com` stays the shared inbox. `noreply@mailservicenow.com` is the shared agent key's From address, not a customer mailbox. Product-domain mailboxes are hosted only. `issue_mailbox_credential` (and `POST /mailboxClientCredential`) returns an IMAP/SMTP app password once and stores a SHA512-CRYPT hash. The mail server export includes those named mailboxes, not the product domain as coverage, so Dovecot and vmailbox update on the next sync (about 60 seconds) without rewriting the catch-all.

### Delete a mailbox

`POST /deleteMailbox` accepts `{ "address": "sales@acme.example" }` or `{ "domain": "acme.example", "localPart": "sales" }`. `domainId` and `mailboxId` are accepted as well. The signed-in account must own the mailbox. Someone else's mailbox is **404**. A missing mailbox is **404**, including a second delete of the same address. Unauthenticated is **401**.

An unverified domain may delete its last mailbox. On a verified domain, the last active mailbox is **409** `This is the last mailbox on <domain>. Add another mailbox first, or contact support to remove the domain.` The record stays. Only active mailboxes count: a missing status counts as active, and any other status does not. Deleting an inactive mailbox is allowed while another active mailbox remains.

A protected address is hard-coded and cannot be deleted. That call is **409**. The record stays.

The call deletes the Firestore mailbox record only. It does not delete mail stored on the server (Maildir under `/var/vmail` is left in place). The next mailbox export omits the address and lists `domains`, the verified domains that export covers, including a domain with no mailboxes left. The mail server syncs from `mtaMailboxExport` about every 60 seconds and uses that list to drop the address from the vmailbox map, the virtual alias map, sender login, and the Dovecot passwd file. Routing for a deleted mailbox drops on that next sync. The Maildir is kept. A protected address is never removed from those maps. A partial mailbox file that has no `domains` list of hostnames only updates the addresses it names. Removals are not acked, because the mailbox record is already gone.

The response records what was removed. `mtaApplied: false` matches the shape `setMailboxMode` writes:

```json
{
  "ok": true,
  "deleted": true,
  "mailbox": {
    "address": "sales@acme.example",
    "removed": true,
    "previousMode": "both",
    "delivery": { "vmailbox": false, "virtualAlias": null },
    "mtaApplied": false,
    "maildir": "leave"
  }
}
```

`delivery` describes the address leaving the vmailbox map and the virtual alias map. `maildir: "leave"` means do not remove stored mail. `previousMode` is what the mailbox was. It is not a new mode to apply. The sync drops the map rows from the export's `domains` list, not by replaying this response.

```bash
curl -sS -X POST "$MSN_API_BASE/addMailbox" \
  -H "Authorization: Bearer <firebase-id-token>" \
  -H "Content-Type: application/json" \
  -d '{"domainId":"acme.example","localPart":"sales","mode":"both","forwardTo":["ada@example.net"]}'
```

## How an agent requests access

No API key is required for this flow. Call it over MCP or REST. The code is emailed. It is never in the HTTP 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. Read the email sent to `contactEmail`. The code is 8 digits and expires in 24 hours.
3. Call `verify_access_request` (or `POST /verifyAccessRequest`) with `requestId` and `code`.
4. Read the result:
   - `status: "ready"` includes `access: "standard"` and `apiKey` (`msn_…`). That is a normal customer key: send API, domain and mailbox routes, webhooks, and the customer MCP tools, with the default quotas (100 credits, 5 sends per minute, 50 per UTC day). It does not add the domain or create mailboxes. Naming an address in `intendedUse` does not provision it. Continue with [After you have a standard key](#after-you-have-a-standard-key-set-up-your-domain-and-mailboxes).
   - `status: "setting_up"` has no `apiKey`. 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, in addition to any key it already had. 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 from the ready response. It is shown once. Keep it in a secret store. Send it the same way as any other `msn_…` key: `Authorization: Bearer msn_…` or `X-Api-Key: msn_…`. If `X-Api-Key` is present, that header is the only credential checked. Do not commit the key, and do not put it in a prompt.

A standard key is not an admin key. Admin routes and the admin MCP tools (`list_domain_allowlist`, `add_domain_allowlist`, `remove_domain_allowlist`, `check_domain_allowlist`, `operator_remove_domain`, `operator_delete_request`) return **403** `This action is limited to operator accounts.` The shared `inbox@mailservicenow.com` mailbox stays with Mail Service Now. `operator_remove_domain` marks a customer's domain removed, including its last mailbox, and releases that domain's claim when the claim still names the same account. When the domain has any mailbox, pass `confirm` set to the domain name. `mailservicenow.com` is **409** `system_domain`. `delete_mailbox` has no force flag and does not release the claim.

`request_access` is limited to **5 requests per hour per IP** and **3 requests per hour per email**. A **429** body is `{ "ok": false, "error": "Rate limit exceeded", "retryAfter": <seconds> }` and the `Retry-After` header is set. The code allows **5 attempts**. A wrong code is **401**. After 5 failures the code is **401** `That code is no longer valid`. An expired code is **410**. An unknown `requestId` is **404**. Completing the same request twice is **409** and does not return the key again.

### curl

```bash
export MSN_API_BASE=https://us-central1-mailservicenow.cloudfunctions.net

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"
  }'
```

```json
{ "ok": true, "requestId": "ar_0123456789abcdef01234567", "expiresIn": 86400, "message": "If the verification code email doesn't arrive within a few minutes, check the spam folder or contact support." }
```

```bash
curl -sS -X POST "$MSN_API_BASE/verifyAccessRequest" \
  -H "Content-Type: application/json" \
  -d '{"requestId":"ar_0123456789abcdef01234567","code":"12345678"}'
```

Ready:

```json
{
  "ok": true,
  "status": "ready",
  "requestId": "ar_0123456789abcdef01234567",
  "access": "standard",
  "apiKey": "msn_<your-key>",
  "tokenType": "Bearer",
  "message": "Save this key now. It is shown once.",
  "quotas": { "credits": 100, "burstPerMinute": 5, "perUtcDay": 50 }
}
```

Setting up:

```json
{
  "ok": true,
  "status": "setting_up",
  "requestId": "ar_0123456789abcdef01234567",
  "message": "We're setting up your account. We'll email you when it's ready."
}
```

Send later with the saved key:

```bash
curl -sS -X POST "$MSN_API_BASE/sendMail" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{"to":"recipient@example.com","from":"support@example-domain.com","subject":"Hello","text":"Sent with the standard key."}'
```

### MCP JSON-RPC

`request_access` and `verify_access_request` do not send an `Authorization` header.

```bash
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"
      }
    }
  }'
```

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "verify_access_request",
      "arguments": {
        "requestId": "ar_0123456789abcdef01234567",
        "code": "12345678"
      }
    }
  }'
```

Read `result.structuredContent`. `status` is `ready` or `setting_up`. On a tool failure the HTTP status is still 200 and `result.isError` is true. `structuredContent.status` is the HTTP code (400, 401, 404, 409, 410, or 429). A 429 includes `retryAfter`.

| Status | When |
| --- | --- |
| 400 | `contactEmail`, `domain`, `agentName`, `intendedUse`, `requestId`, or `code` is missing or invalid. `Invalid JSON`. |
| 401 | The code is wrong, or it is no longer valid. Also a bad key on a later send. |
| 403 | A standard key called an admin route or an admin MCP tool. |
| 404 | `requestId` is unknown. |
| 409 | `already_complete`. That request was already finished. Do not retry it. 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. The key is not returned again. |
| 410 | The code expired (24 hours). |
| 429 | Rate limit on `request_access`. Body includes `retryAfter` seconds. |

## Agent onboarding sequence

1. Request access (request_access).
2. Verify the emailed code (verify_access_request).
3. If the result is pending, poll get_request_status (e.g. every 20 minutes). Do NOT resend the same requestId.
4. 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.
5. Add and verify the domain, create a hosted mailbox, and check get_account_status until canSend is true.
6. Send, always setting from.

An account can hold one domain. `POST /addDomain` and MCP `add_domain` accept the account key. The same normalized name (lowercase, trimmed, trailing dot removed) that is already on the account returns `created: false` and does not change mailboxes or DNS. A second domain, including a subdomain such as `mail.example.com`, is **409** `one_domain_per_account`. After the only domain is removed, one domain can be added again. Accounts that already hold more than one domain keep their mailboxes and DNS. A new add on those accounts is refused.

409 already complete: that request was already finished. Do not retry it. 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.

Domain already claimed: the domain belongs to another account. Request a separate account for it only if you own it, or contact support. Each domain needs its own account.

## 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 same `contactEmail`). No API key. Unknown email, a real email with the wrong `requestId`, and an unknown `requestId` all return the same HTTP 200 body:

```json
{ "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:

```json
{ "ok": true, "status": "ready", "ready": true, "requestId": "ar_0123456789abcdef01234567" }
```

A limited response is the same whether or not the email is on file:

```json
{ "ok": false, "error": "Rate limit exceeded", "retryAfter": 3600 }
```

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` as `verified`, `missing`, or `unchecked`), 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: [Domain setup / deliverability](/developers/domain-setup/).

```json
{
  "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": [{ "address": "support@example-domain.com", "mode": "hosted", "ready": true }],
  "canSend": true
}
```

When no hosted mailbox is ready, `canSend` is false and `reason` is `Add a fully hosted mailbox on a verified domain before sending.` While setup is still in progress, `reason` is `Account setup is not complete yet`.

## After you have a standard key: set up your domain and mailboxes

`status: "ready"` and a standard `msn_…` key mean this account can call send and the domain routes. They do not add the hostname, publish DNS, or create mailboxes. `request_access` stores `domain` as the hostname you named and `intendedUse` as a note. Writing `support@example-domain.com` or `orders@example-domain.com` in `intendedUse` does not create those mailboxes.

Do these steps in order. Examples use `example-domain.com`. The key in `Authorization` is the `apiKey` from the ready response (`msn_<your-key>`), the same value you store as `MSN_AGENT_API_KEY`. The same paths exist under `https://mailservicenow.com/api/`.

1. Confirm `status: "ready"` and `apiKey`. `setting_up` has no key. The message is `We're setting up your account. We'll email you when it's ready.` Mail Service Now is still finishing that account. Domain and mailbox calls then return **403** `Account setup is not complete yet`. The web app shows the same setup message. Once the response is `ready` and you have the key, the remaining work is this list. A send with that key, before a hosted mailbox exists on a verified domain, returns **403** `Add a fully hosted mailbox on a verified domain before sending.`

2. Call `POST /addDomain` with the standard key, or use **Add domain** on `/app.html` (Domains). Body field `domain` is the customer hostname.

```bash
curl -sS -X POST "$MSN_API_BASE/addDomain" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{"domain":"example-domain.com"}'
```

The live function checks DNS as part of this call. If mail DNS is not published yet, the response is still **200** with `created: true`, `verified: false`, and `domain.status` `failed`. That first `failed` is the DNS check, not a refusal to add the hostname. Read `domain.dnsInstructions.records` and `domain.failureReason`. Calling `addDomain` again for the same normalized name returns **200** `created: false` and leaves mailboxes and DNS alone. A second domain is **409** `one_domain_per_account`. A hostname on another account is **409** `domain_claimed`: the domain belongs to another account. `mailservicenow.com` is **400**. MCP `add_domain` uses the same rule.

`GET /listDomains` lists what this account already has. `GET /getDnsInstructions?domainId=example-domain.com` returns the same records.

The standard key is `Authorization: Bearer msn_<your-key>` or `X-Api-Key: msn_<your-key>`. A Firebase ID token works on these routes too. The shared agent key (uid `agent:default`) does not. On these REST routes, `X-Api-Key` set to that shared key is **401** `Invalid API key`. A Bearer value that is not a customer key and not a Firebase ID token is **401** `Firebase sign-in required`.

3. Publish the records in `dnsInstructions.records`. Copy `value` from the response. Record-by-record steps, including DMARC, are in the public guide: <https://mailservicenow.com/developers/domain-setup/>. The required rows are:

| `id` | Type | Host | Value |
| --- | --- | --- | --- |
| `mx` | MX | `@` | `mail.mailservicenow.com`, priority **10** |
| `spf` | TXT | `@` | the SPF `value` on the domain card (one TXT only; see the [domain setup guide](https://mailservicenow.com/developers/domain-setup/#spf)) |
| `dkim` | TXT | `mail._domainkey` | the `value` on the `dkim` record (selector `mail`) |

`dmarc` (`_dmarc`) is recommended. Verification does not require it.

The preferred MX (lowest priority number) has to be `mail.mailservicenow.com`. If Cloudflare Email Routing is a better priority, `checks.mx.detail` is `MX points at route1.mx.cloudflare.net` (or whichever host is preferred). A second SPF TXT fails with `Publish one SPF TXT`. If an SPF TXT already exists, add the dashboard `ip4:` mechanism to that one record and keep existing `include:` mechanisms. Leave existing A records and unrelated TXT records, including `_acme-challenge`, in place. Do not add an A record for the mail server on this domain.

**Cut over when you mean to.** Pointing MX at `mail.mailservicenow.com` takes inbound mail off the previous host. Replacing Cloudflare Email Routing (or any other MX) stops whatever that host delivers today. Change MX only when Mail Service Now should receive the domain's mail.

4. Call `POST /verifyDomain` after DNS propagates, or **Check DNS** on the domain in the web app.

```bash
curl -sS -X POST "$MSN_API_BASE/verifyDomain" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{"domainId":"example-domain.com"}'
```

`domain` is accepted in place of `domainId`. `verified: true` sets `domain.status` to `verified`. `verified: false` leaves `failed` and `failureReason` (also on `checks.mx`, `checks.spf`, and `checks.dkim`). `transient: true` means the lookup did not finish; call again. A `failed` result from step 2 is cleared by this call once the records match.

5. Create each mailbox with `POST /addMailbox`, or **Add mailbox** in the web app. One call per address. `localPart` is the name before `@`. `mode` is `hosted`, `forward`, or `both`. `hosted` stores mail and takes no `forwardTo`. `forward` and `both` require `forwardTo` (an array, at most 5). See [Customer mailbox delivery](#customer-mailbox-delivery).

```bash
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"}'
```

Auth on this call:

| Call | Standard `msn_…` key from `ready` | Firebase ID token | Shared agent key |
| --- | --- | --- | --- |
| `POST /addDomain`, `GET /listDomains`, `GET /getDnsInstructions`, `POST /verifyDomain`, `POST /addMailbox`, `POST /setMailboxMode`, `POST /deleteMailbox`, `POST /mailboxClientCredential` | Yes. Bearer or `X-Api-Key`. | Yes. | No. |
| MCP `add_mailbox`, `issue_mailbox_credential`, `set_mailbox_mode`, `delete_mailbox` | Yes. Same key, for a mailbox on that account. | Yes, and only these mailbox MCP tools. | No. JSON-RPC `-32000`, message `Firebase sign-in required`, `data.status` 401. |

`Firebase sign-in required` is the message for the shared agent key. A standard key is a customer key and is accepted, including on MCP `add_mailbox`. You do not need a separate Firebase sign-in when you already have that key. The web app signs in with Firebase and calls the same REST routes.

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 7,
    "method": "tools/call",
    "params": {
      "name": "add_mailbox",
      "arguments": {
        "domainId": "example-domain.com",
        "localPart": "orders",
        "mode": "hosted"
      }
    }
  }'
```

MCP `add_domain` adds the one domain for this account, the same rule as `POST /addDomain`. There is no MCP tool for `verifyDomain`. That call is REST (or the web app).

6. Wait about 60 seconds. The mail server reads `mtaMailboxExport` on that interval. A new mailbox has `mtaApplied: false` until that sync. `mtaAppliedAt` is the time it went live. IMAP, SMTP, and webmail delivery start after that, and only for `hosted` or `both` on a domain whose `status` is `verified`. `forward` has no webmail login and no IMAP/SMTP credential. The app password is a separate call: `POST /mailboxClientCredential` or MCP `issue_mailbox_credential` with `address`. It returns the password once and stores a SHA512-CRYPT hash. A standard key can issue it for a mailbox on that account. The shared agent key is refused.

7. Optional. `POST /webhooks` registers `inbound.received`. A proof send is `POST /sendMail` or MCP `send_mail`. 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 (**400** `Choose the address to send from.`). Read credits with `GET /credits` or `get_credits`.

### What looks like a hold and is not

An admin list row with `status: "pending"` and `storedStatus: "code_sent"` is an access-request stub. The code was emailed and the request was not verified. An expired code stays `code_sent`; `POST /verifyAccessRequest` then returns **410** `That code has expired` and does not change the stub. `decidable` is false on that row. A decision returns **409** `This request is not ready for a decision.` That row is not why mailboxes are missing, and it is not something to decide. Ignore it when this account already returned `ready` and a key.

`setting_up` is the other case. That response has no `apiKey`. The account is still being finished. Domain and mailbox calls stay **403** `Account setup is not complete yet` until a later verify returns `ready`. After `ready`, finish domain, DNS, verify, and mailboxes as above.

## MCP

Stateless JSON-RPC 2.0 over `POST`. There is no SSE stream and no session id to keep. A JSON array body is a batch. Notifications (`id` omitted) that succeed return **204** with an empty body.

`GET /mcp` needs no auth:

```json
{
  "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", "list_domain_allowlist", "add_domain_allowlist", "remove_domain_allowlist", "check_domain_allowlist", "operator_remove_domain", "operator_delete_request", "add_mailbox", "issue_mailbox_credential", "set_mailbox_mode", "delete_mailbox"],
  "auth": "Authorization: Bearer <MSN_AGENT_API_KEY> or a standard-access customer key. request_access, verify_access_request, and get_request_status need no key. get_account_status needs the account key."
}
```

Remote client shape (header value is your key, not this placeholder):

```json
{
  "mcpServers": {
    "mailservicenow": {
      "url": "https://us-central1-mailservicenow.cloudfunctions.net/mcp",
      "headers": {
        "Authorization": "Bearer msn_<your-key>"
      }
    }
  }
}
```

Use that only with a client that POSTs JSON-RPC. Do not assume an SSE MCP transport.

### initialize

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"example","version":"0.0.0"}}}'
```

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": { "tools": {} },
    "serverInfo": { "name": "mailservicenow", "version": "0.7.0" }
  }
}
```

Also implemented: `notifications/initialized`, `initialized`, `ping`, `tools/list`, `tools/call`.

### tools/list

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

The `tools` array entries:

| Tool | Arguments | Effect |
| --- | --- | --- |
| `request_access` | `contactEmail`, `domain`, `agentName`, `intendedUse` | Emails a one-time code. No key required. See [How an agent requests access](#how-an-agent-requests-access). |
| `verify_access_request` | `requestId`, `code` | Returns a standard `apiKey` once, or `status: "setting_up"`. No key required. |
| `get_request_status` | `requestId`, `email` | No key. Same pending body for an unknown email, a wrong `requestId`, and an unknown `requestId`. Poll about every 20 minutes. See [Check if my account is ready](#check-if-my-account-is-ready). |
| `get_account_status` | none. The key selects the account. | Account key only. Returns `status`, `accessLevel`, domain verification, mailbox readiness, and `canSend`. |
| `send_mail` | `to` (string), `subject` (string), `text` (string). 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. Optional `inReplyTo`, `references`. No attachments. | Sends. Costs 1 credit. Rate limits apply. The shared agent key uses From `noreply@mailservicenow.com`. Omitting `from` when more than one sendable hosted mailbox exists is **400** `Choose the address to send from.` |
| `get_credits` | none | `{ "ok": true, "remaining": 100 }` |
| `list_inbox` | optional `limit` (1–50, default 25), optional `mailbox` | Same list shape as `GET /inbox`. No credit. |
| `get_message` | `id` and/or `messageId` | One message, marked read. No credit. |
| `mark_read` | `id` and/or `messageId`. Optional `unread` boolean. | Sets that inbound row read (`unread` omitted or `false`) or unread (`true`). No credit. |
| `add_mailbox` | `domainId`, `localPart`, `mode` (`hosted` \| `forward` \| `both`), optional `forwardTo` (array of emails, max 5) | Creates a mailbox. Output includes `mailbox.mode`, `mailbox.forwardTo`, and `mailbox.delivery`. Account setup must be complete. A standard key is accepted. The shared agent key is refused (`Firebase sign-in required`). No credit. Add the domain with `POST /addDomain` or MCP `add_domain` first. An admin sign-in may use domain `mailservicenow.com` with mode `hosted` for a named mailbox such as `a.codex`. |
| `issue_mailbox_credential` | `address` (or `mailbox`) | Issues or resets the IMAP/SMTP app password. Returns `password` once and stores a SHA512-CRYPT hash. A standard key is accepted for a mailbox on that account. A Firebase ID token works for the owner, or an admin for a product-domain mailbox. The shared agent key is refused. Forward-only is refused. No credit. |
| `set_mailbox_mode` | `domainId`, `localPart` or `mailboxId`, `mode`, optional `forwardTo` | Updates delivery mode. Same output shape. Does not delete stored mail. A standard key is accepted. The shared agent key is refused. No credit. |
| `delete_mailbox` | `address`, or `domainId` / `domain` plus `localPart` / `mailboxId` | Removes the mailbox record. Routing drops on the next sync, about every 60 seconds. The Maildir is kept. Output includes `removed`, `delivery` (`vmailbox` false, `virtualAlias` null), `mtaApplied` false, and `maildir` `leave`. 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.` Unverified domains are not affected. A standard key is accepted. The shared agent key is refused. No credit. |
| `operator_delete_request` | `id` or `ids` (at most 200). An id is `access:ar_…` or the raw `ar_…` id. | Admin only. Deletes those access requests and no others. The account and its key stay as they are. Each id writes one `request.delete` audit row and clears that email's request rate counter. A standard key, the shared agent key, and the MTA token are refused. |

### tools/call send_mail

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Authorization: Bearer msn_<your-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."
      }
    }
  }'
```

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 (**400** `Choose the address to send from.`). The shared agent key sends as `noreply@mailservicenow.com`.

HTTP **200** success (check `structuredContent`, not only the HTTP status):

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\n  \"ok\": true,\n  \"messageId\": \"<id@mail.mailservicenow.com>\",\n  \"creditsRemaining\": 99\n}"
      }
    ],
    "structuredContent": {
      "ok": true,
      "messageId": "<id@mail.mailservicenow.com>",
      "creditsRemaining": 99
    }
  }
}
```

`to` is a string. Commas or semicolons split extra recipients, max 5. `text` over 100KB is not separately rejected on the MCP path before SMTP; keep bodies at or under 100KB to match `sendMail`.

### tools/call get_credits

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_credits","arguments":{}}}'
```

```json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      { "type": "text", "text": "{\n  \"ok\": true,\n  \"remaining\": 100\n}" }
    ],
    "structuredContent": { "ok": true, "remaining": 100 }
  }
}
```

### tools/call list_inbox

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_inbox","arguments":{"limit":25,"mailbox":"inbox@mailservicenow.com"}}}'
```

`structuredContent` is `{ "ok": true, "messages": [ ... ], "unreadInPage": 0 }` with the same message fields as `GET /inbox` list.

### tools/call get_message

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_message","arguments":{"id":"firestore-doc-id"}}}'
```

`structuredContent` is `{ "ok": true, "message": { ... } }` including `text` (and `html` when stored). The row is marked read.

### tools/call mark_read

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Authorization: Bearer msn_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"mark_read","arguments":{"id":"firestore-doc-id"}}}'
```

`structuredContent` matches `POST /inbox`: `{ "ok": true, "thread", "inboxUnread" }` when the row has a thread, or `{ "ok": true, "message" }` when it does not. Pass `"unread": true` to mark that inbound row unread. Omitted `unread` marks it read. This tool does not call the credit counter. Unknown id is HTTP 200 with `result.isError: true` and `structuredContent.status` **404**, same as other MCP not-found results. A bad `unread` value is JSON-RPC `-32000` with `data.status` **400**.

### tools/call add_mailbox and set_mailbox_mode

Standard key or Firebase ID token. Account setup must be complete. The shared agent key is refused with the same message as `POST /addMailbox` (`Firebase sign-in required`). Domain setup is REST: [After you have a standard key](#after-you-have-a-standard-key-set-up-your-domain-and-mailboxes).

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Authorization: Bearer <firebase-id-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 7,
    "method": "tools/call",
    "params": {
      "name": "add_mailbox",
      "arguments": {
        "domainId": "acme.example",
        "localPart": "sales",
        "mode": "both",
        "forwardTo": ["ada@example.net"]
      }
    }
  }'
```

`structuredContent` is `{ "ok": true, "mailbox": { "mode", "forwardTo", "delivery", ... } }`. `set_mailbox_mode` uses the same output. `forwardTo` has at most 5 addresses. See Customer mailbox delivery for the cap, the loop rule, and the note that the mail server syncs from `mtaMailboxExport` about every 60 seconds, with `mtaApplied` and `mtaAppliedAt` as the confirmation.

### tools/call delete_mailbox

Firebase ID token, same as `add_mailbox`. The agent key is refused.

```bash
curl -sS -X POST "$MSN_API_BASE/mcp" \
  -H "Authorization: Bearer <firebase-id-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 8,
    "method": "tools/call",
    "params": {
      "name": "delete_mailbox",
      "arguments": { "address": "sales@acme.example" }
    }
  }'
```

`structuredContent` is `{ "ok": true, "deleted": true, "mailbox": { "removed": true, "delivery": { "vmailbox": false, "virtualAlias": null }, "mtaApplied": false, "maildir": "leave" } }`. This does not delete mail stored on the server. A missing mailbox is HTTP 200 with `result.isError: true` and `structuredContent.status` **404**. A protected address is JSON-RPC `-32000` with `data.status` **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.` Unverified domains are not affected.

### MCP errors

Tool failures that are **402, 404, 429, or 503** still use HTTP **200**. The JSON-RPC result has `isError: true` and `structuredContent.status`. Insufficient credits and rate limits are both **429** here (credits are not HTTP 402).

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      { "type": "text", "text": "{\n  \"ok\": false,\n  \"error\": \"Rate limit exceeded\"\n}" }
    ],
    "isError": true,
    "structuredContent": {
      "ok": false,
      "error": "Rate limit exceeded",
      "status": 429
    }
  }
}
```

Other tool failures use a JSON-RPC **error** (HTTP still 200):

| JSON-RPC `error.code` | When |
| --- | --- |
| `-32600` | Body is not a JSON-RPC 2.0 object |
| `-32601` | Method not found |
| `-32602` | `tools/call` missing `params.name` |
| `-32000` | Other failures, including validation (`data.status` **400**) and auth problems that are not HTTP 401 |

Validation example (`to` / `subject` / `text` missing, unknown tool, bad mailbox, bad id): HTTP 200

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": -32000,
    "message": "to, subject, and text are required",
    "data": { "status": 400 }
  }
}
```

Auth failure on `POST /mcp` is HTTP **401** `{ "ok": false, "error": "…" }`, not a JSON-RPC envelope. Wrong method is HTTP **405**.

## HTTP errors (REST)

| Status | `error` string | When |
| --- | --- | --- |
| 400 | `to, subject, and text are required` | sendMail missing fields |
| 400 | `Max 5 recipients per request` | more than 5 recipients |
| 400 | `Body too large (max 100KB)` | sendMail `text` |
| 400 | `contactEmail must be an email address` / `domain must be a hostname such as example.com` / `agentName is required` / `intendedUse is required` / `requestId is invalid` / `code is invalid` | `requestAccess` or `verifyAccessRequest` |
| 400 | `Invalid JSON` | ingest body is not JSON |
| 400 | `messageId is required` / `mailbox must be an @mailservicenow.com address` / `source must be imap, maildir, or pipe` | ingest validation |
| 400 | `id or messageId is required` / `Invalid id` / `Invalid messageId` | inbox read |
| 400 | `Invalid thread` / `Invalid view` | thread id or `view` |
| 400 | `thread, id, or messageId is required` / `unread or folder is required` / `unread must be true or false` / `folder must be inbox, archive, or trash` / `Only inbound mail can be marked unread` | `POST /inbox` or MCP `mark_read` |
| 400 | `url is required` / `url must be https` / `url must not include credentials` / `url must not include a fragment` / `url is too long` / `url host is not allowed` / `url host could not be resolved` / `enabled must be true or false` / `rotateSecret must be true or false` | `/webhooks` |
| 400 | `disabled must be true or false` / `frozenUntil must be an ISO time or null` / `sendingEnabled must be true or false` / `disabled, frozenUntil, or sendingEnabled is required` / `Invalid JSON` | `POST /credits` |
| 400 | `Query must be at least 2 characters` / `Query too long` | `GET /inbox?q=` |
| 400 | `Draft is empty` / `Draft limit reached (50)` / `To too long` / `Subject too long` / `In-Reply-To too long` / `References too long` / `id is required` | `/drafts` |
| 400 | `Invalid inReplyTo` / `Invalid references` | sendMail / send_mail threading fields |
| 400 | `attachments must be an array` / `Max 3 attachments` / `Attachment type not allowed` / `Attachment filename does not match its type` / `Attachment content is required` / `Attachment content is not valid base64` / `Attachment too large (max 2MB each)` / `Attachments too large (max 4MB total)` / `Attachment content does not match its type` | sendMail attachments |
| 400 | `Attachments are not supported on send_mail` | MCP `send_mail` with attachments |
| 400 | `Choose the address to send from.` | sendMail / send_mail when `from` is omitted and the account has more than one sendable hosted mailbox. With exactly one, omission is a fallback, not the normal path. |
| 400 | `Invalid box` / `Invalid attachment index` | `GET /attachment` |
| 401 | `Missing Authorization Bearer token or X-Api-Key` | no credential |
| 401 | `That code is not valid` / `That code is no longer valid` | wrong or locked access code. No key is returned. |
| 401 | `Invalid API key` | wrong agent key or customer key |
| 401 | `Invalid or expired ID token` | bad Firebase JWT |
| 401 | `Invalid ingest key` | wrong or missing ingest key |
| 401 | `MCP requires agent API key (Bearer msn_…)` | Firebase user called MCP |
| 400 | `Enter a domain you own, such as example.com.` / `That domain name is not valid.` / `domainId is required` / `Enter the mailbox name.` / `Choose hosted, forward, or both.` | `addDomain`, `verifyDomain`, or `addMailbox` |
| 401 | `Firebase sign-in required` | shared agent key on a domain or mailbox route (REST Bearer, or MCP mailbox tools). A standard key is accepted there. |
| 403 | `Account setup is not complete yet` | domain or mailbox call before the account can send (`setting_up`, not `ready`) |
| 403 | `Add a fully hosted mailbox on a verified domain before sending.` | standard key `sendMail` / `send_mail` before that mailbox exists |
| 403 | `This action is limited to operator accounts.` | standard customer key on an admin route or an admin MCP tool |
| 403 | `This mailbox requires an operator account` | Firebase user who is not an admin on `sendMail`, `inbox`, `attachment`, or `drafts` |
| 403 | `Drafts require a signed-in user` | agent key on `/drafts` |
| 403 | `Webhooks require an agent API key` | verified Firebase user on `/webhooks` |
| 403 | `Operator control requires the agent key or an operator account` | signed-in user who is not an admin on `POST /credits` |
| 403 | `Pausing all sending requires an operator account` | agent key trying to set `sendingEnabled` |
| 404 | `Request not found` | unknown `requestId` |
| 404 | `Message not found` | inbox / `get_message` |
| 404 | `Draft not found` | `/drafts` id for another user, or missing |
| 404 | `Thread not found` | `GET /inbox?thread=` |
| 404 | `Attachment not found` | `GET /attachment` |
| 409 | `already_complete` | `verifyAccessRequest` on a finished request. Do not retry that requestId. 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. The key is not returned again. |
| 409 | `one_domain_per_account` / `domain_claimed` / `That mailbox already exists on this domain.` / `already complete` | `addDomain`, `add_domain`, `verify_access_request`, or `addMailbox` |
| 410 | `That code has expired` | access code older than 24 hours. No key is returned. |
| 405 | `Method not allowed` | wrong verb (`sendMail`, `requestAccess`, and `verifyAccessRequest` are POST; `inbox` is GET or POST; `attachment` is GET; `drafts` and `webhooks` are GET, POST, or DELETE; `credits` is GET or POST; `mcp` is GET or POST) |
| 429 | `Rate limit exceeded` | burst or daily cap, or access-request cap (5/hour per IP, 3/hour per email). Access-request 429 includes `retryAfter` seconds. |
| 429 | `Insufficient credits` | agent balance is 0. Same status as the rate limit. |
| 500 | `Send failed` / `Inbox request failed` / `Ingest failed` / `MCP error` | unexpected failure (including MTA misconfiguration). The client does not receive the internal message. |
| 503 | `Sending temporarily disabled` | global kill switch (`config/send.enabled === false`) |
| 503 | `Agent key disabled` | agent key `disabled` or `frozenUntil` still in the future. No credit spent. Human sends are not blocked by this flag. |

There is no **402** on the live send path. Do not code clients to wait for 402.

## Anti-abuse expectations

- No credential → **401**. That response does not spend a credit.
- Over the burst or daily cap → **429** `Rate limit exceeded`.
- Agent key frozen → **503** `Agent key disabled`, and the credit counter does not move.
- All sending paused → **503** `Sending temporarily disabled`, and the credit counter does not move.
- Integrators do not connect to port 25 or 587. Submission credentials stay on the function.
- Do not relay arbitrary third-party mail through this API. Sends are audited (actor, recipients, Message-ID, outcome).
- Do not log the agent key. Redact it as `msn_<your-key>`.

## A product that uses this API

This is an integration on the Mail Service Now email API, not a Mail Service Now service. The generic recipe is [Any agent product](https://mailservicenow.com/developers/any-agent/).

1. If you do not have a key, follow [How an agent requests access](#how-an-agent-requests-access). Then set `MSN_API_BASE` and `MSN_AGENT_API_KEY=msn_<your-key>`. A ready key does not add the domain or create mailboxes. Continue with [After you have a standard key](#after-you-have-a-standard-key-set-up-your-domain-and-mailboxes).
2. `GET /mcp` or read this file to confirm tool names.
3. `tools/call` `get_credits` (or ignore credits if you send as a person in the web app; people are not charged and cannot call MCP).
4. `send_mail` / `sendMail`. Require HTTP 200 and `ok: true` / `structuredContent.ok === true`. Save `messageId`.
5. On **429**, read `error`: back off for `Rate limit exceeded`; stop and ask Mail Service Now for credits for `Insufficient credits`.
6. Optionally `list_inbox` then `get_message` for mail delivered to the mailbox. `mark_read` changes the unread flag without a credit. `POST /webhooks` registers an HTTPS URL for `inbound.received` events.

## Admin requests

An admin sign-in can remove access requests from the Requests page. Pending, verified, active, expired, and declined rows can be removed. The account and its key stay as they are. A customer key still works, and the account status still answers.

Delete one row, or select several and delete them. Each deleted id writes its own `request.delete` audit row. The row keeps status, type, id, account id, email, domain, and timestamps. It does not keep codes, code hashes, keys, tokens, or secrets. Deleting a row also clears that email's request rate counter, so a fresh `request_access` is accepted immediately.

Review duplicates before deleting them. Enter an email, a domain, or both. That filter is required. The preview keeps the newest open or active row for each email and domain and lists the rest. Untick any row to keep it. `POST /api/admin/requestDelete` then deletes exactly the ids you send, at most 200. It does not search again, and it does not delete every match on its own. Preview is `GET /api/admin/requestDuplicates` with `email`, `domain`, or both.

MCP `operator_delete_request` is admin only. Pass `id` or `ids`. A standard key, the shared agent key, and the MTA token are refused. The same check guards the REST routes.

## Admin domains

An admin sign-in can delete a domain from the Domains page. Verified, pending DNS, and failed rows each have Delete. The confirm dialog names the domain and how many mailboxes it has. If it has any mailbox, type the domain name before it is deleted. Mail stored for this domain is permanently deleted and cannot be recovered. Select several rows to delete them together. Each domain is its own request and writes its own `domain.remove` audit row.

`mailservicenow.com` cannot be deleted. The API returns **409** with code `system_domain`. A protected domain, and any domain that contains a protected address, stays **409**.

A domain whose account record is gone can still be deleted. The audit row sets `ownerMissing` to true. The claim is deleted in the same write only when it still names that account, so a different account can add the domain afterward. A customer's own mailbox removal does not release the claim. Mail already stored for that one mailbox stays until the whole domain is deleted.

The next mail sync drops that domain from the mail maps. Mail stored for the domain is permanently deleted and cannot be recovered. There is no backup.

MCP `operator_remove_domain` is admin only. Pass `uid` or `ownerEmail`, and `domainId`. Pass `confirm` when the domain has any mailbox. A standard key, the shared agent key, and the MTA token are refused. REST is `POST /api/admin/removeDomain` with the same body. The response says mail is permanently deleted and cannot be recovered.

## Related pages

- Developers: <https://mailservicenow.com/developers/>
- Any agent product recipe: <https://mailservicenow.com/developers/any-agent/>
- Legacy agent page (still served): <https://mailservicenow.com/docs/agents.html>
- OpenAPI: <https://mailservicenow.com/docs/openapi.json>
- Web app: <https://mailservicenow.com/app.html>
