Emails
Send transactional and marketing emails and retrieve their delivery status.
SMTP Bridge
Legacy systems can submit mail over SMTP and still use the same verified domains, rate limits, suppression checks, queueing, logs, and webhooks as the HTTP API.
Host: smtp.smtpfa.st Port: 587 or 2525 Security: STARTTLS Username: smtpfast Password: YOUR_SMTPFAST_API_KEY
The API key must include the email:send scope. Port 2525 is available for environments that block standard SMTP submission ports.
Attachments and inline images (parts with a Content-ID) go through as the API's attachments: up to 10 files, 10 MB each. A message sent over SMTP can be up to 10 MB in total.
Mail sent over SMTP is marketing unless you label it. To mark a message as transactional, add the header X-SMTPfast-Category: transactional. SMTPfast reads it and removes it before sending.
To send one message without tracking, add X-SMTPfast-Track-Clicks: false and X-SMTPfast-Track-Opens: false. They are removed before sending too.
/v1/emailsSend a single email. The request accepts all standard fields plus Resend-compatible fields like tags and headers.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| from | string | Yes | Sender email address (must be on a verified domain). Optional with a template that sets one. |
| to | string | string[] | Yes | Recipient email address or array of addresses. Addresses on domains reserved for documentation and tests (example.com, example.net, example.org, .test, .example, .invalid, .localhost) are refused with a 400, in cc and bcc too. Broadcasts skip them as invalid. For a test send, use [email protected]. |
| subject | string | Yes | Email subject line. Optional with a template that sets one. |
| html | string | No | HTML body of the email |
| text | string | No | Plain text fallback body |
| cc | string | string[] | No | CC recipients |
| bcc | string | string[] | No | BCC recipients |
| reply_to | string | string[] | No | Reply-to address(es) |
| tags | array | No | Array of { name, value } pairs for categorization |
| headers | object | No | Custom email headers as key-value pairs |
| scheduled_at | string | No | ISO 8601 time to send at. Must be in the future and within 30 days. |
| attachments | array | No | Up to 10 files. Each needs filename and base64 content, and may set content_type, content_id and inline. 10MB per file, 25MB per message. |
| template | object | No | Send a published template instead of html and text: { id, variables }. id is the template id or alias. from, subject and reply_to then default to the template's. See the Templates page. |
| category | string | No | "transactional" or "marketing". Defaults to marketing. Label receipts, password resets and other transactional mail "transactional" so a marketing opt-out does not block them. Any other value is a 400. See Marketing and transactional email below. |
| track_clicks | boolean | No | false sends this email's links exactly as written; true routes them through the click tracker. Left out, your account's tracking settings apply. Not in Resend, which sets tracking per domain. See Tracking per email below. |
| track_opens | boolean | No | false sends this email without the open-tracking pixel; true adds it. Left out, your account's tracking settings apply. Not in Resend. |
Request Body
{
"from": "[email protected]",
"to": ["[email protected]"],
"cc": ["[email protected]"],
"reply_to": "[email protected]",
"subject": "Welcome!",
"html": "<h1>Hello!</h1><p>Welcome to our platform.</p>",
"text": "Hello! Welcome to our platform.",
"tags": [
{ "name": "category", "value": "onboarding" }
],
"headers": {
"X-Entity-Ref-ID": "abc-123"
}
}Response
{
"id": "email_abc123"
}Marketing and transactional email
Every email is either marketing or transactional. Email is marketing unless you label it transactional, so unlabelled mail is sent as it always was. The category decides two things:
- Who it reaches. When a recipient unsubscribes, they stop getting marketing email from you, but email you label transactional still reaches them. Hard bounces and complaints block both, and so do manual suppressions by default; a manual suppression you add with
"scope": "marketing"blocks marketing email only. See Suppressions. - The unsubscribe header. Marketing email carries the one-click
List-UnsubscribeandList-Unsubscribe-Postheaders that Gmail and Yahoo ask bulk senders for. Transactional email has neither.
How the category is chosen:
- An email is transactional only when you label it so:
"category": "transactional"in an API, batch or template send, or the headerX-SMTPfast-Category: transactionalover SMTP. If you pass both, the field wins. - An email whose
htmlortextcontains an unsubscribe placeholder ({{unsubscribe_url}}or{{{RESEND_UNSUBSCRIBE_URL}}}) is always marketing, even when labelled transactional, so the link in it can stop it. For a template send, the rendered body is checked. - Everything else is marketing: unlabelled email, broadcasts, broadcast test sends and signup form emails.
Label password resets, receipts and other transactional mail with "category": "transactional" (or the X-SMTPfast-Category header) so a marketing opt-out does not block them. Unlabelled mail is treated as marketing, as before. The template's own category label does not affect this.
{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Reset your password",
"html": "<p>Use this link to choose a new password.</p>",
"category": "transactional"
}What counts as transactional
Transactional email is mail the recipient needs because of something they did or an account they hold: receipts, password resets, security and account alerts. Promotions, newsletters and announcements are marketing. Labelling promotional email as transactional to reach people who unsubscribed is not allowed.
The category is stored on the email and returned as category by GET /v1/emails and GET /v1/emails/:id.
Tracking per email
Open and click tracking follow your account settings (Settings, Email Tracking), which apply to every email sent through the API and SMTP. Broadcasts have their own tracking settings. Click tracking sends each link through a redirect and records the full URL, which you can read back with GET /v1/emails/clicked-links. For a link that carries a secret, such as a password reset or sign-in token, turn tracking off so the token stays out of the redirect and the log. There are four ways, from the narrowest:
- One link. Add
data-smtpfast-track="off"to the<a>tag. That link goes out exactly as written, the others in the email are still tracked, and the attribute is removed before sending. - One email, over the API.
"track_clicks": falseand"track_opens": falseon POST /v1/emails or on a batch item.trueturns tracking on for that email even when your account has it off. - One email, over SMTP. The headers
X-SMTPfast-Track-Clicks: falseandX-SMTPfast-Track-Opens: false(ortrue). They are removed before sending. With the API you can also pass them inheaders; a field wins over its header. - All transactional email. Turn off "Track transactional email" in Settings, Email Tracking. Email labelled transactional then goes out untracked unless the email itself asks for tracking.
<p>Hi Ada, use this button to choose a new password:</p> <a data-smtpfast-track="off" href="https://yourapp.com/reset?token=8f3a...">Reset password</a> <p>Questions? <a href="https://yourapp.com/help">Visit the help center</a></p>
Click tracking rewrites links written as <a href="https://..."> in the HTML part, whatever the link text says. A URL written as plain text, and the plain-text part of an email, are never rewritten; a mail app may still turn such a URL into a link. Any value other than true or false in a field or header is refused with a 400; a null field is the same as leaving it out. The fields apply to POST /v1/emails and batch items. Replies and forwards sent from an inbox follow your account settings, and the per-link attribute works in them too.
/v1/emails/batchSend up to 100 emails in a single request. The body is a JSON array of email objects, each accepting the same fields as the single-send endpoint (from, to, subject, html/text or template, cc, bcc, reply_to, tags, headers, scheduled_at, category). Each email is queued and dispatched independently.
Request Body
[
{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Welcome!",
"html": "<h1>Hello User 1!</h1>",
"tags": [{ "name": "campaign", "value": "welcome" }]
},
{
"from": "[email protected]",
"to": ["[email protected]", "[email protected]"],
"subject": "Weekly digest",
"html": "<h1>Your week</h1>",
"scheduled_at": "2026-04-10T14:00:00Z"
}
]Response
{
"batch_id": "batch_abc123",
"count": 2,
"emails": [
{ "id": "email_abc123", "status": "queued" },
{ "id": "email_def456", "status": "scheduled" }
],
"data": [
{ "id": "email_abc123" },
{ "id": "email_def456" }
]
}/v1/emailsList sent emails, newest first. Bodies are not included; fetch one by id for those. Requires the email:read scope.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | integer | No | Page size, 1-100. Defaults to 50. |
| page | integer | No | 1-based page number. Ignored when a cursor is given. |
| after | string | No | Return emails older than this id. Not included in the result. Use either after or before, not both. |
| before | string | No | Return emails newer than this id. |
| status | string | No | Filter by current status, e.g. delivered, bounced, scheduled. |
| to | string | No | Exact recipient address. |
Response
{
"object": "list",
"data": [
{
"id": "email_abc123",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome!",
"status": "delivered",
"source": "api",
"category": "marketing",
"created_at": "2026-04-05T12:00:00Z",
"sent_at": "2026-04-05T12:00:01Z",
"delivered_at": "2026-04-05T12:00:03Z",
"opened_at": null,
"clicked_at": null,
"bounced_at": null,
"error_message": null
}
],
"page": 1,
"limit": 50,
"total": 1284,
"has_more": true
}To find what has not gone out yet, filter on status=scheduled. For per-event detail rather than per-email, use the event log, which needs the logs:read scope.
/v1/emails/:idReschedule an email that has not been sent yet. Only the send time can change; to change the content, cancel it and send again. Requires the email:send scope.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The email ID |
| scheduled_at | string | Yes | New send time in ISO 8601. Must be in the future and within 30 days. |
Response
{
"object": "email",
"id": "email_abc123",
"scheduled_at": "2026-04-09T09:00:00Z"
}/v1/emails/:id/cancelStop an email that has not been sent yet. Works while it is scheduled or queued. Requires the email:send scope.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The email ID |
Response
{
"object": "email",
"id": "email_abc123"
}When cancelling is too late
Both of these return 409 once the send has started, and say which states they would have accepted. A message already handed to the provider cannot be recalled. Emails that belong to a broadcast are refused as well: cancel or reschedule the broadcast instead, so its recipient bookkeeping stays correct.
/v1/emails/:idRetrieve the details and delivery events for a specific email.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The email ID returned from the send endpoint |
Response
{
"id": "email_abc123",
"from": "[email protected]",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Welcome!",
"html": "<h1>Hello!</h1><a href='{{unsubscribe_url}}'>Unsubscribe</a>",
"text": null,
"created_at": "2026-04-05T12:00:00Z",
"last_event": "unsubscribed",
"body_cleared_at": null,
"source": "api",
"category": "marketing",
"status": "delivered",
"reply_to": null,
"tags": [{ "name": "campaign", "value": "welcome" }],
"message_id": "<[email protected]>",
"ses_message_id": "0100019a1b2c3d4e-5f6a7b8c-0000-0000-0000-000000000000-000000",
"sent_at": "2026-04-05T12:00:01Z",
"delivered_at": "2026-04-05T12:00:03Z",
"opened_at": null,
"clicked_at": null,
"bounced_at": null,
"unsubscribed_at": "2026-04-06T08:14:22Z",
"error_message": null,
"events": [
{ "type": "queued", "metadata": null, "timestamp": "2026-04-05T12:00:00Z" },
{ "type": "sent", "metadata": null, "timestamp": "2026-04-05T12:00:01Z" },
{ "type": "delivered", "metadata": null, "timestamp": "2026-04-05T12:00:03Z" },
{ "type": "unsubscribed", "metadata": null, "timestamp": "2026-04-06T08:14:22Z" }
]
}About unsubscribed
When the recipient hits the one-click List-Unsubscribe link (or the link from the unsubscribe placeholder) tied to a delivered message, SMTPfast records an unsubscribed event on the email and sets unsubscribed_at. The address is then suppressed for marketing email only; email you label transactional still reaches it. The top-level status field intentionally stays at its terminal delivery value (delivered, bounced and so on), because an unsubscribe is something the recipient did after delivery, not a delivery outcome. Check unsubscribed_at (or filter the events array) to detect opt-outs without losing the original delivery state.
htmlandtextare the stored body. For a send through the API, with your own HTML or a template, the unsubscribe placeholder is filled in when the email goes out, so it shows here unreplaced (template variables are already filled in). Broadcast and form emails are stored with the real link. See placeholders.tagsare the tags given at send time, as name/value pairs; an empty array when none were given.message_idis the Message-ID header on your sending domain, the value a recipient's server and bounce reports quote.body_cleared_atis set once the retention window removes the body. Each event carries itsmetadata, such as the bounce type or the clicked link.- Counts and rates across many emails are in GET /v1/emails/metrics.
Error Codes
| Code | Meaning |
|---|---|
| 400 | Invalid request body (missing required fields, malformed email) |
| 401 | Missing or invalid API key |
| 403 | Domain not verified or sending not allowed for this key |
| 404 | Email ID not found |
| 429 | Rate limit exceeded |