# Mailumi email API documentation

> The Mailumi email API is a JSON API over HTTPS at `https://mailumi.com/api/v1`. Authenticate with a Bearer API key, send email with `POST /emails` or `POST /emails/batch`, read messages and delivery events with `GET /emails`, and get delivery events and incoming email as signed webhooks.

HTML version: https://mailumi.com/docs. Prompts for AI coding tools: https://mailumi.com/ai. Everything in one file: https://mailumi.com/llms-full.txt.

## Quickstart

1. Create a free account and confirm your email address.
2. Add your domain in Dashboard → Domains and add the DNS records shown. On Cloudflare, Mailumi can add them for you.
3. Create an API key in Dashboard → API keys and store it in your server’s environment variables as `MAILUMI_API_KEY`.
4. Send your first email with one HTTPS request from your backend, then follow it in Email activity.

**cURL**

```bash
curl -X POST https://mailumi.com/api/v1/emails \
  -H "Authorization: Bearer $MAILUMI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: welcome-user-123" \
  -d '{
    "from": "Acme <hello@yourdomain.com>",
    "to": "alex@example.com",
    "subject": "Welcome aboard",
    "html": "<p>Your account is ready.</p>",
    "text": "Your account is ready."
  }'
```

**Node.js**

```js
// npm install mailumi
import { Mailumi } from 'mailumi';

const mailumi = new Mailumi(process.env.MAILUMI_API_KEY);

const { data, error } = await mailumi.emails.send({
  from: 'Acme <hello@yourdomain.com>',
  to: 'alex@example.com',
  subject: 'Welcome aboard',
  html: '<p>Your account is ready.</p>',
  text: 'Your account is ready.',
}, { idempotencyKey: 'welcome-user-123' });

if (error) throw new Error(error.message);
console.log(data.id, data.status);
```

**Python**

```python
import os
import requests

response = requests.post(
    "https://mailumi.com/api/v1/emails",
    headers={
        "Authorization": f"Bearer {os.environ['MAILUMI_API_KEY']}",
        "Idempotency-Key": "welcome-user-123",
    },
    json={
        "from": "Acme <hello@yourdomain.com>",
        "to": "alex@example.com",
        "subject": "Welcome aboard",
        "html": "<p>Your account is ready.</p>",
        "text": "Your account is ready.",
    },
    timeout=30,
)
email = response.json()
if not response.ok:
    raise RuntimeError(email["error"])
print(email["id"], email["status"])
```

**PHP**

```php
<?php
$ch = curl_init('https://mailumi.com/api/v1/emails');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('MAILUMI_API_KEY'),
        'Content-Type: application/json',
        'Idempotency-Key: welcome-user-123',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'from' => 'Acme <hello@yourdomain.com>',
        'to' => 'alex@example.com',
        'subject' => 'Welcome aboard',
        'html' => '<p>Your account is ready.</p>',
        'text' => 'Your account is ready.',
    ]),
]);
$email = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 300) {
    throw new RuntimeException($email['error']);
}
echo $email['id'];
```

**Go**

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	body, _ := json.Marshal(map[string]any{
		"from":    "Acme <hello@yourdomain.com>",
		"to":      "alex@example.com",
		"subject": "Welcome aboard",
		"html":    "<p>Your account is ready.</p>",
		"text":    "Your account is ready.",
	})
	req, _ := http.NewRequest("POST", "https://mailumi.com/api/v1/emails", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("MAILUMI_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", "welcome-user-123")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	var email map[string]any
	json.NewDecoder(res.Body).Decode(&email)
	if res.StatusCode >= 300 {
		panic(email["error"])
	}
	fmt.Println(email["id"], email["status"])
}
```

For Node.js, Bun, Deno and Cloudflare Workers, use the official [mailumi package](https://www.npmjs.com/package/mailumi). Other languages need no SDK: any language that can make an HTTPS request can use Mailumi.

## Node.js SDK and CLI

The official [mailumi](https://www.npmjs.com/package/mailumi) package is a typed client with no dependencies for Node.js 18+, Bun, Deno, Cloudflare Workers and any other runtime with `fetch`, as ESM or CommonJS. Every method resolves to `{ data, error }` and never throws for API errors, the same shape as the Resend SDK, so switching from Resend means changing the import and the API key.

```bash
npm install mailumi
```

```ts
import { Mailumi } from 'mailumi';

const mailumi = new Mailumi(); // reads MAILUMI_API_KEY

// Send later, with an attachment.
const { data, error } = await mailumi.emails.send({
  from: 'Acme <billing@yourdomain.com>',
  to: ['alex@example.com'],
  replyTo: 'support@yourdomain.com',
  subject: 'Your invoice',
  text: 'Your invoice is attached.',
  attachments: [{ filename: 'invoice.pdf', content: pdfBytes }],
  scheduledAt: 'in 1 hour',
}, { idempotencyKey: `invoice/${invoice.id}` });

// Check delivery, or cancel while it is scheduled.
await mailumi.emails.get(data.id);
await mailumi.emails.cancel(data.id);

// Up to 100 emails in one request.
await mailumi.batch.send([
  { from, to: 'a@example.com', subject, html },
  { from, to: 'b@example.com', subject, html },
]);

// Contacts for campaigns (needs an API key with contacts:write).
await mailumi.contacts.create({ audienceId: 'aud_…', email: 'alex@example.com' });
```

Options accept camelCase (`replyTo`, `scheduledAt`, `templateId`) as well as the API’s snake_case. Attachment content can be bytes or a base64 string. Rate limits (429) and temporary errors (5xx) are retried twice with backoff. Each send carries an `Idempotency-Key`, reused on every retry, so a retry never sends twice; pass `{ idempotencyKey }` to choose your own. `verifyWebhook` checks webhook signatures, as shown under [Webhooks](#webhooks).

| Method | Endpoint |
| --- | --- |
| `emails.send(email, { idempotencyKey })` | `POST /emails` |
| `batch.send(emails, { idempotencyKey })` | `POST /emails/batch` |
| `emails.list({ direction, before })` | `GET /emails` |
| `emails.get(id)` | `GET /emails/{id}` |
| `emails.update({ id, scheduledAt })` | `PATCH /emails/{id}` |
| `emails.cancel(id)` | `POST /emails/{id}/cancel` |
| `emails.attachment({ id, index })` | `GET /emails/{id}/attachments/{index}` |
| `domains.list()` | `GET /domains` |
| `audiences.list()`, `create({ name })`, `get(id)`, `update({ id, name })`, `remove(id)` | `/audiences` and `/audiences/{id}` |
| `contacts.create()`, `import()`, `list()`, `get()`, `update()`, `remove()` | `/audiences/{id}/contacts` and `/audiences/{id}/contacts/{contact}` |
| `verifyWebhook({ payload, headers, secret })` | Runs locally; needs no API key |

The package also installs the `mailumi` command. Set `MAILUMI_API_KEY`, then send email, check delivery and manage contacts from a terminal or a CI job. Add `--json` for machine-readable output; errors exit with code 1.

```bash
npx mailumi send --from "Acme <hello@yourdomain.com>" --to alex@example.com \
  --subject "Deploy finished" --text "Version 2.4 is live." --attach ./report.pdf
npx mailumi emails get <id>
npx mailumi domains list
npx mailumi contacts add <audience_id> alex@example.com --first-name Alex
npx mailumi --help
```

## Send with SMTP

Software that only speaks SMTP can send through Mailumi too: WordPress, Supabase Auth, Ghost, Django, Laravel, Rails, Nodemailer, PHPMailer, monitoring tools and printers. Use these settings:

| Setting | Value |
| --- | --- |
| Host | `smtp.mailumi.com` |
| Port | `465` (TLS) or `587` (STARTTLS) |
| Username | `mailumi` |
| Password | An API key with the `email:send` permission |
| Encryption | Required: TLS 1.2 or newer before you log in |

**Nodemailer**

```js
import nodemailer from 'nodemailer';

const transport = nodemailer.createTransport({
  host: 'smtp.mailumi.com',
  port: 465,
  secure: true,
  auth: { user: 'mailumi', pass: process.env.MAILUMI_API_KEY },
});

await transport.sendMail({
  from: 'Acme <hello@yourdomain.com>',
  to: 'alex@example.com',
  subject: 'Welcome to Acme',
  html: '<p>Thanks for signing up.</p>',
});
```

**Django**

```python
# settings.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.mailumi.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "mailumi"
EMAIL_HOST_PASSWORD = os.environ["MAILUMI_API_KEY"]
DEFAULT_FROM_EMAIL = "Acme <hello@yourdomain.com>"
```

**Laravel**

```env
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailumi.com
MAIL_PORT=587
MAIL_ENCRYPTION=tls
MAIL_USERNAME=mailumi
MAIL_PASSWORD=your_mailumi_api_key
MAIL_FROM_ADDRESS=hello@yourdomain.com
MAIL_FROM_NAME="Acme"
```

**Rails**

```ruby
# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: "smtp.mailumi.com",
  port: 587,
  user_name: "mailumi",
  password: ENV["MAILUMI_API_KEY"],
  authentication: :plain,
  enable_starttls_auto: true
}
```

**PHPMailer**

```php
use PHPMailer\PHPMailer\PHPMailer;

$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = 'smtp.mailumi.com';
$mail->Port = 465;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mail->SMTPAuth = true;
$mail->Username = 'mailumi';
$mail->Password = getenv('MAILUMI_API_KEY');
$mail->setFrom('hello@yourdomain.com', 'Acme');
$mail->addAddress('alex@example.com');
$mail->Subject = 'Welcome to Acme';
$mail->Body = 'Thanks for signing up.';
$mail->send();
```

**Python**

```python
import os, smtplib
from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "Acme <hello@yourdomain.com>"
msg["To"] = "alex@example.com"
msg["Subject"] = "Welcome to Acme"
msg.set_content("Thanks for signing up.")

with smtplib.SMTP_SSL("smtp.mailumi.com", 465) as smtp:
    smtp.login("mailumi", os.environ["MAILUMI_API_KEY"])
    smtp.send_message(msg)
```

Email sent through SMTP is handled exactly like an API request: the From address must be on a verified domain, it counts toward your plan, suppressed recipients are skipped, and it appears in Email activity and sends the same webhook events. The success reply contains the email ID, as in `250 Queued as 3f2c…`, which you can look up with `GET /emails/{id}`.

- Recipients come from the SMTP envelope. Addresses in the To and Cc headers stay visible; every other envelope recipient is sent as Bcc.
- Attachments and inline images (`cid:`) are kept, as are threading and list headers (`In-Reply-To`, `References`, `List-Unsubscribe`) and your own `X-` headers.
- Up to 50 recipients per message and 20 MB per message, with attachments under 10 MB in total.
- If your client resends a message after a dropped connection, the same Message-ID and recipients return the original email instead of sending it twice.
- Use AUTH PLAIN or AUTH LOGIN. Any username works; `mailumi` is a good choice. Restrict the key to one domain if the app only sends from that domain.

| Reply | Meaning |
| --- | --- |
| `250` | Accepted and queued for delivery. |
| `535` | Login failed: the API key is wrong, revoked, expired or lacks `email:send`. |
| `550` | Rejected for good, with the reason: for example a From domain that is not verified, a recipient that is not a valid address, or the plan limit. Your client should not retry. |
| `451` | Temporary, for example the rate limit of 120 requests per minute. Send the message again a little later; mail servers and apps with a mail queue do this on their own. |
| `452` | More than 50 recipients; the rest are refused and can go in another message. |
| `552` | The message is too large. |
| `421` | Too many failed logins from your address; wait 15 minutes. |

The SMTP server runs in Amsterdam on Fly.io. It does not store messages: each one is passed straight to the Mailumi API in the EU over HTTPS and handled from there. For new code, the [HTTP API](#sending) or the [Node.js SDK](#sdk) gives you more, such as scheduling, templates, tags and batch sends.

## Build with AI

Using Cursor, Claude Code, Codex, GitHub Copilot, Windsurf, Lovable, Bolt, v0 or Replit? Point your assistant at the complete reference in one file, [llms-full.txt](https://mailumi.com/llms-full.txt), or this page as Markdown, [docs.md](https://mailumi.com/docs.md). Ready-made prompts for sending, webhooks, receiving and switching providers are on the [Build with AI](https://mailumi.com/ai) page.

To teach your assistant Mailumi for good, save these rules in your project as `AGENTS.md`, `CLAUDE.md` or a Cursor rule in `.cursor/rules/mailumi.mdc`:

**AGENTS.md**

```markdown
# Mailumi email API

This project sends and receives email with Mailumi. Complete reference: https://mailumi.com/llms-full.txt

- Base URL: https://mailumi.com/api/v1. Authenticate with the header "Authorization: Bearer $MAILUMI_API_KEY". Call Mailumi only from server-side code; never expose the key to browsers or mobile apps.
- JavaScript and TypeScript: use the official package (npm install mailumi). const mailumi = new Mailumi(process.env.MAILUMI_API_KEY); const { data, error } = await mailumi.emails.send({ from, to, subject, html }, { idempotencyKey }). Methods never throw for API errors; they resolve to { data, error }. Verify webhooks with verifyWebhook({ payload: rawBody, headers, secret }).
- Send: POST /emails with JSON { from, to, subject, html and/or text } and optional cc, bcc, reply_to, attachments [{ filename, content (base64), content_type, content_id }], headers, tags [{ name, value }] and scheduled_at. The from domain must be verified in Mailumi.
- Batch: POST /emails/batch with an array of up to 100 emails (no attachments or scheduled_at). Templates: pass template_id and variables instead of subject and html.
- Software that only speaks SMTP: host smtp.mailumi.com, port 465 (TLS) or 587 (STARTTLS), username mailumi, password $MAILUMI_API_KEY.
- Always send an Idempotency-Key header that is stable for each logical email. Retry 429 and 503 with the same key.
- Errors are JSON { "error": "message", "code": "machine_readable_code" } with a 4xx or 5xx status. On 429, wait the seconds in the Retry-After header. OpenAPI description: https://mailumi.com/openapi.json
- Webhooks use the Standard Webhooks format. Verify webhook-signature ("v1," + base64 HMAC-SHA256 of webhook-id + "." + webhook-timestamp + "." + raw body) with the base64 part of MAILUMI_WEBHOOK_SECRET after "whsec_", in constant time. Reject timestamps older than 5 minutes and process each event id once.
- Events: email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked, email.failed, email.unsubscribed, email.received.
```

## MCP server

Connect Cursor, Claude Code, VS Code, Codex or any other MCP client to `https://mailumi.com/mcp` and your assistant can send a test email, check whether it was delivered and see which domains are verified, without leaving the editor. The server uses your API keys and their permissions, so a key without `email:send` gives read-only access.

**Cursor**

```json
{
  "mcpServers": {
    "mailumi": {
      "url": "https://mailumi.com/mcp",
      "headers": {
        "Authorization": "Bearer ${env:MAILUMI_API_KEY}"
      }
    }
  }
}
```

**Claude Code**

```bash
claude mcp add --transport http mailumi https://mailumi.com/mcp \
  --header "Authorization: Bearer $MAILUMI_API_KEY"
```

**VS Code**

```json
{
  "inputs": [
    {
      "type": "promptString",
      "id": "mailumi-api-key",
      "description": "Mailumi API key",
      "password": true
    }
  ],
  "servers": {
    "mailumi": {
      "type": "http",
      "url": "https://mailumi.com/mcp",
      "headers": {
        "Authorization": "Bearer ${input:mailumi-api-key}"
      }
    }
  }
}
```

**Codex**

```toml
[mcp_servers.mailumi]
url = "https://mailumi.com/mcp"
bearer_token_env_var = "MAILUMI_API_KEY"
```

- Cursor: [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=mailumi&config=eyJ1cmwiOiJodHRwczovL21haWx1bWkuY29tL21jcCIsImhlYWRlcnMiOnsiQXV0aG9yaXphdGlvbiI6IkJlYXJlciAke2VudjpNQUlMVU1JX0FQSV9LRVl9In19) in one click, or save the JSON in `~/.cursor/mcp.json`.
- Claude Code: run the command in your terminal.
- VS Code: save the JSON as `.vscode/mcp.json`; VS Code asks for the key once and stores it securely.
- Codex: add the lines to `~/.codex/config.toml`.

Set `MAILUMI_API_KEY` in your system environment before you open the editor. You can also put the key straight into your user-level config instead of the variable, but never into a file you commit.

| Tool | Permission | What it does |
| --- | --- | --- |
| `send_email` | `email:send` | Send an email, for example a test to your own address |
| `list_emails` | `email:read` | List recent sent and received email |
| `get_email` | `email:read` | Check delivery: each recipient’s status and every event |
| `reschedule_email` | `email:send` | Change when a scheduled email is sent |
| `cancel_email` | `email:send` | Cancel a scheduled email |
| `list_domains` | `domains:read` | List your domains and their DNS verification status |
| `get_documentation` | Any key | Read the complete API reference as Markdown |

Emails sent through MCP are ordinary emails: they count toward your plan and appear in Email activity. The server speaks the Streamable HTTP transport, and requests count toward the same 120 per minute limit as the API.

## Authentication and base URL

All endpoints live under `https://mailumi.com/api/v1`. Create a key in Dashboard → API keys, choose the permissions it needs and, if you like, restrict it to one domain or give it an expiry date. The full key is shown once; store it straight away.

Send the key in an `Authorization: Bearer` header from your backend, never from browser or mobile code. The permissions are `email:send`, `email:read`, `domains:read` and `contacts:write` (audiences and contacts). Revoke a key at any time in the dashboard.

## Send an email

Send a JSON body with `from`, `to`, `subject`, and `text`, `html` or both. The sender’s domain must be verified in your account. Optional fields are `cc`, `bcc`, `reply_to`, `attachments`, `headers`, `tags` and `scheduled_at`.

**Request**

```http
POST https://mailumi.com/api/v1/emails
Authorization: Bearer YOUR_MAILUMI_API_KEY
Content-Type: application/json
Idempotency-Key: welcome-user-123

{
  "from": "Acme <hello@yourdomain.com>",
  "to": "alex@example.com",
  "subject": "Welcome aboard",
  "html": "<p>Your account is ready.</p>",
  "text": "Your account is ready.",
  "tags": [{ "name": "category", "value": "welcome" }]
}
```

The response is the new email, with its `id` and `status` `queued`. Mailumi sends it within seconds and records every following event.

**Response**

```json
{
  "id": "…",
  "object": "email",
  "domain_id": "…",
  "direction": "outbound",
  "from": "hello@yourdomain.com",
  "to": ["alex@example.com"],
  "subject": "Welcome aboard",
  "status": "queued",
  "last_event": "queued",
  "scheduled_at": null,
  "tags": { "category": "welcome" },
  "error": null,
  "created_at": "2026-10-06T09:00:00.000Z"
}
```

The optional `Idempotency-Key` header (1–256 characters) protects against duplicates for 24 hours: if a request times out, repeat it with the same key and Mailumi returns the original email instead of sending twice. Reusing a key with different content returns `409`.

An attachment is an object with `filename`, base64 `content`, an optional `content_type` and an optional `content_id` to show it inline in HTML with `<img src="cid:…">`. `headers` is an object of extra headers such as `List-Unsubscribe`. `tags` are up to 10 name and value pairs of letters, numbers, underscores and dashes; they are returned with the email and in every webhook event.

## Send a batch

Send up to 100 emails in one request with `POST /emails/batch` and a JSON array of email objects. The batch is accepted or rejected as a whole: if one email is invalid or the batch exceeds your plan limit, nothing is sent and the error names the email. Batch emails cannot have attachments or `scheduled_at`.

```http
POST https://mailumi.com/api/v1/emails/batch
Authorization: Bearer YOUR_MAILUMI_API_KEY
Content-Type: application/json

[
  { "from": "hello@yourdomain.com", "to": "alex@example.com", "subject": "Your receipt", "text": "…" },
  { "from": "hello@yourdomain.com", "to": "sam@example.com", "subject": "Your receipt", "text": "…" }
]

→ { "data": [{ "id": "…" }, { "id": "…" }] }
```

## Schedule, reschedule and cancel

Add `scheduled_at` to send later, up to 30 days ahead. Use an ISO 8601 date such as `2026-10-07T09:00:00Z` or plain English such as `in 1 hour` or `in 3 days`. The email gets the status `scheduled` until it is sent.

Change the time with `PATCH /emails/:id` and a new `scheduled_at`, or cancel with `POST /emails/:id/cancel`. Canceling returns the email to your plan allowance. Only scheduled emails that have not been sent can be changed.

## Send with a template

Create a template in Dashboard → Templates with placeholders such as `{{first_name}}`, then pass its ID and the values instead of `subject` and `html`. Values are HTML-escaped, and any variable you leave out uses the fallback saved with the template. A `subject` in the request overrides the template’s subject.

```json
{
  "from": "Acme <hello@yourdomain.com>",
  "to": "alex@example.com",
  "template_id": "tpl_…",
  "variables": { "first_name": "Alex", "login_url": "https://app.acme.com" }
}
```

## Campaigns and contacts

Campaigns are newsletters and announcements sent to an audience, a list of contacts. Write and send them in Dashboard → Campaigns. Each recipient counts as one email toward your plan, and every campaign email shows up in Email activity with its own delivery events.

- Add contacts in Dashboard → Audiences one by one, by pasting a list, or by importing a CSV from another tool. Columns named email, first name and last name are recognized automatically.
- Personalize the subject and message with `{{first_name}}`, `{{last_name}}` and `{{email}}`.
- Every campaign email gets your workspace name, your postal address and an unsubscribe link in the footer, plus `List-Unsubscribe` and `List-Unsubscribe-Post` headers so Gmail, Yahoo and Apple Mail show their one-click unsubscribe button. Add the postal address in Dashboard → Settings.
- An unsubscribe applies to the address in every audience of the workspace and fires the `email.unsubscribed` webhook event. Importing a list never resubscribes anyone, and suppressed addresses are skipped.
- Campaigns are sent at a steady pace on a separate queue, so they never delay your transactional email.

Keep audiences in sync from your app with a key that has the `contacts:write` permission. Add one contact with an object, or import up to 1,000 at once with an array:

```bash
curl -X POST https://mailumi.com/api/v1/audiences/aud_…/contacts \
  -H "Authorization: Bearer $MAILUMI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "alex@example.com", "first_name": "Alex" }'
```

To unsubscribe someone who opted out in your app, send `PATCH /audiences/:id/contacts/alex%40example.com` with `{ "unsubscribed": true }`. Only send campaigns to people who agreed to receive them.

## Endpoints

| Method & path | Permission | Purpose |
| --- | --- | --- |
| `POST /emails` | `email:send` | Send an email |
| `POST /emails/batch` | `email:send` | Send up to 100 emails at once |
| `PATCH /emails/:id` | `email:send` | Change the send time of a scheduled email |
| `POST /emails/:id/cancel` | `email:send` | Cancel a scheduled email |
| `GET /emails` | `email:read` | List sent and received email |
| `GET /emails/:id` | `email:read` | Read one email with per-recipient status and its events |
| `GET /emails/:id/attachments/:index` | `email:read` | Download an attachment |
| `GET /domains` | `domains:read` | List your domains and their verification status |
| `GET /audiences` | `contacts:write` | List audiences with contact counts |
| `POST /audiences` | `contacts:write` | Create an audience |
| `PATCH /audiences/:id` | `contacts:write` | Rename an audience |
| `DELETE /audiences/:id` | `contacts:write` | Delete an audience and its contacts |
| `GET /audiences/:id/contacts` | `contacts:write` | List or search contacts, 100 per page |
| `POST /audiences/:id/contacts` | `contacts:write` | Add one contact, or import up to 1,000 |
| `GET /audiences/:id/contacts/:contact` | `contacts:write` | Read a contact by ID or email address |
| `PATCH /audiences/:id/contacts/:contact` | `contacts:write` | Change a name or unsubscribe |
| `DELETE /audiences/:id/contacts/:contact` | `contacts:write` | Remove a contact |

Paths are relative to `https://mailumi.com/api/v1`. Filter the list with `direction=inbound` or `direction=outbound`. Results come 50 at a time as `{ "data": […], "next_cursor": … }`; pass `next_cursor` as the `before` parameter to load the next page.

The [OpenAPI 3.1 description](https://mailumi.com/openapi.json) lists every endpoint with its operation ID, request and response schemas and errors. Use it to generate a client or to give an AI agent the API as function calls.

## Statuses and events

Every email has a `status` and a `last_event`, and each recipient has its own status. `GET /emails/:id` returns the full event history.

| Event | When |
| --- | --- |
| `email.sent` | The email left Mailumi for the recipient’s mail server |
| `email.delivered` | The recipient’s mail server accepted it |
| `email.delivery_delayed` | The receiving server is temporarily refusing it and delivery is being retried |
| `email.bounced` | The address does not exist or refused the email; hard bounces are suppressed |
| `email.complained` | The recipient marked the email as spam; the address is suppressed |
| `email.opened` | The recipient opened the email (open tracking on) |
| `email.clicked` | The recipient clicked a link (click tracking on) |
| `email.failed` | The email could not be sent; the reason is included |
| `email.unsubscribed` | The recipient unsubscribed from a campaign, by its link or the email app’s unsubscribe button |
| `email.received` | An email arrived on one of your receiving domains |

Turn on open and click tracking per domain in Dashboard → Domains; both are off by default. Addresses that hard-bounce or complain are added to your suppression list in Dashboard → Suppressions. Later emails to them are accepted but not sent, do not count toward your plan, and show the recipient status `suppressed`.

## Webhooks

Add an HTTPS endpoint in Dashboard → Webhooks and choose its events. Each delivery is a JSON object with `id`, `type`, `created_at` and `data`; `data.email_id` identifies the email. `email.received` includes the sender, recipients, subject, text, HTML and attachment links, which you download with a read key. Press Send test for a `webhook.test` event.

**Event**

```json
{
  "id": "…",
  "type": "email.delivered",
  "created_at": "2026-10-06T09:00:04.000Z",
  "data": {
    "email_id": "…",
    "domain_id": "…",
    "direction": "outbound",
    "from": "hello@yourdomain.com",
    "to": ["alex@example.com"],
    "subject": "Welcome aboard",
    "status": "delivered",
    "last_event": "delivered",
    "scheduled_at": null,
    "tags": { "category": "welcome" },
    "error": null,
    "created_at": "2026-10-06T09:00:00.000Z",
    "recipient": "alex@example.com",
    "delivery": { "smtp_response": "250 2.0.0 OK" }
  }
}
```

Mailumi signs every delivery with the [Standard Webhooks](https://www.standardwebhooks.com/) format, so any Standard Webhooks library can verify it. Read the raw body before parsing JSON, compute HMAC-SHA256 over `webhook-id.webhook-timestamp.body` with the base64-decoded part of your `whsec_` secret, and compare it in constant time with the value after `v1,`. Reject old timestamps and store event IDs so you process each event once.

**Node.js**

```js
// npm install mailumi
import { verifyWebhook } from 'mailumi';

// Pass the raw body before parsing JSON: the signature covers the exact bytes.
const { data: event, error } = await verifyWebhook({
  payload: await request.text(),
  headers: request.headers,
  secret: process.env.MAILUMI_WEBHOOK_SECRET, // whsec_…
});
if (error) return new Response('Invalid signature', { status: 401 });

// Save event.id with a unique constraint so each event is processed once.
await saveEventOnce(event.id, event);
return new Response('OK');
```

**Python**

```python
import base64, hashlib, hmac, json, os, time

def verify_mailumi_webhook(raw_body: bytes, headers) -> dict:
    msg_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")
    signatures = headers.get("webhook-signature", "").split(" ")
    if not msg_id or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Invalid signature")
    # The secret looks like whsec_…; the key is the base64 part after the prefix.
    key = base64.b64decode(os.environ["MAILUMI_WEBHOOK_SECRET"].removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    if not any(s.startswith("v1,") and hmac.compare_digest(s[3:], expected) for s in signatures):
        raise ValueError("Invalid signature")
    return json.loads(raw_body)
```

Respond with any 2xx status within eight seconds. If your endpoint fails, Mailumi retries automatically with increasing delays, up to ten attempts within 24 hours, using the same event ID. Every attempt is visible in the dashboard, where you can also retry by hand.

## Limits and errors

| Limit | Value |
| --- | --- |
| Recipients (to, cc and bcc) | 50 per email |
| Batch | 100 emails per request |
| Attachments | 20 per email, 10 MB in total |
| Text and HTML combined | 500 KB |
| Request body | 15 MB |
| Scheduling | Up to 30 days ahead |
| API requests | 120 per minute per key |
| Requests with a missing or invalid key | 30 per minute per IP address |
| Audiences | 100 per workspace, 100,000 contacts each |
| Contact import | 1,000 contacts per request |
| Free plan | 3,000 emails per month, 100 per day |

Errors are JSON with a message that explains what to fix and a machine-readable code, for example `{ "error": "Send to no more than 50 recipients per email.", "code": "invalid_request" }`.

| Status | Code | Meaning |
| --- | --- | --- |
| `400` | `invalid_request` | The request is invalid; the message explains what to fix |
| `401` | `unauthorized` | The API key is missing, invalid, expired or revoked |
| `402` | `plan_limit_reached` | Your plan’s sending limit is reached |
| `403` | `forbidden` | The key lacks the permission, the domain is not verified, or the key may not use this domain |
| `404` | `not_found` | The email, attachment, template, audience, contact or endpoint was not found |
| `409` | `conflict` | The idempotency key was already used with different content, the email can no longer be changed, or a campaign is being sent to the audience |
| `413` | `payload_too_large` | The request body is larger than 15 MB, or a contact import is larger than 1 MB |
| `429` | `rate_limited` | Too many requests; wait the number of seconds in `Retry-After` |
| `503` | `service_unavailable` | A temporary problem; retry with the same idempotency key |

Every API response includes `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds), so your code can slow down before it hits the limit. A `429` response also includes `Retry-After`.

Each recipient counts as one email toward your plan. Incoming email does not count. See [delivery monitoring](https://mailumi.com/delivery-monitoring) for what each delivery status means.

## Versioning and deprecation policy

The version is part of the path: `/api/v1`. Version 1 gets no breaking changes. New endpoints, new optional request fields, new response fields and new event types can be added at any time, so ignore fields you do not know.

A breaking change only ships in a new version, such as `/api/v2`. When that happens, version 1 keeps working for at least 12 months after the new version is released. Every workspace owner gets an email at least 6 months before an endpoint is retired, and responses from a deprecated endpoint carry a `Deprecation` header and a `Sunset` header with the retirement date.
