API Reference

Send API

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.

Base URL  https://api.malumail.com
Auth  Authorization: Bearer <api-key>
Format  JSON in, JSON out
Transport  HTTPS only

Prerequisites

Two things must exist on your account before the API will send. Both are set up once in the customer portal.

1. A verified sending domain

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.

2. An API key

Portal → API Keys → create one. The key is shown once and cannot be recovered, so store it somewhere safe.

  • Format: mm_ followed by 48 lowercase hex characters (51 characters total). The server enforces ^mm_[a-f0-9]{48}$.
  • Each key is bound to a dedicated relay credential behind the scenes — you never handle SMTP directly when using the API.
  • Revoking a key in the portal disables it immediately (401 thereafter).

Authentication

Send your key as a Bearer token on every request:

Header
Authorization: Bearer mm_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f

Failure modes

Both return 401:

CauseResponse 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."}
HTTPS is mandatory Plain HTTP to a 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.

Send an email

POST https://api.malumail.com/v1/send

Request body

FieldTypeRequiredNotes
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.
At least one of 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.

Response

Success — 200 OK

JSON
{
  "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.
Partial success is still 200 Always inspect 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": [ ... ]}

Examples

bash
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>"
  }'
python
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
javascript
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);

Suppression management

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.

List suppressions

GET /v1/suppressions[?search=<substring>]
200 OK
{
  "suppressions": [
    {
      "email": "bob@example.com",
      "reason": "bounce",
      "created_at": "2026-07-30T01:00:00Z",
      "is_global": false
    }
  ]
}
  • Returns your entries plus any global platform suppressions (is_global: true).
  • Optional ?search= does a case-insensitive substring match on the address.
  • Capped at 1000 rows, newest first.

Add a suppression

POST /v1/suppressions
Request
{"email": "user@example.com", "reason": "unsubscribe"}
  • reason must be "manual" or "unsubscribe" (defaults to manual).
  • 201{"status":"suppressed","email":...,"reason":...}
  • 409 if the address is already suppressed.

Delete a suppression

DELETE /v1/suppressions?email=<address>
  • Removes your suppression for that address. 200{"status":"deleted","email":...}
  • 404 if there is no entry for that address on your account.
  • Global suppressions cannot be removed via the API.

Status codes

CodeMeaningRetry?
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.

Behavior to design around

  • Sender authorization is enforced server-side. A verified from domain is mandatory; there is no way to spoof another domain.
  • Suppression is enforced server-side. You cannot send to a suppressed address even if you try — it lands in rejected. Respect it in your own logic to avoid wasted calls.
  • Rate limits come from your account plan (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.
  • Deduplication: duplicate addresses in to collapse to one.
  • Authentication (SPF/DKIM/DMARC) is handled by the platform for verified domains — you don't sign or add auth headers.

Limitations

This is a lean send API. It does not currently support:

  • Attachments or inline images (bodies are text / html only).
  • cc, bcc, Reply-To, or arbitrary custom headers.
  • Templates, merge variables, or per-recipient personalization — each request sends one message.
  • Scheduled or delayed sends.
  • Open/click tracking, a List-Unsubscribe header, or delivery webhooks.
Sending in bulk Loop with one recipient per request and honor 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.

Integration checklist

  1. Verify your sending domain in the portal; confirm it shows Verified.
  2. Create an API key and store it as a secret (env var or secrets manager). Never commit it.
  3. Send from an address on the verified domain.
  4. On 200, inspect rejected — treat suppressed:* as permanent (don't retry those recipients) and invalid_address as a data-quality issue.
  5. Retry only 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.
  6. Feed your own unsubscribe and complaint signals into POST /v1/suppressions.
  7. Warm up gradually on a new domain or account before high volume.
Need help? Email support@malumail.com.