Transactional email over a small JSON REST API. One endpoint to send, three to manage your suppression list. Authentication (SPF, DKIM, DMARC) is handled for you on every verified domain.
https://api.malumail.comAuthorization: Bearer <api-key>Two things must exist on your account before the API will send. Both are set up once in the customer portal.
You may only send from an address whose domain you have verified —
that means publishing the ownership TXT record,
SPF include:_spf.malumail.com, and the DKIM record we generate for you.
Sending from an unverified domain returns 403.
DKIM signing and SPF alignment are then automatic. You never sign anything yourself.
Portal → API Keys → create one. The key is shown once and cannot be recovered, so store it somewhere safe.
mm_ followed by 48 lowercase hex characters (51 characters
total). The server enforces ^mm_[a-f0-9]{48}$.401 thereafter).Send your key as a Bearer token on every request:
Authorization: Bearer mm_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f
Both return 401:
| Cause | Response body |
|---|---|
Header missing, or not matching Bearer mm_<48 hex> |
{"error":"Missing or malformed Authorization: Bearer header."} |
Key not found or inactive, or the account is not active |
{"error":"Invalid API key."} |
malumail.com host is 301-redirected, and a
non-HTTPS API call returns 400 HTTPS required. Never send a key over
plain HTTP — treat any key you have sent that way as compromised and rotate it immediately.
| Field | Type | Required | Notes |
|---|---|---|---|
from |
string | Required | A valid email address. Its domain must be a verified sending
domain on your account, or you get 403. |
from_name |
string | Optional | Display name. Rendered as "Name" <from>. |
to |
string | string[] | Required | One address, or an array of 1–50. All accepted
recipients go into the same message — they see each
other in the To: header. |
subject |
string | Required | Non-empty. Encoded as UTF-8. |
text |
string | One of | Plain-text body. |
html |
string | One of | HTML body. |
text / html is required
Supply both and we build a multipart/alternative message. Supply one and
we send a single-part message of that type.
The server generates Date, Message-ID
(<token@your-domain>), and MIME-Version automatically.
{
"status": "sent",
"accepted": ["alice@example.com"],
"rejected": [
{"email": "bob@example.com", "reason": "suppressed:bounce"},
{"email": "not-an-email", "reason": "invalid_address"}
]
}
accepted — recipients the message was relayed for
(deduplicated, lowercased).rejected — recipients dropped before sending,
each with a reason:
invalid_address — not a valid email.suppressed:<reason> — on your suppression list or
the global list (e.g. suppressed:bounce,
suppressed:manual). These are never delivered.rejected — a 200 does not mean every
recipient was accepted. If every recipient is rejected you get a
400 instead, with the same rejected array:
{"error": "No deliverable recipients.", "rejected": [ ... ]}
curl -sS -X POST https://api.malumail.com/v1/send \ -H "Authorization: Bearer $MALUMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "noreply@yourdomain.com", "from_name": "Your App", "to": ["alice@example.com", "carol@example.com"], "subject": "Welcome aboard", "text": "Thanks for signing up.", "html": "<p>Thanks for signing up.</p>" }'
import os, requests resp = requests.post( "https://api.malumail.com/v1/send", headers={"Authorization": f"Bearer {os.environ['MALUMAIL_API_KEY']}"}, json={ "from": "noreply@yourdomain.com", "from_name": "Your App", "to": "alice@example.com", "subject": "Welcome aboard", "html": "<p>Thanks for signing up.</p>", }, timeout=30, ) resp.raise_for_status() # raises on 4xx/5xx data = resp.json() if data["rejected"]: ... # some recipients were suppressed/invalid
const res = await fetch("https://api.malumail.com/v1/send", { method: "POST", headers: { Authorization: `Bearer ${process.env.MALUMAIL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ from: "noreply@yourdomain.com", to: "alice@example.com", subject: "Welcome aboard", html: "<p>Thanks for signing up.</p>", }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error);
Suppressed addresses are silently refused by /v1/send — they come back in
rejected. Bounces and spam complaints are added to your list
automatically by the platform; these endpoints let you read and manage it.
{
"suppressions": [
{
"email": "bob@example.com",
"reason": "bounce",
"created_at": "2026-07-30T01:00:00Z",
"is_global": false
}
]
}
is_global: true).?search= does a case-insensitive substring match on the address.{"email": "user@example.com", "reason": "unsubscribe"}
reason must be "manual" or "unsubscribe"
(defaults to manual).{"status":"suppressed","email":...,"reason":...}{"status":"deleted","email":...}| Code | Meaning | Retry? |
|---|---|---|
| 200 | Sent (check rejected for partial drops) |
— |
| 201 | Suppression created | — |
| 400 | Validation error, malformed JSON, or all recipients undeliverable | No — fix the request |
| 401 | Missing, malformed, or invalid key; or inactive account | No |
| 403 | from domain is not a verified sending domain on this account |
No — verify the domain |
| 404 | Unknown endpoint, or no suppression for the given address | No |
| 409 | Address already suppressed | No |
| 429 | Rate limit exceeded (N/hour, M/day) |
Yes — back off |
| 500 | API key has no relay credential (recreate the key) | No |
| 502 | The mail relay refused the message (SMTP error included) | Yes — with backoff |
Error bodies are always {"error": "<message>"} — except the
all-rejected 400, which also includes rejected.
from domain is mandatory; there is no way to spoof another domain.rejected. Respect it
in your own logic to avoid wasted calls.max_per_hour / max_per_day). Each accepted
recipient counts as one unit — a send to 10 recipients uses 10 units.
Over the limit returns 429 and the message is not sent.to collapse to one.This is a lean send API. It does not currently support:
text / html only).cc, bcc, Reply-To, or arbitrary custom headers.List-Unsubscribe header, or delivery webhooks.429 backoff. The
≤50-address to array is for a genuinely shared message (a small team
alert, say) — not for a mailing list, since recipients see each other.
from an address on the verified domain.200, inspect rejected — treat
suppressed:* as permanent (don't retry those recipients) and
invalid_address as a data-quality issue.429, 502, and transient
5xx, with exponential backoff. There is no idempotency key, so never
blindly retry a request that already returned 200 — you would
double-send.POST /v1/suppressions.