Idempotency keys
A retried request returns the original message. Kept for 24 hours.
POSTIdempotency-Key: order-1234TimeoutPOSTsame key, same bodymsg_8Xk2…A small REST API that is honest about what has and hasn’t happened to each message.
# 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" }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.
Saved safely. This is what a 202 means.
The mail provider accepted it. We bill here.
Their server accepted it. Not a promise of the inbox.
Rejected. Hard bounces suppress the address.
Marked as spam. The address is suppressed.
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.
A retried request returns the original message. Kept for 24 hours.
POSTIdempotency-Key: order-1234TimeoutPOSTsame key, same bodymsg_8Xk2…Pick the result with the address. Nothing real is sent.
delivered@x.testDeliveredbounced@x.testBouncedcomplained@x.testSpam reportconst 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);
// });Contacts, segments and campaigns by API, with stats for each send. Guide
Read incoming mail by API, or get an email.received webhook. Guide
Every endpoint is in the API reference and the OpenAPI file. Guides for Zapier, Make, n8n, Supabase and Clerk.