TL;DR: Sending transactional email from a Node.js or Python app takes about 20 lines of code: a POST request to a REST endpoint with your API key in the header, a from address, a recipient, and an HTML body. The hard part isn't the send call. It's handling webhooks for bounces, retrying transient failures correctly, and setting up SPF, DKIM, and DMARC so your mail reaches the inbox. This guide gives you working code for all three patterns in both languages, plus the deliverability notes most quickstarts skip.
You're building a SaaS app. A user requests a password reset. You need to send one email, reliably, in the next two seconds. So you search "email API Node.js," land on four different vendor docs, and 45 minutes later you're still staring at a 401 because every provider names its auth header something different.
That friction is the reason this guide exists. There's a reason we're covering these two languages specifically: JavaScript is used by 66% of developers worldwide, and Python just hit 57.9%, its largest single-year jump in the survey's history (Stack Overflow Developer Survey, 2025). These are the two languages where developers are most likely to type "how do I send email from my app." Below you'll find complete, runnable integration patterns for Node.js and Python covering the three things every real app needs: sending a message, receiving event webhooks, and recovering from bounces and errors. We use SendPost for the working examples, but the patterns transfer to any modern email API. The code is the easy 20%. We'll spend most of our time on the 80% that decides whether your mail lands.
Why use an email API instead of SMTP?
A REST email API beats raw SMTP for app-to-person mail because it's stateless, returns structured JSON you can act on, and supports event webhooks that SMTP simply can't. SMTP gives you a numeric status code and a persistent connection to babysit. An API gives you a delivery record, a message ID, and a callback when something bounces. For transactional email sent from serverless or containerized backends, that difference is the whole ballgame.
Think of SMTP as a phone call and a REST API as a text message. SMTP holds a live connection open, negotiates state, and expects both ends to stay on the line. That model fights serverless functions, which spin up and tear down in milliseconds and have nowhere to keep a connection alive. A stateless HTTP POST fits that world cleanly.
The performance gap is real too. Industry benchmarking puts modern email API response times between 200ms and 1,200ms across providers, with sub-2-second delivery achievable for transactional sends (MailerToGo Email API Performance Benchmarking, 2025). And the deliverability floor you're aiming for is high: email delivery rates reached 98% industry-wide in 2024, with B2C hitting 99.2% (DMA Email Benchmarking Report 2025, via MailerToGo). That floor assumes proper authentication and error handling. Most hand-rolled SMTP setups skip exactly that layer, which is how "it works on my machine" turns into "it's in the spam folder." If you're weighing options, our breakdown of the best transactional email API for SaaS compares the tradeoffs in detail.
What does every email API request need?
Every transactional email API request needs four things: an authenticated sender address you control, a valid recipient, a message payload of headers plus body, and an API key passed in the provider's auth header. Miss any one and you get a 4xx error. Get all four right and your call succeeds, but success here only means "accepted for delivery," not "landed in the inbox."
Here's the part that trips people up. Your API key authenticates you to the provider. It does nothing for the receiving mail server at Gmail or Yahoo. Those servers check SPF, DKIM, and DMARC, which are DNS records, independent of whatever credential you used to make the call. So a request can return a clean 200, and the message can still hit spam, because the API key and the DNS setup are two separate trust systems. We'll come back to this. For now, just hold onto the idea that a green API response is necessary but not sufficient. Our guide to email authentication for transactional emails walks through the records you need. Field names are case-sensitive and easy to get wrong, so check them against the SendPost send-email reference rather than copying from memory.
Node.js email API examples: send, webhook, and error handling
In Node.js, sending email is a single fetch POST with your key in the header. Webhook handling is an Express route that reads an array of event objects and acts on each type. Error handling means treating 4xx and 5xx failures differently: never retry a 4xx, always retry a 5xx with backoff. Below are all three patterns, runnable as-is on Node 18+ where fetch is built in.
Pattern 1: Send a transactional email (Node.js)
// SendPost API: send a single transactional email
// npm install node-fetch (or use built-in fetch in Node 18+)
const SENDPOST_API_KEY = process.env.SENDPOST_API_KEY;
async function sendEmail({ to, subject, html }) {
const response = await fetch('https://api.sendpost.io/api/v1/subaccount/email/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-SubAccount-ApiKey': SENDPOST_API_KEY,
},
body: JSON.stringify({
from: { email: 'hello@yourdomain.com', name: 'Your App' },
to: [{ email: to }],
subject,
htmlBody: html,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(`SendPost API error ${response.status}: ${JSON.stringify(err)}`);
}
return response.json();
}
// Usage
sendEmail({
to: 'user@example.com',
subject: 'Reset your password',
html: '<p>Click <a href="https://yourapp.com/reset?token=abc123">here</a> to reset.</p>',
}).then(console.log).catch(console.error);Two rules this snippet follows that yours should too. Pull the key from an environment variable, never a string literal in source. And in production, wrap the call in try/catch so a network blip doesn't crash the request that triggered the send. The if (!response.ok) check matters because fetch doesn't throw on HTTP errors, so a 401 looks like a successful promise unless you inspect the status yourself.
Pattern 2: Webhook handler for bounces and opens (Node.js + Express)
// Express webhook endpoint: receive SendPost event callbacks
const express = require('express');
const app = express();
app.use(express.json());
// SendPost identifies events by numeric code, not by name.
const EVENT = {
PROCESSED: 0,
DROPPED: 1,
DELIVERED: 2,
SOFT_BOUNCED: 3,
HARD_BOUNCED: 4,
OPENED: 5,
CLICKED: 6,
UNSUBSCRIBED: 7,
SPAM: 8,
};
app.post('/webhooks/email', (req, res) => {
// Each event looks like:
// { event: { type, messageID }, emailMessage: { to: [{ email }] } }
// Accept a single event or a batch so this keeps working either way.
const payload = req.body;
const events = Array.isArray(payload) ? payload : [payload];
for (const { event, emailMessage } of events) {
const recipient = emailMessage?.to?.[0]?.email;
switch (event.type) {
case EVENT.HARD_BOUNCED:
// Permanent failure. Suppress the address and never retry it.
suppressEmail(recipient);
break;
case EVENT.SOFT_BOUNCED:
// Temporary failure. Safe to retry with backoff.
console.log('Soft bounce, will retry:', recipient);
break;
case EVENT.SPAM:
// Unsubscribe immediately: a legal requirement in most jurisdictions.
unsubscribeEmail(recipient);
break;
default:
console.log('Event received:', event.type, recipient);
}
}
res.status(200).send('OK');
});
app.listen(3000);The single most important line here is res.status(200).send('OK'). Webhook senders interpret any non-200 response as "you didn't get it" and retry, sometimes aggressively. Return a 200 first to acknowledge receipt, then do your real work. If the processing is heavy, push the events onto a queue and respond immediately. A webhook endpoint that does database writes inline before responding is an endpoint that gets hammered with duplicate deliveries the moment your database slows down.
One thing both handlers above leave out: verify the signature. As written, the endpoint trusts any POST that reaches it — which means anyone who guesses the URL can suppress your customers' addresses or unsubscribe them. Before this goes anywhere near production, authenticate the request using SendPost's webhook signature verification, and reject anything that fails the check.
Pattern 3: Error handling with retry logic (Node.js)
Three failure modes need three different responses: rate limits (back off and respect the Retry-After header), transient 5xx errors (retry with exponential backoff), and 4xx errors (don't retry, fix the request). The distinction between 4xx and 5xx is the one to internalize. A 4xx means you sent a bad request, so retrying the identical payload just fails again. A 5xx means the provider had a temporary problem, so a retry a few seconds later usually works.
async function sendWithRetry(payload, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch('https://api.sendpost.io/api/v1/subaccount/email/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-SubAccount-ApiKey': process.env.SENDPOST_API_KEY,
},
body: JSON.stringify(payload),
});
if (response.ok) return response.json();
// 4xx: a bad request. Retrying won't help. Fail fast.
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error ${response.status}: ${await response.text()}`);
}
// 5xx: transient. Back off and try again, with jitter.
const delay = Math.min(1000 * 2 ** attempt, 8000) + Math.random() * 250;
await new Promise((r) => setTimeout(r, delay));
}
throw new Error('Send failed after retries');
}If you're building marketing sends rather than one-off transactional mail, our JavaScript API guide covers the list-management side of the same workflow.
Python email API examples: send, webhook, and error handling
In Python, the send is a requests.post with raise_for_status() as your minimal error gate. The webhook is a Flask route that returns 200 after processing the event array. Robust retries come from the tenacity library, which handles exponential backoff and jitter so you don't reimplement it. The patterns mirror the Node.js versions exactly, which is the point: the logic is universal, only the syntax changes.
Pattern 1: Send a transactional email (Python)
# SendPost API: send a single transactional email
# pip install requests
import os
import requests
SENDPOST_API_KEY = os.environ["SENDPOST_API_KEY"]
def send_email(to: str, subject: str, html: str) -> dict:
payload = {
"from": {"email": "hello@yourdomain.com", "name": "Your App"},
"to": [{"email": to}],
"subject": subject,
"htmlBody": html,
}
response = requests.post(
"https://api.sendpost.io/api/v1/subaccount/email/",
json=payload,
headers={
"Content-Type": "application/json",
"X-SubAccount-ApiKey": SENDPOST_API_KEY,
},
timeout=10, # always set a timeout
)
response.raise_for_status() # raises HTTPError on 4xx/5xx
return response.json()
# Usage
send_email(
to="user@example.com",
subject="Reset your password",
html='<p>Click <a href="https://yourapp.com/reset?token=abc123">here</a> to reset.</p>',
)The timeout=10 argument is not optional in production. Without it, requests will wait forever if the connection stalls, and one hung send can tie up a worker indefinitely. raise_for_status() is your floor for error handling, not your ceiling. In real code, catch requests.exceptions.HTTPError and requests.exceptions.Timeout separately, because a bad payload and a slow network call need different responses.
Pattern 2: Flask webhook handler (Python)
# Flask webhook endpoint: receive SendPost event callbacks
from flask import Flask, request, jsonify
app = Flask(__name__)
# SendPost identifies events by numeric code, not by name.
PROCESSED, DROPPED, DELIVERED = 0, 1, 2
SOFT_BOUNCED, HARD_BOUNCED = 3, 4
OPENED, CLICKED, UNSUBSCRIBED, SPAM = 5, 6, 7, 8
@app.route('/webhooks/email', methods=['POST'])
def email_webhook():
# Each event looks like:
# {"event": {"type": int, "messageID": str},
# "emailMessage": {"to": [{"email": str}]}}
# Accept a single event or a batch so this keeps working either way.
payload = request.get_json()
events = payload if isinstance(payload, list) else [payload]
for item in events:
event_type = item["event"]["type"]
recipient = item["emailMessage"]["to"][0]["email"]
if event_type == HARD_BOUNCED:
suppress_email(recipient)
elif event_type == SPAM:
unsubscribe_email(recipient)
# Always acknowledge receipt, even for events you ignore.
return jsonify({'status': 'ok'}), 200Same rule as Node.js: return 200 fast, process slow. If you're on Django, the equivalent view uses JsonResponse and the same loop. On FastAPI, make the route an async def and the structure is identical. The framework changes; the contract with the webhook sender does not. The exact payload your endpoint receives is documented in the SendPost webhook object reference — worth confirming before you rely on a particular shape.
Pattern 3: Retry with backoff (Python)
For automatic retries, reach for tenacity instead of hand-rolling a loop. It gives you exponential backoff with jitter in a decorator, which keeps the send function readable.
# pip install tenacity requests
import requests
from tenacity import retry, stop_after_attempt, wait_exponential_jitter, retry_if_exception_type
@retry(
stop=stop_after_attempt(4),
wait=wait_exponential_jitter(initial=1, max=8),
retry=retry_if_exception_type(requests.exceptions.RequestException),
)
def send_with_retry(payload: dict) -> dict:
response = requests.post(
"https://api.sendpost.io/api/v1/subaccount/email/",
json=payload,
headers={"X-SubAccount-ApiKey": SENDPOST_API_KEY},
timeout=10,
)
response.raise_for_status()
return response.json()One efficiency note for high-volume senders: reuse a requests.Session instead of creating a new connection per call. Connection pooling cuts the overhead of repeatedly opening TLS connections, which adds up fast when you're sending thousands of messages an hour.
How do you handle bounces and spam complaints?
Handle a hard bounce by suppressing the address immediately and never retrying it. Handle a soft bounce by retrying with backoff, capped at three attempts, then treating it as hard. Handle a spam complaint by unsubscribing the recipient on the spot, because CAN-SPAM and CASL both require it. Ignore these signals and your domain reputation drops, which quietly tanks delivery for every email you send afterward.
A hard bounce is a permanent failure: the address doesn't exist, or the domain is gone. Keep resending to it and your bounce rate climbs above the 2% safe baseline, which tells Gmail and Yahoo you're a careless sender. That label follows your domain. A soft bounce is temporary, like a full mailbox or a server hiccup, so a short retry window is fine. But cap it. After three soft bounces to the same address, stop and suppress. SendPost signals each of these with a numeric event code; the full list and what each one means is in the webhook event lifecycle reference.
The stakes are worth the engineering. Transactional emails generate 8x more opens and clicks than bulk marketing email (Experian, widely cited industry research). That engagement advantage is exactly what you lose when poor bounce handling drags your reputation down, because reputation is what gets you into the inbox in the first place. Our deep dive on email deliverability in 2026 covers how sender reputation is scored and recovered.
Why your API key doesn't replace SPF, DKIM, and DMARC
Your API key authenticates you to your email provider. It does not authenticate your mail to Gmail, Yahoo, or Outlook. SPF, DKIM, and DMARC are DNS records that receiving servers check on their own, independent of your API credentials. Set up the key and skip the DNS records and your perfectly formed API calls will still land in spam. This is the most common reason developer integrations underperform.
The pattern is so common it's almost a cliché. The code is correct, the API returns 200, every test passes, and the mail still goes to spam. The cause is almost always missing or misconfigured authentication records. As LoriBeth Blair, a deliverability consultant we work with, put it bluntly: even software developers with 20-plus years of experience tend to glaze over the moment email authentication comes up. It feels like someone else's problem until inbox placement craters. It isn't someone else's problem. It's three DNS records, and our authentication guide shows you exactly which ones.
Why developers building at scale choose SendPost
SendPost is a REST-first email API built for developers who'd rather not configure SMTP. It returns JSON throughout, fires webhook callbacks for every event type with a consistent schema, and handles bounce suppression and complaint management for you. For teams scaling past a single provider, it supports multi-provider routing, so you can send through SendPost and keep your SendGrid or Amazon SES keys as a fallback. If you're moving off SES specifically, our Amazon SES alternative breakdown covers the migration.
The point isn't the feature list. It's that consistent webhook schemas and clean API responses are what let the patterns above stay this short. When you scale, a dedicated IP and the bounce-handling discipline we covered become the difference between 98% delivery and a spam-foldered domain. Full API documentation lives at docs.sendpost.io.
The next developer to integrate your API might be an AI agent
Here's the shift worth sitting with. The bar for email API documentation just got higher, because your next "developer" might not be a person. It might be a coding agent making the library choice on a human's behalf. Varun Jain, CTO and co-founder of SendWorks, framed it on a recent call:
"Today when [developers] are building a new product, they would typically be asking Claude or ChatGPT or Gemini to figure out [the right] email API product. They won't be writing the code from an integration perspective or connecting with SMTP. The LLMs would be taking that decision. So whatever our API spec is, how we are exposing the MCP, needs to be optimized from that perspective." Varun Jain, CTO and Co-founder, SendWorks
That reframes everything in this guide. A clean REST API, a consistent webhook schema, and honest documentation aren't just nice for the human skimming your docs at 2am. They're what an LLM reads when it decides which provider to wire in. As Varun put it, "you can't get to this level without building the first-class primitives first." The send call is 20 lines in any language. The infrastructure behind it, the bounce handling, the retries, the DNS authentication, is what actually decides whether your mail arrives. And it's worth getting right: automated and triggered emails drove 37% of email-attributed sales while making up just 2% of total volume (Omnisend, 2024). Get those primitives right and you become the default choice when the next developer, human or AI, asks Claude to "add email to my app."
FAQs
1. Can I send transactional email from a serverless function?
Yes, and it is one of the main reasons to choose a REST API over SMTP. SMTP holds a persistent connection open and negotiates state, which fights functions that spin up and tear down in milliseconds. A stateless HTTPS POST completes inside a single invocation, so there is no connection to keep alive between calls.
2. Do I still need SPF, DKIM, and DMARC if I use an email API?
Yes. Your API key authenticates you to your email provider. It tells Gmail and Outlook nothing about whether you are allowed to send as your domain — those three DNS records are what receiving mail servers actually check. An API call can return 200, meaning accepted for delivery, and still land in spam if authentication is missing.
3. What is the difference between transactional and marketing email?
Transactional email is triggered by a user action and sent to one person: password resets, receipts, shipping notifications. Marketing email goes to a list on your schedule. The distinction matters technically because the two have different deliverability profiles, and in most jurisdictions different consent requirements.
4. Should I use Node.js or Python to send email?
Neither has an advantage at the protocol level — both make the same HTTPS POST with the same headers and body. Use whichever language your application already runs in. The send is roughly 20 lines either way; the real differences are library choice, fetch versus requests, and retry tooling, where Python has tenacity and Node.js usually means hand-rolling backoff.
5. How do I test a webhook handler locally?
Point a tunnel such as ngrok or Cloudflare Tunnel at your local server and register that public URL as the webhook endpoint, or send a test event to a request-inspection service first. Capturing one real delivery is the reliable way to confirm the exact payload shape your handler receives before you write code against it.
Ready to send your first message? Grab a key and copy one of the snippets above from the SendPost docs. The code works today. The reputation you build with it compounds for years.