sendsend.email

Built to be debugged.

A small REST API that is honest about what has and hasn’t happened to each message.

terminal
# queue an email, safely
curl https://api.sendsend.email/v1/emails \
  -H "Authorization: Bearer $KEY" \
  -H "Idempotency-Key: signup-123" \
  -d '{"from":"Acme <hello@mail.acme.com>",
      "to":["maria@example.com"],
      "subject":"Welcome","text":"Hi!"}'

# 202 Accepted: saved, not yet delivered
{ "id": "msg_8Xk2pQwLr0aV", "status": "queued" }

Send an email in your language.

curl -X POST 'https://api.sendsend.email/v1/emails' \
  -H "Authorization: Bearer $SENDSEND_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "from": "Acme <hello@mail.example.com>",
  "to": [
    "delivered@example.com"
  ],
  "subject": "Welcome to Acme",
  "html": "<p>Thanks for signing up!</p>",
  "text": "Thanks for signing up!",
  "tags": {
    "application_reference": "signup_123"
  }
}'

Test keys render and simulate. They never deliver real email.

A status is a promise.

Queued

Saved safely. This is what a 202 means.

Sent

The mail provider accepted it. We bill here.

Delivered

Their server accepted it. Not a promise of the inbox.

Bounced

Rejected. Hard bounces suppress the address.

Complained

Marked as spam. The address is suppressed.

Unconfirmed

No answer from the provider. We never resend blindly, so a password reset cannot go out twice.

A queued message and a delivered one are different things, so we never blur them.

Retry without fear.

Idempotency keys

A retried request returns the original message. Kept for 24 hours.

POSTIdempotency-Key: order-1234Timeout
POSTsame key, same bodymsg_8Xk2…
1 emailsent once, however many retries

Test every outcome

Pick the result with the address. Nothing real is sent.

delivered@x.testDelivered
bounced@x.testBounced
complained@x.testSpam report

Webhooks you can trust.

const crypto = require("crypto");

// rawBody must be the exact request body string, before any JSON parsing.
function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = header.split(",").map((p) => p.trim().split("="));
  const t = Number(parts.find((p) => p[0] === "t")?.[1]);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
  );
  return parts
    .filter((p) => p[0] === "v1")
    .some((p) => {
      const got = Buffer.from(p[1] || "");
      return got.length === expected.length && crypto.timingSafeEqual(got, expected);
    });
}

// Express: app.post("/hook", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyWebhook(req.body.toString(), req.get("SendSend-Signature"), process.env.SENDSEND_WEBHOOK_SECRET)) return res.sendStatus(400);
//   const event = JSON.parse(req.body); // deduplicate on event.id
//   res.sendStatus(200);
// });
HMAC
SHA-256 signature and timestamp
8
retries with backoff, then stop
24 h
secret rotation overlap
1×
replay sends the same event

More than sending.

Audiences and broadcasts

Contacts, segments and campaigns by API, with stats for each send. Guide

Segment: Pro plan, subscribed184 people
Delivered98.8%
Opened35%
Unsubscribed0.5%

Receive email

Read incoming mail by API, or get an email.received webhook. Guide

Question about my ordersupport@
Re: your invoice1 file
Receivingsupport.acme.com

Every endpoint is in the API reference and the OpenAPI file. Guides for Zapier, Make, n8n, Supabase and Clerk.

50
recipients per request
20/s
requests per key
10 MB
of attachments per email
30 days
of request logs
0
surprises: every limit is documented

All limits · API reference · OpenAPI file