Sending email from your application: SMTP, APIs, bounces and webhooks
Last updated: 26:10:2026
What makes an email transactional
A transactional email is one your application sends because of something a specific person just did or needs to know: confirm your email address, reset your password, here's your receipt, your export is ready, someone signed in from a new device. It goes to one recipient, it's expected, and it's often urgent - a password reset that arrives in twenty minutes is a support ticket.
That makes the requirements different from a newsletter. Volume is modest, but every single message matters: it must arrive quickly, land in the inbox rather than spam, never be sent twice by accident, and when it can't be delivered your application should find out and do something sensible.
SMTP or an HTTP API?
There are two ways to hand a message to a mail provider. Both end up as the same SMTP delivery to the recipient's server; the difference is how your code talks to the provider.
| SMTP submission | HTTP API | |
|---|---|---|
| Integration | Every language and framework has a mailer; often configuration only | A client library or plain HTTPS requests |
| Credentials | Username and password for the mailbox | An API key, scoped to the API |
| Errors | SMTP reply codes, sometimes only after DATA | Structured errors (validation, rate limit, not found) before anything is sent |
| Timeouts and retries | You manage the connection and its retries | Ordinary HTTP timeouts; easy to wrap in a job queue |
| Beyond sending | Nothing - SMTP only sends | Usually also reading replies, delivery status and webhooks |
Use SMTP when you're configuring software that already speaks it (a CMS, a framework's built-in mailer, a device). For code you write yourself, an API is usually less work and fails more clearly. AnyMail offers both: authenticated SMTP submission on anymail.tech:587 with a per-inbox app password (set up like a mail app - see IMAP and SMTP settings), and the Developer API with official SDKs for TypeScript, Python and Go. Either way, every recipient of a message - To, Cc or Bcc, up to 50 - is delivered and reported on separately, so one bad address never holds up the rest. Transactional mail usually has just one recipient anyway.
Authenticate your sending domain first
Mail that claims to come from your domain is only trusted if your domain says so. Before the first real message, publish:
- an SPF record that includes your provider,
- a DKIM key, so every message is signed with
d=your domain, - a DMARC policy, which Gmail and Yahoo require from bulk senders and which ties the visible From address to the checks above.
Then send from an address on that domain that a human could plausibly reply to - support@ or accounts@ generally earn more trust than noreply@, and replies to a receipt are often real questions. With AnyMail the sender must be an inbox you've created on a mail server whose DNS checks (ownership, MX, SPF, DKIM and DMARC) have all passed; until then sending is refused rather than silently delivered unauthenticated. Outgoing mail is DKIM-signed with your domain's key and uses your own address as the envelope sender, so SPF and DKIM both align with From.
Sending with the SDKs
With the TypeScript SDK (npm install @anymail/sdk), sending is one call. from is the inbox address; the SDK looks up its id for you. API keys are created on the API tab of your account and are part of the Pro plan.
import { Anymail } from '@anymail/sdk';
await Anymail.connect({ apiKey: process.env.ANYMAIL_API_KEY! });
export async function sendPasswordReset(email: string, resetUrl: string) {
const sent = await Anymail.sendMessage({
from: 'no-reply@example.com',
to: email,
subject: 'Reset your password',
text: `Someone asked to reset the password for this account.\n\nOpen this link within 60 minutes:\n${resetUrl}\n\nIf it wasn't you, you can ignore this email.`
});
if (sent.deliveryWarning) console.warn('Reset email not delivered:', sent.deliveryWarning);
return sent.id;
}The returned message is the copy stored in the inbox's Sent folder. deliveryWarning is set when the recipient couldn't be reached at all on the first attempt - no mail server for the domain, an address the receiving server rejected outright, or a recipient who unsubscribed from this sender - so you can tell the user straight away ("we couldn't deliver to that address - check it for typos").
The Python and Go SDKs work the same way:
Anymail.connect() # reads ANYMAIL_API_KEY from the environment
app = Flask(__name__)
def send_receipt(order):
sent = Anymail.send_message(
from_="orders@example.com",
to=order.email,
subject=f"Your receipt for order {order.number}",
html=render_receipt_html(order),
)
if sent.delivery_warning:
log.warning("receipt for order %s not delivered: %s", order.number, sent.delivery_warning)
return sent.id
@app.post("/webhooks/anymail")
def anymail_webhook():
try:
event = Anymail.webhooks.parse(request.get_data(), request.headers.get("X-AnyMail-Signature"), WEBHOOK_SECRET)
except WebhookSignatureError:
return "", 401
if not remember_event(event.id): # False when this event id was handled before
return "", 204
if event.type == "message.delivery_failed":
mark_undeliverable(event.data["to"], event.data["error"])
return "", 204func sendWelcome(ctx context.Context, to, confirmURL string) error {
mail, err := anymailsdk.Connect(ctx, anymailsdk.Options{APIKey: os.Getenv("ANYMAIL_API_KEY")})
if err != nil {
return err
}
sent, err := mail.SendMessage(ctx, anymailsdk.OutgoingMessage{
From: "no-reply@example.com",
To: to,
Subject: "Confirm your email address",
Text: "Welcome! Confirm your address by opening this link:\n" + confirmURL,
})
if err != nil {
return err
}
if sent.DeliveryWarning != "" {
log.Printf("confirmation to %s not delivered: %s", to, sent.DeliveryWarning)
}
return nil
}Each SDK also reads mail (replies, threads, attachments) - see the developer docs for the full reference.
Retries without duplicates
Network calls fail, and the dangerous failure is the ambiguous one: your request timed out, and you don't know whether the provider sent the email. Retry blindly and the user gets two password resets; don't retry and they may get none. A few habits make this manageable:
- Don't send inside the web request. Write the email to a queue or an "outbox" table in the same database transaction as the change that caused it (the order, the signup), and let a worker send it. A slow mail provider then never slows down your sign-up page, and a crash between "order saved" and "email sent" can't lose the email.
- Retry only what is safe to retry. Validation errors won't fix themselves; rate limits will, after the delay the server asks for. The AnyMail SDKs retry reads automatically but deliberately never retry a send behind your back - that decision belongs to your code.
- Check before resending. AnyMail's API has no idempotency key, but it does keep a Sent copy of every message it accepted. After an ambiguous failure, look for that copy before sending again.
import { ApiError, NetworkError, RateLimitError, ValidationError } from '@anymail/sdk';
export async function processOutbox() {
for (const job of await db.outbox.claimDue(20)) {
if (job.attemptedAt) {
// A previous attempt ended without an answer. Look before sending again.
const since = new Date(job.attemptedAt.getTime() - 60_000); // allow for clock skew
const sentCopies = await Anymail.getMessages({ inbox: job.from, folder: 'sent', subject: job.subject, since });
const already = sentCopies.find((message) => message.to.includes(job.to));
if (already) {
await db.outbox.markSent(job.id, already.id);
continue;
}
}
await db.outbox.markAttempted(job.id, new Date());
try {
const sent = await Anymail.sendMessage({ from: job.from, to: job.to, subject: job.subject, text: job.text });
await db.outbox.markSent(job.id, sent.id);
} catch (error) {
if (error instanceof RateLimitError) await db.outbox.retryLater(job.id, error.retryAfterMs ?? 60_000);
else if (error instanceof ValidationError) await db.outbox.markFailed(job.id, error.message);
else if (error instanceof NetworkError || (error instanceof ApiError && (error.status ?? 0) >= 500)) {
await db.outbox.retryLater(job.id, 30_000); // outcome unknown: the next run checks Sent first
} else throw error;
}
}
}Rate limits come in two kinds here. Ordinary API rate limiting answers 429 with a Retry-After header, which the SDK honours. Separately, every AnyMail mail server has an hourly cap on outgoing mail - lower for a new mail server during its first two weeks, to build the sending reputation gradually - and reaching it is also a 429, with the code SEND_VOLUME_THROTTLED. Either way the right response is the same: keep the job and try later.
Bounces and delivery status
Accepted by your provider is not the same as delivered. After the hand-off, the receiving server can still accept the message, defer it ("try again later", an SMTP 4xx) or refuse it for good (a 5xx: no such user, mailbox full for too long, policy rejection). When a message finally can't be delivered, the standard way to say so is a delivery status notification - a bounce - formatted per RFC 3464.
For AnyMail mail sent from your application, that plays out like this:
| Outcome | What you see |
|---|---|
| Delivered on the first attempt | Sent copy with delivery status delivered |
| Permanent failure straight away | deliveryWarning on the send result, a message.delivery_failed webhook and a bounce report in the sending inbox |
| Temporary failure | Status queued; AnyMail retries with increasing delays for a little over two days, with one "delayed" notice in the sending inbox after several hours |
| Retries exhausted | Status failed, a message.delivery_failed webhook and a bounce report |
delivereddeliveryWarning on the send result, a message.delivery_failed webhook and a bounce report in the sending inboxqueued; AnyMail retries with increasing delays for a little over two days, with one "delayed" notice in the sending inbox after several hoursfailed, a message.delivery_failed webhook and a bounce reportWhat your application should do with a failure: stop sending to an address that hard-bounced (and ask the user to correct it next time they sign in), don't retry a permanent failure yourself, and keep an eye on the rate - a sudden rise in bounces usually means a bug that is mangling addresses, or a sign-up form being abused with made-up ones, both of which hurt your sending reputation.
Webhooks for delivery events
Polling for status works, but a webhook is simpler: you register an HTTPS URL and the provider POSTs an event to it when something happens. AnyMail sends three event types - message.received (mail arrived in one of your inboxes), message.sent (a message was sent; its delivered field reports how the first delivery attempt went) and message.delivery_failed (a delivery failed for good). Webhooks are part of the Pro plan and are managed on the Webhooks tab of the developer area.
Every delivery is signed: the X-AnyMail-Signature header is sha256= followed by an HMAC-SHA256 of the raw request body, keyed with the webhook's secret. Verify it against the body exactly as it arrived, before parsing JSON - re-serialising parsed JSON changes the bytes and breaks the signature.
import express from 'express';
import { WebhookSignatureError } from '@anymail/sdk';
const app = express();
app.post('/webhooks/anymail', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = Anymail.webhooks.parse(req.body, req.header('X-AnyMail-Signature'), process.env.ANYMAIL_WEBHOOK_SECRET!);
} catch (error) {
if (error instanceof WebhookSignatureError) return res.sendStatus(401);
throw error;
}
// Deliveries can repeat: record the event id first and skip one you've already handled.
if (!(await db.webhookEvents.insertIfNew(event.id))) return res.sendStatus(204);
if (event.type === 'message.delivery_failed') {
await db.emails.markUndeliverable(event.data.to, event.data.error);
}
res.sendStatus(204);
});- Expect repeats. A delivery that times out or gets a non-2xx answer is retried - up to six attempts over roughly two hours - so the same event can arrive twice. De-duplicate on the event's
id. - Answer fast. Return a
2xxwithin a few seconds and do slow work afterwards in a job. AnyMail gives up on an attempt after 8 seconds, and disables a webhook once 10 events in a row have failed every retry. - Reject bad signatures with a
401and never act on an unverified body.
Keep transactional and marketing mail apart
Receivers judge mail partly by the reputation of who sends it. If your newsletter collects spam complaints and comes from the same address and domain as your password resets, the resets pay for it. Google's sender guidelines advise using different From addresses for different kinds of mail and not mixing promotional content into transactional messages; many senders go further and use a separate subdomain, such as news.example.com, for marketing.
- Don't put promotions in a receipt or a security notice. Keep transactional messages short and about the event.
- Give each stream its own address, and ideally its own subdomain with its own SPF, DKIM and DMARC records.
- Marketing mail needs consent and an easy way out; transactional mail must still reach people who opted out of marketing.
The last point has a concrete consequence on AnyMail. Every message AnyMail delivers outside the platform carries a one-click List-Unsubscribe header, and an unsubscribe is recorded per mail server and recipient. If newsletters and password resets come from the same mail server, someone who unsubscribes from the newsletter also stops getting password resets. Running marketing from its own mail server (for example on a subdomain) keeps the two independent. AnyMail isn't a bulk newsletter platform, either: every recipient counts against an hourly sending cap, which suits transactional volumes rather than large campaigns.
A short checklist
- Sending domain has SPF, DKIM and DMARC, all passing and aligned with From.
- Emails go through a queue or outbox, never directly from the web request.
- Ambiguous failures are reconciled before resending; validation errors are not retried.
- Hard bounces stop further sends to that address; delivery events arrive by webhook.
- Webhook signatures are verified on the raw body, and events are de-duplicated by id.
- Marketing runs on its own address and, ideally, its own subdomain.
- The whole flow is covered by an automated test - see testing email in CI.
Let AnyMail handle these records for you
AnyMail generates the MX, SPF, DKIM and DMARC records for your domain, checks every one of them automatically and tells you exactly which record to fix - then hosts your inboxes, operated from the EU.