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.

Base URL: https://app.stacksender.devAuth: Bearer tokenContent-Type: application/json

#Quick start

Send your first email in under 5 minutes. No SDK required.

  1. 1.Create an account
  2. 2.Add a domain and copy the 3 DNS records to your registrar
  3. 3.Create an API key
  4. 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 typeAllowed operations
full_accessSend, validate, templates, domains, webhooks, suppressions
sending_onlySend email, validate addresses only

#Send email

POST /api/v1/emails

FieldTypeRequiredNotes
fromstringYesMust match a verified domain
tostring | string[]YesOne or more recipients
subjectstringIf no template_idEmail subject line
htmlstringNoHTML body
textstringNoPlain-text body
cc / bccstring | string[]NoCarbon copy addresses
reply_tostring | string[]NoReply-To addresses
template_idstring (UUID)NoUse a saved template
variablesobjectNoTemplate variable substitution
scheduled_atISO 8601NoSchedule future send
validate_before_sendbooleanNoSkip 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 react

Then 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
  }
}
Tip: Use 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

PlanDaily limitPer-minute limit
Free10010
ProUnlimited100

Rate limit headers: X-RateLimit-Limit-Day, X-RateLimit-Remaining-Day,X-RateLimit-Limit-Minute, Retry-After on 429 responses.

#Error codes

HTTPCodeMeaning
400invalid_jsonRequest body is not valid JSON
401invalid_api_keyMissing or revoked API key
403domain_not_verifiedSending domain not yet verified
404not_foundResource does not exist or not owned by your team
422validation_errorRequest body failed schema validation
422all_recipients_suppressedAll recipients are on the suppression list
422all_recipients_high_riskAll recipients skipped by validate_before_send
429minute_rate_limit / daily_rate_limitToo many requests — check Retry-After
502ses_errorAWS 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. 1.
    Update the base URL. Point your client at https://app.stacksender.dev.
  2. 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" }, ...
});
What you also get: email validation, inbound forwarding, team members, and a custom-brandable unsubscribe page — features that often live on higher-priced tiers elsewhere. Most teams see 60-75% lower cost per email at scale.

Ready to send?

Free tier includes 100 emails/day, full API access, and all features. No credit card required to start.

Create free account →