SendGrid, Mailgun, SES, Resend, Postmark, MailerSend, ZeptoMail, Mailtrap, Elastic Email, Bird, SMTP, connection, deliverability, gotchas.
Email is the highest-volume channel and the one with the most subtle traps: SPF, DKIM, DMARC, bounce loops, complaint thresholds, IP warm-up. This page covers what you need at the API layer; the provider docs cover the deeper deliverability work.
Picking a provider
| Provider | Strengths | Watch out for |
|---|---|---|
| SendGrid | Mature deliverability, easy setup, good logs. | API limits on the free tier. |
| Mailgun | EU-region routing, strong analytics. | Region affects API host (api.eu.mailgun.net). |
| Amazon SES | Cheapest at volume. Bring-your-own deliverability. | Account stays in sandbox until you request prod access. |
| SMTP | Self-hosted, on-prem, custom MTA. | You own bounces / FBLs. No webhooks unless you wire one. |
| Resend | Developer-friendly API, idempotent retries. | Open and click tracking is set per domain in Resend, not per message. |
| Postmark | Strong transactional deliverability, separate message streams. | Higher price at volume; use the right message_stream. |
| MailerSend | Low cost, generous free tier. | Custom headers need the Professional plan or above. |
| ZeptoMail | Very low cost, transactional only. | No delivery events yet; the data-centre region must match your Mail Agent. |
| Mailtrap | Sending plus a sandbox inbox for testing. | No delivery events yet; sandbox mode needs an inbox_id. |
| Elastic Email | Flat pricing, optional IP pools. | Delivery events need a PRO-plan webhook created with request signing on. |
| Bird (SparkPost) | Enterprise volume and EU region. | Webhooks use a shared secret, not a signature. |
You can connect more than one and let routing do failover, see Providers.
Sending an email
Email sends go to POST /v1/email/send. The body is flat: there is no
content wrapper and no channel field.
curl https://api.dev.messagepipe.dev/v1/email/send \
-H "Authorization: Bearer mp_live_..." \
-H "Content-Type: application/json" \
-d '{
"to": "maya@example.com",
"from": { "email": "orders@your-domain.com", "name": "Acme Orders" },
"subject": "Order #1234 confirmed",
"html": "<p>Pretty body, served when the client accepts HTML.</p>"
}'from must be an object with an email (and optional name) — a bare
string like "from": "orders@your-domain.com" is rejected with a 400. For an
inline send it is optional only if the project has a default sender
configured, or an active email provider with its own From address (see
provider overrides); templates can also
carry their own sender. Otherwise an inline send
with no from is rejected with a 400 rather than failing later at the
provider. The from address must be an authenticated sender on your provider
(see Domain authentication).
from must also be on one of the project's sender domains: the domain
of the project's default sender, plus any listed in the project's
sender_domains (set with PATCH /projects/{id}). Any other domain is refused
with 403 sender_not_allowed; MessagePipe never swaps in the default sender
instead. A project with neither a default sender nor sender_domains accepts
any from, and the provider decides. Matching is exact and case-insensitive,
so mail.example.com needs its own entry next to example.com. Write an
internationalised domain in its punycode form (xn--…) in both places. MessagePipe
checks the domain against the list; whether that domain is verified for SPF
and DKIM is up to you and your provider.
The check runs when the send is accepted. A scheduled or queued send keeps the
from it was accepted with: removing a domain from sender_domains afterwards
doesn't cancel or rewrite sends already queued.
The check applies to the from on the request. A template's own
from_email is template content, set by your workspace, and isn't
checked, so don't build it from data unless every caller may choose the
sender.
Or render a stored template by reference. The variables field is data:
{
"to": "maya@example.com",
"template": "order-confirmation",
"data": { "order_id": "1234" },
"template_version": 3
}template_version is optional (it pins a specific version; omit it for the
latest).
On a template send there is no subject field on the request — the
subject comes from the template, and a top-level subject is silently
ignored. To vary it per send, make it a variable in the template and pass it
through data (see
Setting the subject on a template send).
Sender, Reply-To and headers on a template send
from, reply_to and headers belong to the message, not the template. They
work the same on a template send as on an inline one, and the template
doesn't need to reference them. When the request carries one, it wins
over whatever the template set; when it doesn't, the template's value
is used. If neither names a sender, the provider's own override is used when
it has one (see provider overrides), then the
project's default sender, along with its default Reply-To when the send has none. A request from
replaces the template's sender as a whole, display name included, and a
send with its own sender doesn't pick up the project's default Reply-To.
Field names are matched exactly, on every channel: From, Subject or
REPLY_TO is refused with 400 (details.constraint key:case) rather than
read as the field. If you upgraded from a version that accepted any case, fix
callers that send capitalised names before they start getting 400s.
{
"to": "maya@example.com",
"template": "trial-reminder",
"from": { "email": "partners@your-domain.com", "name": "Acme Partnerships" },
"reply_to": "partners@your-domain.com",
"headers": {
"List-Unsubscribe": "<https://your-domain.com/unsubscribe?t=...>, <mailto:partners@your-domain.com?subject=unsubscribe>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
},
"metadata": { "template": "B6", "enrollment_id": "enr_123" },
"data": { "first_name": "Maya" }
}A successful send returns 202 Accepted:
{
"id": "msg_01HZ...",
"status": "queued",
"channel": "email",
"template_id": null,
"queued_at": "2026-06-23T12:00:00Z",
"_links": { "self": "/v1/sends/msg_01HZ..." }
}The identifier is id (a msg_ value). Poll GET /v1/sends/{id} for the
current status. Optional fields on any send: scheduled_for (RFC3339) and
idempotency_key. You can also pass idempotency via the Idempotency-Key
header; it is optional, not required.
Custom headers
headers is an object of header names to string values, delivered unchanged
on every email provider (SendGrid, Mailgun, SES, Resend, Postmark, MailerSend, ZeptoMail, Mailtrap, Elastic Email, Bird, SMTP), including after
failover. Use it for List-Unsubscribe and List-Unsubscribe-Post
(RFC 2369 / RFC 8058), which Gmail and Yahoo read to show their own
Unsubscribe button, and for your own X- headers.
A send with headers is only handed to providers that can carry them. If no provider in the chain can, the send fails rather than going out without them.
These are refused with a 400 validation_failed (the error's
details.field names the header, e.g. headers.From):
| Rule | details.constraint |
|---|---|
| A CR, LF or other control character, or any non-ASCII byte, in a value. Encode non-ASCII per RFC 2047. Empty values are refused too. | header:value |
| A name that isn't printable ASCII, or has a space or colon, or is over 64 characters. | header:name |
A header MessagePipe or the provider sets: From, To, Cc, Bcc, Subject, Date, Message-ID, Reply-To, Sender, Return-Path, DKIM-Signature, Received, MIME-Version, Content-Type, Content-Transfer-Encoding, Content-Disposition, Authentication-Results, Received-SPF, Resent-*, ARC-*, and provider control headers (X-SES-*, X-Mailgun-*, X-SG-*, X-SMTPAPI, X-PM-*, X-MSYS-*, X-ElasticEmail-*, X-MT-*). Use from, to, subject and reply_to instead. | header:reserved |
| The same name twice (names are case-insensitive). | header:duplicate |
Name: value longer than one 998-character line. | header:max_length |
| More than 20 headers. | max_items |
For one-click unsubscribe to work, the provider's DKIM signature has to
cover both List-Unsubscribe headers (RFC 8058). MessagePipe passes the
headers to the provider and the provider signs the message, so check each
provider once: send a test and look for both names in the h= list of the
received message's DKIM-Signature. If
SendGrid's subscription tracking is on, SendGrid adds its own
unsubscribe link and header, so turn it off when you send your own.
Metadata
metadata is an object of string keys to string values (at most 20 keys,
keys up to 64 bytes, values up to 512 bytes), accepted on every channel. It is
stored with the send and returned unchanged in every webhook
event for it, under data.metadata. Use it for your own ids.
It is never sent to the recipient.
Domain authentication
Add the DKIM CNAMEs the provider generates to your DNS, plus an SPF TXT that includes the provider, and a DMARC policy. MessagePipe doesn't proxy the From header, so your sending domain must authenticate or inboxes will reject the message.
Delivery events
A send moves through the status lifecycle queued -> sent -> delivered | bounced | failed. SendGrid, Mailgun, and SES expose webhook event streams
that MessagePipe parses into these canonical statuses:
| Provider event | Canonical |
|---|---|
processed / accepted | sent |
delivered | delivered |
bounce / failed (permanent) / Bounce | bounced (with bounce_type hard or soft) |
Mailgun failed (temporary), SES DeliveryDelay | deferred (the provider is still retrying) |
Mailgun failed with reason suppress-unsubscribe / suppress-complaint, SendGrid dropped | failed (the provider declined to send; not a bounce) |
spamreport / complained / Complaint | complaint |
There are no opened or clicked events: MessagePipe does not emit open or
click tracking. Read the current status with GET /v1/sends/{id}. There is
no /v1/sends/{id}/events endpoint.
When a send ends in failed, GET /v1/sends/{id} explains why: the top-level
failure_code / failure_reason fields carry the cause (e.g.
provider_terminal — "Email address is not verified"), and each event's
metadata holds the same detail per attempt. The 202 Accepted from the send
call only means queued, not delivered, so this is the place to confirm a
message actually went out.
SMTP has no callback, so you only learn delivery success because the SMTP
session closed with a 2xx. Bounces arrive at the mailto: you set as
return-path on your MTA.
Resubscribing an address
Mailgun, SendGrid, Postmark, SparkPost and Elastic Email each keep a suppression list of their own (bounces, spam reports, unsubscribes) and refuse to deliver to an address on it, whatever MessagePipe's own list says. Lifting a MessagePipe suppression alone therefore does not put the address back in an inbox.
In the dashboard, Audience → Suppressions → Resubscribe an address does both steps: it takes the address off the lists of every connected email provider and lifts its MessagePipe suppressions, then shows what each provider said. Only workspace owners and admins can do it, and the reason you give is kept on the record. If one provider is unreachable the rest still go through; submit the same address again once it is fixed, and the parts that already worked are not repeated.
Things to know:
- Consent is not changed. Someone who unsubscribed still has to opt in again before they receive marketing email.
- Postmark does not let the API reactivate an address that filed a spam complaint; that one is reported as failed with Postmark's reason.
- SMTP, Resend, MailerSend, Amazon SES and the SMS, push and chat providers are reported as having no list to clear. For SES, remove the address from the account-level suppression list in the AWS console.
Attachments
Add an attachments array to any email send — inline or template:
{
"attachments": [
{
"filename": "invoice-1234.pdf",
"content_type": "application/pdf",
"content": "<base64>"
}
]
}filename and content are required; content is standard base64.
content_type is optional and defaults to application/octet-stream.
| Limit | Value |
|---|---|
| Attachments per send | 20 |
| Total attachment size | 10 MB, summed across attachments, measured decoded |
| Request body | 16 MB (base64 inflates attachments by ~⅓, and counts against this) |
Exceeding either limit is a 400 at send time, not a later delivery failure.
Larger files? Upload to object storage and link from the body.
filename must be a leaf name: path separators and control characters are
rejected, so a name cannot be read as a path by the recipient's mail client.
Non-ASCII filenames are fine and are encoded per RFC 2231.
Attachments work identically across all four email providers, though the
mechanics differ underneath — SendGrid takes base64 JSON, Mailgun a multipart
form, SES and SMTP raw MIME. A send that carries attachments becomes a
multipart/mixed message.