# Using Nexmail from AI agents and scripts

Source: https://nexmail.nexprove.com/docs/ai-agents

A REST API for campaigns, built so an AI agent can do the work and a person still decides what goes out. Bearer tokens, JSON in and out, and refusals you can branch on.

## Rules for agents

Follow these exactly. They are how the API is built, not suggestions.

1. Use only the endpoints on this page. Do not guess other paths.
2. Never launch a campaign a person has not approved. The launch call must name that person, and it must echo the approval digest they saw.
3. Branch on the HTTP status and on `code` when present. Never on the wording of `error`; it changes.
4. A `202` from a launch means queued, not sent. Read the campaign afterwards to see what happened.
5. Never retry a `422` with a freshly read digest unless a person approved again. That defeats the approval.
6. Treat a held or deferred recipient as waiting, not failed. Nexmail never drops mail; it waits.
7. Never print, log or store the token anywhere but a secret store.

## Get a credential

1. An administrator signs in to Nexmail and opens **Settings → Credentials**.
2. They create a credential, name it after the agent (for example `crm-agent`), and tick only the scopes it needs.
3. Nexmail shows the token **once**. Store it in a secret manager. A lost token cannot be recovered; mint a new one and revoke the old.

The token looks like `mgw_<24 hex characters>_<secret>`. Send it on every request:

```http
Authorization: Bearer mgw_0123456789abcdef01234567_REPLACE_WITH_SECRET
Content-Type: application/json
```

Base URL: `https://nexmail.nexprove.com`. Every path below is relative to it.

### Scopes

| Scope | Allows |
| --- | --- |
| `campaigns:read` | List and read campaigns, read the approval digest, preview, read recipients and send limits. **Required for any launch flow**: without it you cannot read the digest the launch must echo. |
| `campaigns:write` | Create, edit and delete draft campaigns; add recipients; mark reply outcomes. |
| `campaigns:send` | Launch a campaign, with a person’s approval attached. |
| `campaigns:prerendered` | Create campaigns whose recipients each carry finished subject, HTML and text. Not implied by `campaigns:write`. Only for systems that render and approve mail themselves. |

A credential belongs to one organization and acts only inside it. It is not a user: it can never reach settings, raise a send limit, lift the emergency stop, or take a launch override.

## Errors

Every error is JSON with an `error` string. Some carry more fields:

```json
{ "error": "This credential does not carry campaigns:send" }
```

| Status | Means | What to do |
| --- | --- | --- |
| 400 | The body is invalid, or the action is not allowed in this state (for example editing a campaign that is not a draft). | Fix the request. Do not retry unchanged. |
| 401 | No token, or an unknown, malformed or revoked one. Body: `{"error":"Authentication required"}`. | Stop. Ask a person for a valid credential. |
| 403 | The credential lacks the scope, or the action is for people only. | Stop. Do not look for a way around it. |
| 404 | Not found in this organization. | Check the id. |
| 409 | The campaign changed while you were acting on it. | Read it again, then decide. |
| 422 | Refused on purpose: missing or stale approval, or content that would harm the recipient. Carries `blocked: true` and often `code`. | Read `code`. Most need a person. |
| 429 | Rate limited. Has a `retry-after` header in seconds. | Wait that long. |
| 500 | Server error. `{"error":"Internal server error"}`. | Retry later with backoff. Nothing you sent is half-applied in a way that loses mail. |

## Endpoints

| Method | Path | Scope | Does |
| --- | --- | --- | --- |
| GET | `/api/campaigns` | `campaigns:read` | The 100 newest campaigns, each with `stats`. |
| POST | `/api/campaigns` | `campaigns:write` | Create a draft. |
| GET | `/api/campaigns/:id` | `campaigns:read` | One campaign, its stats, approval and a sample of 50 recipients. Add `?approvalDigest=1` for the digest. |
| PATCH | `/api/campaigns/:id` | `campaigns:write` | Edit a draft. |
| DELETE | `/api/campaigns/:id` | `campaigns:write` | Delete a draft. Sent campaigns cannot be deleted. |
| GET | `/api/campaigns/:id/recipients` | `campaigns:read` | Search the list: `?q=` and `?take=` (default 50). |
| POST | `/api/campaigns/:id/recipients` | `campaigns:write` | Add up to 5,000 recipients per call. Safe to repeat. |
| PATCH | `/api/campaigns/:id/recipients/:recipientId` | `campaigns:write` | Record how a reply went. |
| GET | `/api/campaigns/:id/preview` | `campaigns:read` | Dry run: what a launch would do, with warnings. |
| POST | `/api/campaigns/:id/preview` | `campaigns:read` | Render the mail for one recipient or sample fields. |
| POST | `/api/campaigns/:id/test-send` | people only | Sends a test to the signed-in person. A credential gets 403. |
| POST | `/api/campaigns/:id/send` | `campaigns:send` | Launch, with the approval attached. Answers 202. |
| GET | `/api/campaigns/send-limits` | `campaigns:read` | Today’s sending ceilings and how much is left. |

### List campaigns

```bash
curl -s https://nexmail.nexprove.com/api/campaigns \
  -H "Authorization: Bearer $NEXMAIL_TOKEN"
```

200:

```json
{ "campaigns": [ { "id": "CAMPAIGN_ID", "name": "October update", "status": "draft",
  "contentMode": "template", "fromAddress": "news@example.com", "stats": { "total": 0, "pending": 0 } } ] }
```

`status` is one of `draft`, `sending`, `sent`, `failed`.

### Create a draft

Fields: `name` (required), `subject` (required), `bodyHtml` and/or `bodyText` (at least one), `from` (required: an address on a verified domain of this organization), `fromName`, `subjectB` (a second subject to A/B test). Merge fields are written `{{firstName}}` and filled from each recipient’s `fields`.

```bash
curl -s -X POST https://nexmail.nexprove.com/api/campaigns \
  -H "Authorization: Bearer $NEXMAIL_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"October update","subject":"What changed in October",
       "bodyHtml":"<p>Hi {{firstName}},</p><p>Here is what changed.</p>",
       "bodyText":"Hi {{firstName}},\n\nHere is what changed.",
       "from":"news@example.com","fromName":"Example Co"}'
```

201:

```json
{ "campaign": { "id": "CAMPAIGN_ID", "name": "October update", "status": "draft" } }
```

### Edit a draft

Send only the fields to change: `name`, `subject`, `bodyHtml`, `bodyText`, `from`, `fromName`, `subjectB` (null turns the test off). Anything but a draft answers 400.

PATCH /api/campaigns/:id:

```json
{ "subject": "What changed this month" }
```

### Add recipients

Up to 5,000 per call; send a long list in several calls. Repeating a call adds nobody twice. Addresses on the organization’s suppression list (unsubscribed, bounced, complained) come back in `suppressed` and are never mailed.

POST /api/campaigns/:id/recipients:

```json
{ "recipients": [
  { "email": "ada@example.org", "fields": { "firstName": "Ada" } },
  { "email": "lin@example.org", "fields": { "firstName": "Lin" } }
] }
```

200:

```json
{ "added": 2, "skipped": 0, "suppressed": [] }
```

Adding people **after** a campaign was approved needs a fresh approval for the addition: send the same `authorisation` block as a launch, without `launchedBy`, carrying the digest read just before the person approved. Without it the answer is 422 `addition_requires_approval`.

### Dry run and preview

`GET /api/campaigns/:id/preview` reports what a launch would do: counts, recipients missing merge fields, content that would be refused, which transports would carry it, and plain-language `warnings`. Show every warning to the person approving.

200 (shortened):

```json
{ "campaignId": "CAMPAIGN_ID", "status": "draft", "subjectTest": false,
  "stats": { "total": 2, "pending": 2 },
  "warnings": [ "Launching this campaign over a machine credential requires an authorisation block naming the person who approved it. …" ] }
```

`POST /api/campaigns/:id/preview` renders the actual mail. Body (all optional): `recipientId`, or `fields` for sample data, and `variant` (`"A"` or `"B"`). The answer has `subject`, `html`, `text`, `missing` (merge fields it could not fill) and `refusals`.

POST /api/campaigns/:id/preview:

```json
{ "fields": { "firstName": "Ada" } }
```

### Test send

A test goes to the signed-in person’s own inbox, so it needs a person in the Nexmail app: **a credential gets 403**. Each person can send 10 tests an hour; the 11th answers 429 with `retry-after`. Ask the approver to send themselves a test from the campaign page.

### Read the approval digest

Right before a person approves, read the campaign with `?approvalDigest=1`. The digest fingerprints the content (subject, bodies, sender, click tracking, test subject) and the full recipient list. Keep the object exactly as returned.

```bash
curl -s "https://nexmail.nexprove.com/api/campaigns/CAMPAIGN_ID?approvalDigest=1" \
  -H "Authorization: Bearer $NEXMAIL_TOKEN"
```

200 (shortened):

```json
{ "campaign": { "id": "CAMPAIGN_ID", "status": "draft" },
  "approvalDigest": { "content": "7285e7f5…64 hex", "recipients": "6b5e4a64…64 hex" } }
```

### Launch

The `authorisation` block is required from a credential. `approvedBy` is the person who approved; `launchedBy` is who pressed launch (send the same person twice if one person did both). `approvedAt` is when they approved: not in the future, not older than 30 days. Unknown keys are rejected.

POST /api/campaigns/:id/send:

```json
{
  "authorisation": {
    "approvedBy": { "ref": "crm-user-9f3", "name": "Dana Okafor" },
    "approvedAt": "2026-10-14T09:58:02.000Z",
    "launchedBy": { "ref": "crm-user-9f3", "name": "Dana Okafor" },
    "digest": { "content": "7285e7f5…", "recipients": "6b5e4a64…" }
  },
  "allowMissingFields": false
}
```

202:

```json
{ "queued": true, "campaignId": "CAMPAIGN_ID", "pending": 2,
  "approvedBy": { "source": "crm_assertion", "ref": "crm-user-9f3", "name": "Dana Okafor", "asserted": true },
  "launchedBy": { "source": "crm_assertion", "ref": "crm-user-9f3", "name": "Dana Okafor", "asserted": true },
  "approverLaunchedIt": true }
```

`allowMissingFields: true` sends even where a merge field is empty. Leave it false unless a person chose otherwise. `overrideUnauthenticatedTransport` and `overrideUnroutedReplyTo` exist for signed-in administrators only; a credential that sets either gets 403.

Launch refusals that need a person (422, `blocked: true`):

| `code` | Means | Do |
| --- | --- | --- |
| `missing_authorisation` | No `authorisation` block. | Your request is incomplete. Fix it; no person needed. |
| `stale_approval` | The campaign or its list changed after the approval. `mismatched` says which (`content`, `recipients`). | Show the person the current campaign (the 422 carries the new `approvalDigest`), get a new approval, launch again. |
| `approval_expired` | `approvedAt` is more than 30 days old. | Ask the person again. |
| `approval_in_future` | `approvedAt` is more than 5 minutes ahead of Nexmail’s clock. | Fix the clock or the field. |

A 422 **without** `code` but with `refusals` means the content itself is blocked (for example a link whose text names a different site than it opens). Edit the draft; it cannot be overridden.

### After launch

Poll `GET /api/campaigns/:id` (once a minute is plenty). Read each recipient’s `disposition.state`, not `status`: it is one of `pending`, `queued`, `held` (waiting for a later window, with `until`), `sending`, `retrying`, `dispatched` (handed to a provider), `failed`, `suppressed` or `unknown`. Only `failed` is a failure. `held` alongside `stats` says how many are waiting and when the first goes. If every provider refuses at launch, the campaign stays `draft` with everyone `pending`; launching again later is safe and adds nobody twice.

### Record a reply outcome

PATCH /api/campaigns/:id/recipients/:recipientId:

```json
{ "disposition": "meeting_booked" }
```

`disposition` is `interested`, `meeting_booked`, `not_interested`, or `null` to clear it.

### Send limits

```bash
curl -s "https://nexmail.nexprove.com/api/campaigns/send-limits?from=news@example.com" \
  -H "Authorization: Bearer $NEXMAIL_TOKEN"
```

200 (shortened):

```json
{ "window": { "day": "2026-10-14", "timezone": "UTC", "nextWindowStart": "2026-10-15T00:00:00.000Z" },
  "axes": [ { "scope": "global", "label": "the gateway-wide daily limit", "limit": 500, "used": 120, "remaining": 380, "exhausted": false } ],
  "effective": { "scope": "global", "remaining": 380 },
  "unresolved": ["transport"], "exhausted": false, "warmup": null, "deferredCount": 0 }
```

`effective: null` with an empty `unresolved` means no limit is set. `provider` is optional (`resend`, `smtp`, `brevo`, `smtp2go`, `outreach`, `ses`); an unknown one answers 400.

## The safety model

- **Approval digest.** A launch carries the digest a person saw. Nexmail recomputes it from its own data and refuses if anything moved. It proves what was approved; the approver’s name is your word, recorded as `crm_assertion`.
- **Never lose mail.** Every message is saved before it is queued. When no provider is available, mail waits in the queue; it is not marked failed.
- **Suppression.** Unsubscribed, bounced and complained addresses are never mailed, even if you add them. If the list cannot be read, bulk mail is held rather than sent blind.
- **Daily ceilings.** Per organization, per provider and per domain, plus a warm-up ramp for new domains. Mail over a ceiling waits for the next window; it does not fail and does not use up retries.
- **Emergency stop.** An administrator can stop outgoing mail in the app. It acts as a ceiling of zero: `send-limits` shows an axis with `scope: "stop"`, `limit: 0`, `exhausted: true`, and mail waits until it is lifted. A credential cannot lift it.
- **Content checks.** Executable attachments, links that lie about where they go and spoofed sender names are refused before sending.
- **Rate limits.** Test sends: 10 per person per hour (429 with `retry-after`).
- **Audit log.** Every create, edit, addition, launch and refusal is recorded against the credential.

## Recommended workflow

1. **Check limits.** `GET /api/campaigns/send-limits?from=…`. If `exhausted` is true, tell the person mail will wait.
2. **Create the draft.** `POST /api/campaigns`. Save the `id`.
3. **Add recipients.** `POST /api/campaigns/:id/recipients` in pages of up to 5,000. Report `suppressed` to the person.
4. **Preview.** `GET /api/campaigns/:id/preview` and `POST /api/campaigns/:id/preview` for one or two recipients. Fix every missing field and refusal by editing the draft.
5. **Test send.** Ask the person to send themselves a test from the campaign page in Nexmail and read it in their inbox.
6. **Human approval.** Read `GET /api/campaigns/:id?approvalDigest=1`. Show the person the subject, body preview, recipient count and every warning. Record who approved, and when.
7. **Send.** `POST /api/campaigns/:id/send` with the `authorisation` block and the digest from step 6. Expect 202.
8. **Watch.** Poll `GET /api/campaigns/:id` and report progress. On a 422, follow the table above; do not loop.

## Not yet available

These are not open to credentials today. Do not call them or invent paths for them:

- Reading the inbox, conversations or messages.
- Replying to or composing individual emails.
- Contacts and contact lists.
- Settings, domains, providers, routes and teammates.
- An MCP server. It is planned, not built.

## For language models

- This page as Markdown: [/docs/ai-agents.md](https://nexmail.nexprove.com/docs/ai-agents.md).
- A summary of Nexmail and links to every page: [/llms.txt](https://nexmail.nexprove.com/llms.txt).
- Everything in one file, including this page: [/llms-full.txt](https://nexmail.nexprove.com/llms-full.txt).

---

Nexmail by Nextlabs. Last updated 2026-10-14. HTML version: https://nexmail.nexprove.com/docs/ai-agents
