Skip to content
MiniMailer
Docs
Toggle sidebar
Quick links
Documentation
Knowledge Base
API Reference
Changelog
No results found

Sending

This page covers transactional email — one message to a recipient, plus optional cc/bcc copies, triggered by your code: receipts, resets, notifications. To send one message to an audience, see Campaigns. Both send from a verified domain and feed the same event tracking and webhooks.

Transactional email

POST /outbound-emails queues a single email for delivery:

Attribute Required Notes
domain yes A verified domain you own
from yes Sender address on that domain
to yes One recipient address
cc no Carbon-copy recipients — one address or an array of up to 10. See Cc and bcc
bcc no Blind-carbon-copy recipients — one address or an array of up to 10. See Cc and bcc
reply_to no Where replies land — one address or an array of up to 10, on your sending domain's family. See Reply-to
subject yes Up to 998 characters
html yes HTML body
text no Plain-text alternative — include one; it helps spam filtering and accessibility
idempotency_key no Deduplicates retries (see below)
agent_id no Attributes the send to an AI agent for per-agent reputation (up to 128 chars, [A-Za-z0-9_-.])
attachments no Files to attach — see Attachments and inline images

The response is a 201 with the email's identifier and current status. Track it afterwards with GET /outbound-emails/{outboundEmail} or aggregate numbers from GET /outbound-emails/{outboundEmail}/stats.

Cc and bcc

Add cc and/or bcc to a transactional send — each accepts a single address or an array of up to 10 addresses:

  • cc recipients receive the message and are visible to everyone on it.
  • bcc recipients receive the message but never appear in its headers — other recipients cannot see them.

A few things happen for you automatically:

  • Suppression covers everyone. Every address — to, cc, and bcc — is checked against your domain's all-scope suppressions (bounces, complaints, manual entries — marketing unsubscribes never block transactional mail); a suppressed address rejects the whole send with a 422 naming the field, so nothing goes out partially.
  • Every recipient counts. Each cc/bcc address counts as one email toward your plan's volume and the free tier's quota, exactly like to.
  • Duplicates are dropped. An address repeated within a list, or already present in to (or in cc, for bcc), is silently de-duplicated.
  • Status follows to. The email's status tracks the primary recipient's journey. A bounce or complaint from a cc/bcc address is recorded as an event, fires its webhook, and suppresses that address — without flipping the send's status.

Reply-to

Set reply_to to steer replies somewhere other than your from address — a single address or an array of up to 10.

Reply-to addresses must stay in your sending domain's family: the sending domain itself, one of its subdomains, or its parent (sending from mail.acme.com? support@acme.com works). An address outside that family is rejected with a 422 — replies to mail you send should land with you, never with a third party.

Reply-to addresses are not recipients: they receive nothing at send time, so they are never suppression-checked and don't count toward your quota.

Attachments and inline images

Add an attachments array to a transactional send. Each entry needs a filename and exactly one source:

  • A local file — set content to the file's Base64-encoded bytes.
  • A remote file — set path to an HTTPS URL and we fetch it for you.

Optional per attachment: content_type (inferred from the filename when omitted) and content_id (see inline images below).

curl https://api.minimailer.app/outbound-emails \
  -X POST \
  -H "Authorization: Bearer $MINIMAILER_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -H "Accept: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "outbound-emails",
      "attributes": {
        "from": "billing@acme.com",
        "to": "customer@example.com",
        "subject": "Your invoice",
        "html": "<p>Invoice attached.</p>",
        "attachments": [
          { "filename": "invoice.pdf", "content": "JVBERi0xLjcK..." },
          { "filename": "terms.pdf", "path": "https://cdn.acme.com/terms.pdf" }
        ]
      },
      "relationships": { "domain": { "data": { "type": "domains", "id": "dom_01JZX8K7Q2M3N4P5R6S7T8V9W0" } } }
    }
  }'

Limits. The whole message, including attachments after encoding, must stay under 40 MB. Executable and script file types (.exe, .js, .bat, and similar) are rejected for the recipient's safety. A remote path must be a public HTTPS URL.

Inline images. To embed an image in the body instead of appending it as a download, give the attachment a content_id and reference it from your HTML with cid::

"attachments": [
  { "filename": "logo.png", "content": "iVBORw0KGgo...", "content_id": "logo" }
]
<img src="cid:logo" alt="Acme" />

Attachment metadata (filename, content type, size, and whether it is inline) is returned on the email resource; the file bytes themselves are never stored or returned.

Idempotency

Network timeouts make "did that send go through?" a real question. Pass an idempotency_key (any string up to 255 characters — an order ID, a job UUID) and retries of the same logical send become safe: the same key never produces a duplicate email.

Suppression checks happen for you

Every send is checked against your domain's suppression list before it goes out — every recipient on it, to, cc, and bcc alike. A recipient who hard-bounced or marked your mail as spam is rejected with a 422 instead of being delivered. This is not optional, and that's deliberate: repeatedly mailing a dead or unwilling address is the fastest way to destroy deliverability. Marketing unsubscribes are different — they block your campaigns, not your transactional mail, so an opted-out customer still gets their receipts and password resets. How suppression works →

Open and click tracking

Tracking is off by default and opt-in at two levels:

  1. Domain level — enable open and/or click tracking on the domain. This is the capability switch.
  2. Per send — track_opens / track_clicks on an individual email or campaign can narrow within what the domain allows (e.g. domain has tracking on, one send opts out). A per-send flag can't enable tracking the domain has off.

Opens are detected with a tracking pixel, clicks by wrapping links through a redirect. Both feed the email.opened / email.clicked webhooks and the stats endpoints. The default-off posture is a compliance choice: enable tracking when you have a lawful basis for it. EU data residency and privacy →

Rate limits and quotas

API rate limits scale with your subscription tier, applied per account across the whole API (including MCP):

Tier Requests per minute
Spark (free) 60
Starter 300
Growth 900
Scale 3,000

Hitting a limit returns 429 — back off and retry. Separately from rate limits, the free tier enforces its sending quota (5,000 emails/month, 1,000/day); a send over quota is rejected with an explanatory error rather than silently queued. Each recipient counts toward quota — a send with to plus three cc addresses consumes four emails, and a send that doesn't fully fit in the remaining quota is rejected whole rather than partially delivered.

A further cap applies per agent, on top of the account limit above. An agent still building a record sends at most 5 per second and 5,000 per day; an agent whose sender reputation has degraded into the restricted tier is held to 1 per second and 100 per day. The daily window opens on the agent's first send and runs for 24 hours from there — it is not a calendar day, so the allowance cannot be spent twice by sending either side of midnight. Established agents are bound only by your account's tier. These rejections also return 429, but with error code agent_throttled instead of rate_limited, plus a Retry-After header. Sends that carry no agent_id are never capped this way. How reputation works →

Delivery lifecycle

After acceptance, an email moves through delivery events — sent, delivered, bounced, complained, opened, clicked. You can poll the stats endpoints, but the intended integration is webhooks: every lifecycle event pushes to your endpoint as it happens.