API Documentation
StackSender is a transactional email API built on enterprise-grade infrastructure. A familiar REST shape, predictable resources, and first-class support for validation, templates, webhooks, and inbound forwarding.
#Quick start
Send your first email in under 5 minutes. No SDK required.
- 1.Create an account
- 2.Add a domain and copy the 3 DNS records to your registrar
- 3.Create an API key
- 4.Run the curl below
curl -X POST https://app.stacksender.dev/api/v1/emails \
-H "Authorization: Bearer re_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "you@yourdomain.com",
"to": "user@example.com",
"subject": "Hello!",
"html": "<p>Sent via StackSender.</p>"
}'Response: { "id": "em_abc123" }
#Authentication
All API requests require a Bearer token in the Authorization header. Create API keys in the dashboard — they are shown only once at creation.
Authorization: Bearer re_YOUR_API_KEY| Key type | Allowed operations |
|---|---|
| full_access | Send, validate, templates, domains, webhooks, suppressions |
| sending_only | Send email, validate addresses only |
#Send email
POST /api/v1/emails
| Field | Type | Required | Notes |
|---|---|---|---|
| from | string | Yes | Must match a verified domain |
| to | string | string[] | Yes | One or more recipients |
| subject | string | If no template_id | Email subject line |
| html | string | No | HTML body |
| text | string | No | Plain-text body |
| cc / bcc | string | string[] | No | Carbon copy addresses |
| reply_to | string | string[] | No | Reply-To addresses |
| template_id | string (UUID) | No | Use a saved template |
| variables | object | No | Template variable substitution |
| scheduled_at | ISO 8601 | No | Schedule future send |
| validate_before_send | boolean | No | Skip risk_score ≥ 80 recipients |
// Response (202 Accepted)
{ "id": "em_abc123" }
// With validate_before_send, skipped addresses are reported:
{ "id": "em_abc123", "skipped_high_risk": ["disposable@mailinator.com"] }#SDK examples
No official SDK yet — the API is simple enough that a thin wrapper takes under 10 lines. Here are copy-paste helpers for common environments.
Node.js (fetch / ESM)
// stacksender.js
const BASE = "https://app.stacksender.dev";
const KEY = process.env.EMAIL_API_KEY;
export async function sendEmail(payload) {
const res = await fetch(`${BASE}/api/v1/emails`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
return res.json(); // { id }
}
// Usage
await sendEmail({
from: "hello@yourdomain.com",
to: "user@example.com",
subject: "Welcome!",
html: "<p>Hello from Node.js</p>",
});Python (httpx / requests)
import os, httpx
BASE = "https://app.stacksender.dev"
KEY = os.environ["EMAIL_API_KEY"]
def send_email(**kwargs):
r = httpx.post(
f"{BASE}/api/v1/emails",
headers={"Authorization": f"Bearer {KEY}"},
json=kwargs,
)
r.raise_for_status()
return r.json() # {"id": "em_..."}
send_email(
from_="hello@yourdomain.com", # note trailing _ to avoid keyword conflict
to="user@example.com",
subject="Hello from Python",
html="<p>It works!</p>",
)PHP (cURL)
<?php
function sendEmail(array $payload): array {
$ch = curl_init("https://app.stacksender.dev/api/v1/emails");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("EMAIL_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$body = curl_exec($ch);
curl_close($ch);
return json_decode($body, true);
}
$result = sendEmail([
"from" => "hello@yourdomain.com",
"to" => "user@example.com",
"subject" => "Hello from PHP",
"html" => "<p>It works!</p>",
]);
echo $result["id"];Next.js Server Action
// app/actions/email.ts
"use server";
import { env } from "@/lib/env"; // your validated process.env wrapper
export async function sendWelcomeEmail(to: string, name: string) {
const res = await fetch(`${env.APP_URL}/api/v1/emails`, {
method: "POST",
headers: {
Authorization: `Bearer ${env.EMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "hello@yourdomain.com",
to,
subject: `Welcome, ${name}!`,
html: `<p>Thanks for signing up, ${name}.</p>`,
}),
});
if (!res.ok) throw new Error("Failed to send email");
}#React Email
Type-safe transactional email components — same DX as React in your app. Author templates as JSX, get hot-reloaded previews in react-email dev, and ship them with one call.
Install the renderer and component library alongside the SDK (kept as optional peer deps so non-React projects don't pay the install weight):
pnpm add stacksender @react-email/render @react-email/components reactThen call sendReact() with a React element. The SDK renders both HTML and a plain-text fallback under the hood.
import { StackSender } from "stacksender";
import {
Button, Container, Heading, Html, Text,
} from "@react-email/components";
const ep = new StackSender({ apiKey: process.env.EP_API_KEY! });
function WelcomeEmail({ name, verifyUrl }: { name: string; verifyUrl: string }) {
return (
<Html>
<Container>
<Heading>Welcome, {name}!</Heading>
<Text>Click below to verify your email address.</Text>
<Button href={verifyUrl}>Verify email</Button>
</Container>
</Html>
);
}
await ep.emails.sendReact({
from: "team@yourdomain.com",
to: "ada@example.com",
subject: "Verify your email",
react: <WelcomeEmail name="Ada" verifyUrl="https://app.example.com/verify/abc" />,
});Prefer to render yourself (e.g. for caching) and pass HTML straight to send()?
import { render } from "@react-email/render";
const html = await render(<WelcomeEmail name="Ada" verifyUrl="..." />);
const text = await render(<WelcomeEmail name="Ada" verifyUrl="..." />, { plainText: true });
await ep.emails.send({ from, to, subject, html, text });#Templates
Store reusable HTML templates with Mustache-style {{variable}} substitution. Create via the dashboard or the REST API.
// Create a template
POST /api/v1/templates
{
"name": "welcome",
"subject": "Welcome, {{first_name}}!",
"html": "<p>Hi {{first_name}}, welcome to {{product}}.</p>",
"text": "Hi {{first_name}}, welcome to {{product}}."
}
// Send using the template
POST /api/v1/emails
{
"from": "hello@yourdomain.com",
"to": "user@example.com",
"template_id": "tpl_abc123",
"variables": { "first_name": "Ada", "product": "Acme" }
}
// Request fields (subject, html, text) override template values
// when provided — good for per-user customisation beyond variables.#Email validation
Validate addresses before adding them to your list. Checks disposable domains, DNS MX records, role-based prefixes, and common typos.
// Single address
POST /api/v1/validate
{ "email": "user@example.com" }
// Batch (up to 100)
POST /api/v1/validate
{ "emails": ["user@example.com", "fake@mailinator.com"] }
// Response
{
"data": {
"email": "user@example.com",
"valid": true,
"disposable": false,
"role_based": false,
"mx_exists": true,
"risk_score": 10, // 0–100 (higher = riskier)
"suggestion": null // e.g. "gmail.com" if "gmal.com" detected
}
}validate_before_send: true on your send requests to automatically skip recipients with risk_score ≥ 80. No extra API call needed.#Email preview
Render a template with variables without sending it. Returns the resolved subject, HTML, and text. Useful for testing template output in CI or before triggering a real send.
POST /api/v1/emails/preview (full_access key required)
{
"template_id": "tpl-uuid-...",
"variables": { "first_name": "Ada", "product": "Acme" }
}
// Response
{
"data": {
"subject": "Welcome, Ada!",
"html": "<p>Hi Ada, welcome to Acme.</p>",
"text": "Hi Ada, welcome to Acme.",
"variables_applied": 2
}
}Also accepts inline subject, html, text with variables instead of a template ID.
#Batch send
POST /api/v1/emails/batch — send up to 100 emails in one request. Returns a 207 Multi-Status with per-email results.
POST /api/v1/emails/batch
{
"emails": [
{ "from": "hello@yourdomain.com", "to": "a@example.com", "subject": "Hi A", "html": "..." },
{ "from": "hello@yourdomain.com", "to": "b@example.com", "template_id": "tpl_abc", "variables": { "name": "B" } }
]
}
// Response (207)
{
"data": [
{ "id": "em_001", "status": "success" },
{ "id": "em_002", "status": "success" }
]
}#Domains
Add a sending domain to get DKIM/SPF/DMARC DNS records. Once DNS propagates, click Recheck in the dashboard or hit the verify endpoint.
// Add domain
POST /api/v1/domains
{ "name": "yourdomain.com" }
// Response includes DNS records to add at your registrar
{
"data": {
"id": "dom_...",
"status": "pending",
"dns_records": [
{ "type": "CNAME", "name": "_dkim1._domainkey.yourdomain.com",
"value": "...", "purpose": "DKIM" },
{ "type": "CNAME", "name": "_dkim2._domainkey.yourdomain.com",
"value": "...", "purpose": "DKIM" },
{ "type": "CNAME", "name": "_dkim3._domainkey.yourdomain.com",
"value": "...", "purpose": "DKIM" }
]
}
}
// Recheck verification after adding DNS
POST /api/v1/domains/{id}/verify#Suppressions
The suppression list is your do-not-email list. Hard bounces and spam complaints auto-populate it; manual entries are also supported. Sandbox keys see only the sandbox list, prod keys see the prod list — they're fully independent.
// List suppressions
GET /api/v1/suppressions?limit=100
// Manually suppress an address
POST /api/v1/suppressions
{ "email": "user@example.com", "reason": "manual" }
// Remove an address (lifts the suppression)
DELETE /api/v1/suppressions/user%40example.com#Audiences
An audience is a named list of contacts you can send broadcasts to. Contacts can be in multiple audiences. Use the import endpoint to bulk-load up to 50,000 rows from CSV.
// Create an audience
POST /api/v1/audiences
{ "name": "Newsletter subscribers", "description": "Opted in via the homepage form" }
// Bulk-import contacts (CSV columns: email, first_name, last_name, tags)
POST /api/v1/audiences/{id}/import
{
"csv": "email,first_name,tags\nada@example.com,Ada,early-access\n...",
"hasHeader": true
}
// → { "imported": 412, "updated": 7, "skipped": 1, "errors": [] }
// List members
GET /api/v1/audiences/{id}/contacts?limit=100#Contacts
Contacts are persons on your team's lists. Each contact has an email, optional name, optional tags, and an unsubscribe timestamp. Contacts who clicked an unsubscribe link in a broadcast get marked here automatically.
// List contacts (search by ?q=, filter by ?tag=)
GET /api/v1/contacts?q=acme&tag=customer
// Create one
POST /api/v1/contacts
{ "email": "ada@example.com", "firstName": "Ada", "tags": ["early-access"] }
// Update / delete by id
PATCH /api/v1/contacts/{id}
DELETE /api/v1/contacts/{id}#Broadcasts
A broadcast is a one-shot send to an audience. Compose as a draft, optionally filter by tags, test-send to yourself, then schedule or send-now. The dispatcher fans out via batches of 100 with a 50ms throttle for deliverability.
// Create a draft
POST /api/v1/broadcasts
{
"audienceId": "aud_...",
"domainId": "dom_...",
"fromAddress": "team@yourdomain.com",
"fromName": "Your Team",
"subject": "April product update",
"htmlBody": "<h1>Hi {{first_name}}</h1>...",
"textBody": "Hi {{first_name}}...",
"tagFilter": ["early-access"]
}
// Test-send to yourself before launching
POST /api/v1/broadcasts/{id}/test-send
{ "to": "you@example.com" }
// Send now (or set scheduledFor on the draft to schedule)
POST /api/v1/broadcasts/{id}/send
// Cancel an in-flight or scheduled broadcast
POST /api/v1/broadcasts/{id}/cancel
// Read live stats
GET /api/v1/broadcasts/{id}
// → recipientCount, sentCount, bounceCount, openCount, clickCount, unsubscribeCount#Deliverability advisor
Score an email body against ten deliverability rules — spam triggers, reading level, alt text, link ratio, plain-text fallback, unsubscribe presence, and more. Optionally augmented with an LLM check when configured.
POST /api/v1/advisor
{
"subject": "April update",
"html": "<html>...</html>",
"text": "..."
}
// Response
{
"score": 84,
"checks": [
{ "id": "spam_triggers", "severity": "ok", "message": "No high-risk phrases detected." },
{ "id": "reading_level", "severity": "info", "message": "Flesch reading ease 71 (grade 7)." },
{ "id": "alt_text", "severity": "warn", "message": "2 images missing alt text." },
{ "id": "unsubscribe", "severity": "fail", "message": "No unsubscribe link found." }
]
}#Inbox placement testing
Send a probe to seed accounts at major providers (Gmail, Outlook, Apple, Yahoo, Proton) to see where your subject line lands — Inbox, Promotions, Updates, or Spam. Free tier gets 2 tests per month; paid plans 10–200.
// Start a test
POST /api/v1/placement-tests
{ "subject": "April product update", "domainId": "dom_..." }
// → { "id": "ptest_...", "status": "pending" }
// Poll for results (or watch in the dashboard)
GET /api/v1/placement-tests/{id}/providers
// → [{ provider: "gmail", placement: "inbox", latencyMs: 2400 },
// { provider: "outlook", placement: "spam", latencyMs: 4100 }, ...]#Domain health
A daily 0-100 score per verified domain combining DKIM/SPF/DMARC alignment, RBL cleanliness across 5 blocklists, and 30-day complaint and bounce rates. The dashboard shows a sparkline; the API returns the latest snapshot plus a recommendations array.
GET /api/v1/domains/{id}/health
// Response
{
"score": 91,
"checked_at": "2026-05-09T04:30:00Z",
"breakdown": {
"auth": { "dkim": true, "spf": true, "dmarc": true },
"rbl": { "checked": 5, "listed_on": [] },
"complaint_rate_30d": 0.0007,
"bounce_rate_30d": 0.012,
"send_volume_30d": 4250,
"recommendations": [
"Bounce rate is healthy. Keep filtering with /v1/validate before sending."
]
}
}#Branch sandboxes
A sandbox API key shares your team's domains and rate budget but uses an isolated suppression list and never fires customer webhooks. Mint one per CI branch and discard when the branch is deleted.
// Mint a sandbox key for a branch (idempotent — re-calling returns the same key)
POST /api/v1/sandboxes
{ "branchName": "feat/onboarding" }
// → { "id": "ak_...", "token": "re_sbx_<prefix>_<secret>", "branchName": "feat/onboarding" }
// List active sandboxes
GET /api/v1/sandboxes
// Revoke (also runs automatically when the branch is deleted by your CI)
DELETE /api/v1/sandboxes/{id}Sandbox tokens have the prefix re_sbx_. Production tokens use re_live_ (or the legacy re_ prefix for keys minted before sandbox isolation shipped).
#MCP for AI agents
Connect Claude Desktop, Cursor, Cline, or any other MCP-compatible host to StackSender with one config block. Six tools exposed: send_email, validate_email, list_templates, get_template, list_domains, get_domain_health.
// ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"stacksender": {
"command": "npx",
"args": ["-y", "@stacksender/mcp"],
"env": { "EP_API_KEY": "re_sbx_..." }
}
}
}Tip: use a sandbox keyfor AI agents. Sandbox sends never reach real recipients and won't consume your prod rate budget.
#Rate limits
| Plan | Daily limit | Per-minute limit |
|---|---|---|
| Free | 100 | 10 |
| Pro | Unlimited | 100 |
Rate limit headers: X-RateLimit-Limit-Day, X-RateLimit-Remaining-Day,X-RateLimit-Limit-Minute, Retry-After on 429 responses.
#Error codes
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_json | Request body is not valid JSON |
| 401 | invalid_api_key | Missing or revoked API key |
| 403 | domain_not_verified | Sending domain not yet verified |
| 404 | not_found | Resource does not exist or not owned by your team |
| 422 | validation_error | Request body failed schema validation |
| 422 | all_recipients_suppressed | All recipients are on the suppression list |
| 422 | all_recipients_high_risk | All recipients skipped by validate_before_send |
| 429 | minute_rate_limit / daily_rate_limit | Too many requests — check Retry-After |
| 502 | ses_error | AWS SES rejected the request |
Error shape: { "error": { "code": "...", "message": "..." } }
#Migrate from another provider
StackSender is intentionally compatible with the conventions developers already know. In most cases, switching takes two small changes — base URL and API key.
- 1.Update the base URL. Point your client at
https://app.stacksender.dev. - 2.Swap your API key. Create one from the dashboard and replace the value in your environment. Keys start with
re_.
// Before
const res = await fetch("https://api.your-old-provider.com/emails", {
headers: { Authorization: "Bearer OLD_KEY" }, ...
});
// After — only the URL and key change
const res = await fetch("https://app.stacksender.dev/api/v1/emails", {
headers: { Authorization: "Bearer re_NEW_KEY" }, ...
});Ready to send?
Free tier includes 100 emails/day, full API access, and all features. No credit card required to start.
Create free account →