API reference
POST /api/v1/send is the entire transactional Send API — one versioned, stable endpoint. Unlike the rest of MailInApp's routes (which only ever have our own frontend as a caller and can change freely), this one is a contract third-party code depends on, so it's versioned from day one.
Authentication
Authorization: Bearer mia_live_...
A missing or invalid key returns 401. Keys are managed under API & Integrations in the dashboard — see the quickstart. A revoked key stops working immediately.
Request
Content-Type: application/json. Two request shapes share the endpoint — which one applies is decided by whether projectId is present.
Freeform
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| to | string | yes | A single recipient address. |
| subject | string | yes | Truncated to 200 characters. |
| html | string | yes | Sent as-is — no rendering, no merge tags, no studio pipeline. |
| text | string | yes | Plain-text part. |
Template
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| to | string | yes | A single recipient address. |
| projectId | string | yes | Must be a project you own — the studio URL is /studio/<projectId>. |
| mergeData | object | no | Flat { key: value } object, up to 100 fields. Stands in for a data-source row — resolves into the project's {{field}} merge tags. Nested objects/arrays are dropped; null/undefined become empty strings; everything else is stringified. |
| subject | string | no | Defaults to the project's name if omitted. Merge tags in the subject resolve from mergeData. Truncated to 200 characters. |
A body that matches neither shape (e.g. missing to, or missing both html/text and projectId) returns 400.
Common fields
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| type | "transactional" | "marketing" | no | Defaults to "transactional". See below. |
| from | object | no | Per-call override of the sender identity — { "email": string, "name"?: string }. See below. |
| replyTo | string | no | Per-call Reply-To address. See below. |
Headers
| Header | Required | Notes |
| --- | --- | --- |
| Authorization | yes | Bearer <apiKey>. |
| Idempotency-Key | no | See idempotency. |
Response
{ "id": "abc123", "status": "sent" }
status is one of:
| Status | Meaning |
| --- | --- |
| sent | Handed off to your sending method (SMTP relay or native sending) successfully. |
| suppressed | The recipient is on your suppression list — see transactional vs. marketing. No email was sent; this is not an error. |
| failed | The send attempt failed (e.g. your SMTP relay isn't configured, or rejected the message). An error field carries a human-readable reason. |
Error codes
| Status | Meaning |
| --- | --- |
| 400 | Malformed JSON, a request that matches neither the freeform nor template shape, an invalid type, an invalid or disallowed from (see From address), an invalid replyTo, or (template mode) a projectId that doesn't exist or isn't yours. Also returned when the account has no working sending method configured. |
| 401 | Missing or invalid Authorization header, or the key has been revoked. |
| 404 | (Template mode) the project doesn't exist or isn't owned by your account — deliberately the same response as "doesn't exist" to avoid leaking which project IDs are valid for other accounts. |
| 429 | Rate limit exceeded — see rate limits. |
A 200 with status: "failed" (rather than a non-2xx status) is returned when the request itself was valid but the actual send attempt failed downstream — check status in the body, not just the HTTP status code, if you need to distinguish "we rejected your request" from "we tried and it didn't go out."
Transactional vs. marketing
The type field controls which suppression list is checked, mirroring how ESPs separate transactional and marketing streams:
type: "transactional"(default) — bypasses unsubscribe-suppression. A password reset or order receipt shouldn't be blocked just because the recipient unsubscribed from your newsletter. It never bypasses bounce-suppression — a dead address is dead regardless of intent.type: "marketing"— behaves exactly like a campaign send from the dashboard: blocked by both unsubscribe- and bounce-suppression.
Both cases return status: "suppressed" rather than an error when blocked.
From address
By default every send uses your account's configured sender identity — the Settings → Domain & mailbox "From address" card if you're on native (SES) sending, or your SMTP relay's configured from address otherwise. Pass from to override it for one call:
{
"to": "[email protected]",
"subject": "Your one-time code",
"html": "<p>Your code is 123456</p>",
"text": "Your code is 123456",
"from": { "email": "[email protected]", "name": "FitConsent Sales Team" }
}
from.email is required whenever from is present at all — there's no name-only override, so a value here always fully replaces both the address and display name for that call. from.name is optional; omit it to send with just the bare address.
If your account sends via a verified domain (native/SES sending), from.email must be an address at that exact domain (e.g. [email protected], not [email protected]) — this is a platform sending account shared across every MailInApp customer, so an unrestricted override would let one account send mail that appears to come from a different one's verified domain. On SMTP sending there's no such restriction: the send goes out over your own relay/credentials, so it's already trusted the same way your relay's own sender-verification rules already are.
An invalid or disallowed from returns 400 before any send is attempted.
Reply-To address
By default, replies go to your account's configured default Reply-To — set on the SMTP sending or Sending domain card in Settings depending on your sending method — or nowhere if you haven't set one. Pass replyTo to override it for one call — useful when the visible from is a no-reply address but you still want a human to see replies:
{
"to": "[email protected]",
"subject": "Your order shipped",
"html": "<p>Your order is on its way.</p>",
"text": "Your order is on its way.",
"from": { "email": "[email protected]", "name": "FitConsent" },
"replyTo": "[email protected]"
}
Unlike from.email, replyTo has no domain restriction on native (SES) sending — it never affects the sending identity or deliverability reputation, it's just a header the recipient's mail client honors when they hit reply. An invalid replyTo returns 400 before any send is attempted.
Idempotency
Pass an Idempotency-Key header on any call that might be retried — a checkout webhook redelivery, a queue consumer re-processing a message. A retried call with the same key against the same account returns the original call's {id, status} without sending a second email, even if the first call is still in flight.
Keys are scoped per account and have no expiry; reusing a key you've already used for a different payload is your own responsibility to avoid (it will still return the first call's result, not send the new payload). If you don't pass a key, every call sends.
Rate limits
Three independent caps apply:
- Per API key: a requests-per-minute cap. Exceeding it returns
429for that key specifically — other keys on the same account are unaffected. - Per account, Send API quota: your plan includes a number of Send API calls per calendar month (500/month on Free). Exceeding it returns
429until the quota resets on the 1st. - Per account, send volume: your plan's cumulative monthly send-volume cap, shared across every sending path (dashboard sends, scheduled sends, lifecycle emails, and this API). This is the same cap that protects the rest of your account's sending — the Send API doesn't get a separate budget.
Template-mode specifics
A template-mode send renders the linked project exactly like a normal recipient send: the fallback engine's three tiers, interactive blocks, and a personal signed live view link built from mergeData rather than a stored contact row. Everything downstream behaves the same as a studio-driven send:
- Poll votes, ratings, form submissions and opens are recorded and show up in the project's Responses view, attributed to this specific API call rather than a contact row.
- Your project webhook fires per interaction event, same as any other recipient.
- Recent calls (status, recipient, timestamp) are listed on the API & Integrations dashboard page as an audit trail.
The one thing that's different from a dashboard send: there's no stored data-source row behind the interaction, so joins on your own side should key off the response's recipient identifier rather than a row index.