MessagePipedocs
Guides/Channels

Email

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

ProviderStrengthsWatch out for
SendGridMature deliverability, easy setup, good logs.API limits on the free tier.
MailgunEU-region routing, strong analytics.Region affects API host (api.eu.mailgun.net).
Amazon SESCheapest at volume. Bring-your-own deliverability.Account stays in sandbox until you request prod access.
SMTPSelf-hosted, on-prem, custom MTA.You own bounces / FBLs. No webhooks unless you wire one.
ResendDeveloper-friendly API, idempotent retries.Open and click tracking is set per domain in Resend, not per message.
PostmarkStrong transactional deliverability, separate message streams.Higher price at volume; use the right message_stream.
MailerSendLow cost, generous free tier.Custom headers need the Professional plan or above.
ZeptoMailVery low cost, transactional only.No delivery events yet; the data-centre region must match your Mail Agent.
MailtrapSending plus a sandbox inbox for testing.No delivery events yet; sandbox mode needs an inbox_id.
Elastic EmailFlat 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):

Ruledetails.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 eventCanonical
processed / acceptedsent
delivereddelivered
bounce / failed (permanent) / Bouncebounced (with bounce_type hard or soft)
Mailgun failed (temporary), SES DeliveryDelaydeferred (the provider is still retrying)
Mailgun failed with reason suppress-unsubscribe / suppress-complaint, SendGrid droppedfailed (the provider declined to send; not a bounce)
spamreport / complained / Complaintcomplaint

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.

LimitValue
Attachments per send20
Total attachment size10 MB, summed across attachments, measured decoded
Request body16 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.