Quickstart
- Create a free account and confirm your email address.
- Add your domain in Dashboard → Domains and add the DNS records shown. On Cloudflare, Mailumi can add them for you.
- Create an API key in Dashboard → API keys and store it in your server’s environment variables as
MAILUMI_API_KEY. - Send your first email with one HTTPS request from your backend, then follow it in Email activity.
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."
}'// 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);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
$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'];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. Other languages need no SDK: any language that can make an HTTPS request can use Mailumi.
Node.js SDK and CLI
The official 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.
npm install mailumiimport { 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.
| 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.
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 --helpSend 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 |
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>',
});# 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>"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"# 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
}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();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 ownX-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;
mailumiis 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 or the Node.js 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, or this page as Markdown, docs.md. Ready-made prompts for sending, webhooks, receiving and switching providers are on the Build with 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:
# 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.
{
"mcpServers": {
"mailumi": {
"url": "https://mailumi.com/mcp",
"headers": {
"Authorization": "Bearer ${env:MAILUMI_API_KEY}"
}
}
}
}claude mcp add --transport http mailumi https://mailumi.com/mcp \
--header "Authorization: Bearer $MAILUMI_API_KEY"{
"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}"
}
}
}
}[mcp_servers.mailumi]
url = "https://mailumi.com/mcp"
bearer_token_env_var = "MAILUMI_API_KEY"- Cursor: Add to Cursor 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.
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.
{
"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.
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.
{
"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-UnsubscribeandList-Unsubscribe-Postheaders 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.unsubscribedwebhook 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:
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 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.
{
"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 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.
// 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');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 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.