Email API vs SMTP: a 30-second decision table and the same receipt sent both ways
Published Updated 13 min readBy the Rasket team
Written for startup founders and developers

Can you change the code that sends? Use an email API. If you can’t, use SMTP. The API answers each send with a message id you can store, and an idempotency key makes a retry safe. SMTP is for the hosted tool, device or old software that only takes a host, port, username and password.
An idempotency key is a name you give one send, such as receipt-a2291. On Rasket, for 24 hours, sending the same key again gets the first answer back instead of a second email, and the same key on a different email is refused (the idempotency docs have the details).
A common case: your web app sends receipts, and your auth service sends magic links. The receipt code is yours, so it calls the API and stores the id. The auth service only has an SMTP form, so it gets SMTP settings. Both can share one domain and one record of what was delivered or bounced.
For whoever decides
If SMTP works today and you need no message ids or safe retries, leave it. Otherwise, here is how the two compare on price, effort and lock-in:
- Price: on Rasket an SMTP message and an API message come out of the same allowance. The Free plan sends 3,000 emails a month, and 50,000 a month costs $20 (pricing). When you compare providers, check whether SMTP is billed the same, sold as an add-on, or left off some plans; the comparison of seven providers has their prices.
- Effort: for a hosted tool, SMTP is four settings in a form. From your own code the two are close: the SMTP sample below is 33 lines of Python and the API one 42, 19 of them the retry loop.
- Lock-in: SMTP moves to another provider with those four settings, plus whatever parses your bounce emails. The API moves almost as easily if every send goes through one function of your own (step 1 of the move below).
Which should you use? The 30-second table
SMTP is the step-by-step conversation mail servers use to pass a message along. An email API takes the same message as one HTTPS request. Find your situation below; if two rows match, the API wins unless the sending code is not yours to change. Sentences that start “Rasket:” are about our service; the rest hold for any provider.
| Your situation | Use | Why |
|---|---|---|
| You write the code that sends: a web app, a worker, a cron job | API | The response carries the message id, and a retry with the same key cannot send twice. |
| A hosted tool only has SMTP fields: an auth service's custom SMTP, a helpdesk | SMTP | It is the only option the tool has. |
| A WooCommerce or WordPress site | Either | A WordPress SMTP plugin takes SMTP settings. Rasket: our WordPress plugin uses the API instead. |
| An automation tool (n8n, Make, Zapier) offers both | API | An HTTP step gets the id back for the next step to use. An SMTP step gives it little or nothing. |
| An AI agent sends the mail | API | Through an MCP server or an HTTP tool whose key stays in server-side config, never in the prompt. |
| A device or old software: a scanner, a NAS, an ERP module | SMTP | It will not learn HTTP. If it cannot do TLS and a login, put a local relay in front of it. |
| The network blocks 465 and 587 | API | HTTPS on 443 carries the web, so it is the port a restrictive network is least likely to block. |
| A scheduled send, a stored template, a large batch | API | Core SMTP has no field for a schedule or a template, and sends one message per transaction. Rasket: the API takes 10 requests a second and up to 100 emails per batch call. |
| Large attachments | API, or a link | Relays cap the message size. Rasket: the relay takes 4 MB and the API 40 MB, both counting the base64-encoded message. |
| An SMTP integration works and nothing is missing | Keep SMTP | Moving buys you nothing until you need an id, safe retries or events. |
For the agent row, the MCP article covers keeping the key out of the model’s reach and putting a person in front of each send.
On the same provider, inbox placement does not depend on the choice: the receiving server gets the same signed message either way. If mail lands in spam, open a delivered copy’s Authentication-Results header (RFC 8601) and look for dkim=pass and dmarc=pass before changing anything else. DKIM, SPF and DMARC explained says what each result means, and the spam guide covers the rest.
The same receipt, sent both ways
One receipt, sent over the API and then over SMTP, in Python with only the standard library. Every output is from a run on 17 October 2026 against a local build of Rasket’s API and relay. Founders can skip to moving from SMTP to the API. For Node, the rate limits article has the same send with fetch and backoff, and the SMTP relay article has Nodemailer settings.
import http.client, json, os, time, urllib.error, urllib.request
API = os.environ.get("RASKET_API_URL", "https://api.rasket.com")
KEY = os.environ["RASKET_API_KEY"]
RETRY = {"rate_limit_exceeded", "concurrent_idempotent_requests"}
body = {
"from": "Acme <billing@acme.example>",
"to": ["jordan@example.com"],
"subject": "Your receipt for order A-2291",
"text": "Thanks for your order.",
}
request = urllib.request.Request(
f"{API}/emails",
data=json.dumps(body).encode(),
method="POST",
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
"User-Agent": "acme-billing/1.0",
"Idempotency-Key": "receipt-a2291", # the same on every attempt
},
)
for attempt in range(1, 4):
try:
with urllib.request.urlopen(request, timeout=30) as response:
print(response.status, json.loads(response.read()))
break
except urllib.error.HTTPError as error: # an answer, and it is a refusal
try:
problem = json.loads(error.read()) # {"statusCode", "name", "message"}
except ValueError:
problem = {} # a proxy's HTML error page
# A quota 429 or a 4xx gets the same answer again, so only these are retried.
if attempt == 3 or not (error.code >= 500 or problem.get("name") in RETRY):
raise SystemExit(f"{error.code} {problem or error.reason}")
wait = error.headers.get("retry-after", "")
time.sleep(min(int(wait), 60) if wait.isdigit() else 2**attempt)
except (OSError, http.client.HTTPException): # no answer: it may have been sent
if attempt == 3:
raise
time.sleep(2**attempt) # the same key makes this retry safe200 {'id': '01a14bbe-6c34-7e1f-ab72-58fa5b6e5ccc'}
200 {'id': '01a14bbe-6c34-7e1f-ab72-58fa5b6e5ccc'}The second run is a replay: same key, same id, no second email, and the response carries idempotent-replayed: true. A refusal comes back as a JSON body with a name to branch on. With the recipient changed to jordan@example, the script stopped at once:
422 {'statusCode': 422, 'name': 'invalid_parameter', 'message': 'to[0]: to contains an invalid email address: jordan@example'}The loop retries a 5xx, rate_limit_exceeded (waiting for its retry-after), concurrent_idempotent_requests (an attempt with the same key still running), and a request that got no answer. Everything else would get the same answer again: a 4xx, and a quota 429 such as daily_quota_exceeded or monthly_quota_exceeded. The rate limits article adds jitter and a time budget.
import os, smtplib, ssl
from email import policy
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
HOST = os.environ.get("RASKET_SMTP_HOST", "smtp.rasket.com")
PORT = int(os.environ.get("RASKET_SMTP_PORT", "587"))
msg = EmailMessage()
msg["From"] = "Acme <billing@acme.example>"
msg["To"] = "jordan@example.com"
msg["Subject"] = "Your receipt for order A-2291"
msg["Date"] = formatdate()
msg["Message-ID"] = make_msgid(domain="acme.example")
msg["X-Rasket-Idempotency-Key"] = "receipt-a2291"
msg.set_content("Thanks for your order.")
def expect(step, reply, ok):
code, text = reply
if code not in ok: # a 4xx may pass later; a 5xx will fail again
raise SystemExit(f"{step} refused: {code} {text.decode()}")
return text.decode()
with smtplib.SMTP(HOST, PORT, timeout=30) as smtp: # reads the 220 greeting
smtp.starttls(context=ssl.create_default_context()) # sends EHLO, then STARTTLS
smtp.login("rasket", os.environ["RASKET_API_KEY"]) # EHLO again, then AUTH
# send_message() would hide the final reply, so take the steps and check each one.
expect("MAIL FROM", smtp.mail("billing@acme.example"), {250})
expect("RCPT TO", smtp.rcpt(msg["To"]), {250, 251}) # syntax and recipient cap
# data() raises SMTPDataError if DATA itself is refused, and returns the reply
# after the dot: suppression, domain and content refusals come back there.
reply = smtp.data(msg.as_bytes(policy=policy.SMTP)) # CRLF line ends (RFC 5321)
print(250, expect("DATA", reply, {250}))250 2.0.0 Queued as 01a14bbe-6f09-74ee-b9cf-9d061911e1ca
250 2.0.0 Queued as 01a14bbe-6f09-74ee-b9cf-9d061911e1caBoth samples use receipt-a2291. Run them one after the other and the SMTP one stops at DATA refused: 554 5.6.0 This Idempotency-Key was already used with a different payload, a reply the SMTP docs list. The relay builds its own request body from your message, so the API sees the key reused for a different email. One key cannot fail over between the API and SMTP, so send each message one way. Over SMTP alone the key does its job: if the connection drops after the dot, send again, and the second run above shows the same id coming back.
Count the exchanges in the SMTP session. After the server’s greeting the client takes eight turns with AUTH PLAIN (more with AUTH LOGIN): EHLO, STARTTLS, EHLO again inside TLS, AUTH, MAIL FROM, RCPT TO, DATA (answered 354), then the message and its final dot (answered 250). The API takes one request. A server that offers PIPELINING (RFC 2920) lets a client send several commands without waiting, and one connection can carry many messages, so at a few emails a minute speed will not decide this.
What comes back matters more. An SMTP reply is usually one line (RFC 5321 §4.2.1 allows several): a code, a finer status such as 5.7.8, and some text. Where a refusal shows up depends on the cause. With the recipient changed to jordan@example, the sample stopped at RCPT TO refused: 553 5.1.3 Bad recipient address syntax. Suppression, domain and content refusals come only after the final dot. Rasket’s relay puts the message id in the last reply’s text, and your code only sees it if the library hands the reply over. The API answers with a JSON body.
What the protocol decides, and what the platform decides
Many comparisons put suppression lists and webhooks in the API column. They belong to whoever runs the server. A bare relay you run yourself has none. A provider’s relay can have all of them.
| Question | HTTP email API | SMTP | Decided by |
|---|---|---|---|
| Message format | JSON fields | A MIME message you or a library builds | Protocol |
| What the reply holds | A JSON body: the id, named errors | One line: a code, a status, some text | Protocol |
| A retry after a lost reply | A repeated idempotency key returns the first answer | Nothing in the protocol; a resend can deliver twice | Protocol, unless the platform adds a header |
| The credential | A key per integration | A username and password, which a provider can make a key | Platform |
| Suppression, domain signing, delivery events | The platform's | The platform's on a provider's relay; yours to build on a bare one | Platform |
The retry row is the one that bites. If the connection drops after your final dot and before the server answers, you cannot tell whether it took the message. RFC 5321 §6.1 names this duplicate-message problem and only asks the server to answer quickly, so a mailer that resends can deliver twice.
Which ports get blocked?
Port 25 is the one that gets blocked. Mail servers use it to deliver to each other (RFC 5321 §4.5.4.2), and it is where a hijacked machine sends spam, so many hosting and access providers block it outbound (RFC 5068 §4 describes the practice). Google Cloud, for example, blocks port 25 to outside addresses by default and places no restriction on 465 or 587.
Applications use SMTP submission instead, on 587 (RFC 6409) or on 465 with TLS from the first byte (RFC 8314), and RFC 5068 §4.1 says access providers must not block 587. Some locked-down networks still block both, so test from where the code will run:
import os, smtplib, ssl, urllib.error, urllib.request
HOST = os.environ.get("RASKET_SMTP_HOST", "smtp.rasket.com")
API = os.environ.get("RASKET_API_URL", "https://api.rasket.com")
tls = ssl.create_default_context()
def implicit_tls():
with smtplib.SMTP_SSL(HOST, 465, context=tls, timeout=10) as smtp:
return smtp.ehlo()[0]
def starttls():
with smtplib.SMTP(HOST, 587, timeout=10) as smtp:
return smtp.starttls(context=tls)[0]
def https():
try:
with urllib.request.urlopen(f"{API}/emails", timeout=10) as response:
return response.status
except urllib.error.HTTPError as error:
return error.code # 401 without a key still proves the port is open
for name, check in [("465 implicit TLS", implicit_tls), ("587 STARTTLS", starttls), ("443 HTTPS", https)]:
try:
print(name, "reachable:", check())
except OSError as error: # a timeout usually means the network blocks the port
print(name, "failed:", type(error).__name__)465 implicit TLS reachable: 250
587 STARTTLS reachable: 220
443 HTTPS reachable: 401The SMTP guide explains how STARTTLS on 587 differs from TLS on 465. The SMTP relay article covers which one to pick, the alternates for blocked networks, and every reply code.
If you choose SMTP on Rasket
The relay at smtp.rasket.com turns each message into one POST /emails. The password is an API key, so give each tool its own sending-access key, restricted to one domain. A relayed message shares the API’s rate limit and allowance, and gets the same message id, events and suppression checks an API send gets. The relay ignores your MAIL FROM for routing, because the return path is set by your domain, but uses it with the Message-ID and the recipients to derive an idempotency key when you send no X-Rasket-Idempotency-Key header. That only catches a resend that keeps the same Message-ID, so set the header if your app rebuilds the message on retry. Settings, limits and reply codes are in the SMTP relay article.
Moving an application from SMTP to the API
The fields keep their names: From: becomes from, To: becomes to, the text and HTML parts become text and html. What changes is the call that carries them.
- Find the one helper that opens the SMTP transport, and swap its body for one HTTP call with the same signature. Every caller moves untouched, and a later change of provider touches this one function.
- Store the returned id on the record that caused the send, such as the order row, and derive the idempotency key from that record, as in
receipt-a2291. - Move your bounce handling into a webhook handler. If your old setup hands you bounces as emails to parse, as a bare relay does, they now arrive as events with the id attached; the webhooks article has a receiver.
- Run both paths for a week on one low-stakes message type. Look up each API send with
GET /emails/{id}(a full-access key), and compare it with what your old provider’s logs show for the SMTP copy.
When neither fits, or Rasket isn't the answer
- Mail that never leaves your network. Alerts between internal systems can go through the relay you already run. There is no outside reputation to protect.
- Signed or encrypted mail. Rasket’s relay refuses S/MIME and PGP because it rebuilds the message, and the API takes fields, not a finished message. You need a mail server that passes your bytes on unchanged.
- A device with no TLS or no login. Rasket’s relay requires both. Run Postfix on your own network to accept mail from the device and log in upstream with a key. If the device’s From address is not on your verified domain, rewrite it with
sender_canonical_maps.
For that local relay, two settings matter. mynetworks is the list of client addresses Postfix relays for without a login, so list the device and nothing else; a wider range makes an open relay, which RFC 5068 §3.2 says a submission server must never be. And smtp_tls_security_level = secure checks the upstream certificate, so the key cannot be read by someone in the middle.
# /etc/postfix/main.cf on a machine on the device's network
inet_interfaces = all
# the device, and nothing else (Postfix reads # only at the start of a line)
mynetworks = 127.0.0.0/8, 192.168.1.40/32
relayhost = [smtp.rasket.com]:587
# secure = require TLS and check the certificate; encrypt would not check it
smtp_tls_security_level = secure
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
smtp_sasl_auth_enable = yes
smtp_sasl_tls_security_options = noanonymous
# hash: on Debian and Ubuntu; use a map type that postconf -m lists
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
# /etc/postfix/sasl_passwd, then: postmap /etc/postfix/sasl_passwd
# and chmod 600 both sasl_passwd and the sasl_passwd.db it creates
[smtp.rasket.com]:587 rasket:<a sending-only API key>Frequently asked questions
What DNS records does my domain need before either one works?
The same set serves both. When you add the domain, Rasket lists a DKIM record and the sending record for its return-path subdomain: one CNAME, or on some domains an MX and an SPF TXT. Receiving is off unless you turn it on, and Rasket refuses to take over a name whose MX already delivers elsewhere unless you confirm. DMARC is a record you publish yourself; the DMARC guide has a starting value.
What should my firewall allow, and do I add the relay to my SPF record?
Allow outbound TCP to smtp.rasket.com on 465 or 587 for the relay, or to api.rasket.com on 443 for the API. Nothing goes into your root SPF record: the relay never delivers to recipients itself. Our mail servers do, under the return-path subdomain whose record you published when you verified the domain.
Does strict SPF alignment (aspf=s) still pass DMARC?
aspf=s in your DMARC record asks for strict SPF alignment: the return-path domain must equal your From domain exactly. SPF will not align under it, because the return path is a subdomain of your From domain, not the same name. DMARC needs only one aligned result, and DKIM is signed with your own domain's key, so DMARC still passes on DKIM. That holds for the API and the relay alike.
Is SMTP less secure than an API?
Not inherently: both run inside TLS. The weak spot is STARTTLS used only when the server offers it. Someone on the network path can strip the offer, and a lax client then logs in in plain text. Make the client require TLS, as Python's starttls() does by raising an error, or use port 465, where TLS starts before anything else.
Can I send a newsletter by putting the whole list in Bcc?
Not through Rasket's relay, which refuses a message with nobody in To: and caps recipients per message. It is a poor idea anywhere: every recipient gets one shared copy, with no unsubscribe link of their own. Send a campaign as a broadcast instead.
Do I need to warm up when I switch from SMTP to the API?
Not when you only switch between the API and SMTP on the same provider: the receiving server sees the same domain, the same signing and the same sending servers. Moving to a new provider is different: your domain's reputation comes with you, but the sending servers have no history with your mail. Raise the volume over a few weeks; the warm-up guide explains how.
Sources
- RFC 5321: Simple Mail Transfer Protocol — IETF, read 2026-10-17
- RFC 6409: Message Submission for Mail — IETF, read 2026-10-17
- RFC 8314: Cleartext Considered Obsolete: Use of TLS for Email Submission and Access — IETF, read 2026-10-17
- RFC 5068: Email Submission Operations: Access and Accountability Requirements — IETF, read 2026-10-17
- RFC 2920: SMTP Service Extension for Command Pipelining — IETF, read 2026-10-17
- Sending email from an instance — Google Cloud, read 2026-10-17
- RFC 3207: SMTP Service Extension for Secure SMTP over Transport Layer Security — IETF, read 2026-10-17
- Email sender guidelines — Google, read 2026-10-17
- Idempotency — Rasket, read 2026-10-17
- SMTP relay: limits and reply codes — Rasket, read 2026-10-17
- Pricing — Rasket, read 2026-10-17
- WordPress plugin: limits (40 MB per email) — Rasket, read 2026-10-17
- RFC 8601: Message Header Field for Indicating Message Authentication Status — IETF, read 2026-10-17
- Postfix SASL Howto: the SMTP client — Postfix, read 2026-10-17
Related
- SMTP relay: what it is and how to set one up — What an SMTP relay is, when you need one, and how to set one up: ports 465 and 587, the login, limits, reply codes, and settings for WordPress and Laravel.
- SMTP — Send from anything that speaks SMTP: settings, setup guides, limits and replies.
- Quickstart — Key, domain, first send — in that order.
- What is SMTP? How email actually gets delivered — What SMTP is and how email gets delivered: the four hops, ports 25, 465 and 587, reply codes like 550, and MTA-STS vs DANE for mail servers.
- Why are my emails going to spam? How to fix it — Why emails land in spam or junk: read the headers to see which problem you have, then the fixes Gmail, Outlook and Yahoo document, in order.
- Idempotent email sends: retry without sending twice — How to make email sends idempotent and stop duplicate emails: choosing an Idempotency-Key, a tested retry loop, keys for agents, and what each repeat returns.