A retry should never mean a second email.
An idempotency key is a string you attach to a send request that names the logical thing you are sending: this order’s receipt, this signup’s welcome. Send it in the Idempotency-Key header on POST /v1/emails, and a retry with the same key returns the first attempt’s emailId instead of sending again. It costs one header and removes the most common way a transactional email goes out twice.
Why a retry can send twice.
A send request has two halves: your request reaching the API, and the API’s answer reaching you. When the second half fails, your code sees a timeout and cannot tell whether the email was created. Every sensible runtime retries at that point: an HTTP client with a retry policy, a Lambda invoked asynchronously, a queue consumer whose visibility timeout expired, a cron job that runs again after a crash. Each retry is a fresh request to the API, and without a key each one is a fresh send. The customer receives two receipts, and you cannot see why, because from your side only one send succeeded.
The fix is to make the request itself say which send it is. With a key, the API stores the outcome of the first attempt under that key, and any later request carrying the same key gets that outcome back. The retry is then safe by construction, not by luck.
How Notix handles the header.
The server keeps a hash of the canonical request body next to the key. The canonical form sorts object keys and drops undefined fields, so two requests that differ only in field order count as the same body.
- Same key, same body:
200with the originalemailId. Nothing is sent again, and nothing is billed again. - Same key, different body:
409with codeNOT_UNIQUE. A key that has been attached to one message cannot quietly be attached to another. - Same key, first request still running:
409 NOT_UNIQUEwith a message saying the request is in progress. Two workers that pick up the same job at the same moment cannot both send; the second waits and retries.
A key is remembered for 24 hours after the first successful send and may be up to 256 characters. The in-flight lock lasts 60 seconds. A request that fails before an email is created leaves nothing behind, so the same key can be tried again straight away.
Choosing a key.
The key has to be the same on every attempt for the same send, which means it has to be derived from something you already have, not generated when the request is built. Your own record id plus the message’s purpose is the reliable recipe: order-1234-receipt, signup-9f2c-welcome, invoice-2026-0917-reminder. The purpose matters because one order can legitimately produce several emails, and each needs its own key.
A UUID minted per attempt is the common mistake: it looks unique, and it is, which is exactly why the retry carries a different one and sends again. If you queue sends, put the key on the job when it is enqueued, so every worker that picks the job up sends the same one.
The same header everywhere.
curl https://app.usenotix.dev/api/v1/emails \
-H "Authorization: Bearer $NOTIX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1234-receipt" \
-d '{
"from": "receipts@acme.com",
"to": "customer@example.com",
"subject": "Your receipt for order 1234",
"html": "<p>Thanks for your order.</p>"
}'
# First call: 200 { "emailId": "em_8f3..." } (sent)
# Same again: 200 { "emailId": "em_8f3..." } (not sent again)
# Same key, different body:
# 409 { "error": { "code": "NOT_UNIQUE",
# "message": "Idempotency-Key already used with a different payload" } }
import { Notix } from "notix-js";
const notix = new Notix(process.env.NOTIX_API_KEY);
// One key per business event. A retry sends the same key, so the
// second attempt returns the first attempt's emailId instead of a copy.
const { data, error } = await notix.emails.send(
{
from: "receipts@acme.com",
to: order.customerEmail,
subject: `Your receipt for order ${order.id}`,
html: renderReceipt(order),
},
{ idempotencyKey: `order-${order.id}-receipt` },
);
if (error?.code === "NOT_UNIQUE") {
// Either the body changed under a reused key, or the first attempt is
// still in flight. Wait a moment and retry with the same key.
}
import os
from notix import Notix
notix = Notix(os.environ["NOTIX_API_KEY"])
data, error = notix.emails.send(
{
"from": "receipts@acme.com",
"to": order.customer_email,
"subject": f"Your receipt for order {order.id}",
"html": render_receipt(order),
},
options={"idempotency_key": f"order-{order.id}-receipt"},
)
if error and error["code"] == "NOT_UNIQUE":
# Body changed under a reused key, or the first attempt is still
# in flight. Wait a moment and retry with the same key.
...
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://app.usenotix.dev/api/v1/emails", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("NOTIX_API_KEY"))
req.Header.Set("Content-Type", "application/json")
// Same key on every attempt for this order's receipt.
req.Header.Set("Idempotency-Key", "order-"+order.ID+"-receipt")
res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return "", err // timed out or no route: safe to call send() again
}
defer res.Body.Close()
if res.StatusCode == http.StatusConflict {
// NOT_UNIQUE: body changed, or the first attempt is still running.
}
POST /v1/emails/batch takes the header too; a replayed batch returns the same list of ids the first call produced. So does POST /v1/sms. The key is scoped to your team, so a send started from one API key and retried from another still resolves to one message. The SMTP relay has no equivalent, because SMTP has no header for it; if a retry must be safe, use the API. The full parameter description is in the API reference.
Questions, answered.
What is an idempotency key when sending email?
What does the API return when I reuse a key?
How long is a key remembered?
How should I choose the key?
Does the key apply to batch sends and SMS?
Is the key scoped to my API key or to my team?
Keep reading.
Batch and scheduled sends
Up to 100 messages in one call, a send scheduled for later, and what each returns and costs.
ProductTransactional email API
OTPs, receipts and alerts with idempotency keys, a deliverability pre-check and a shared suppression list.
GuidesSend email from Node.js
The SDK, plain fetch, or Nodemailer pointed at the relay: three ways to send from Node in a few minutes.
Use casesPassword reset email
A token link flow and an OTP variant, the template copy, and the limits and suppression rules that apply to resets.
NotixDocs
The quickstart: verify a domain, copy an API key, send the first email.
One header, no duplicate receipts.
Send your first email with an Idempotency-Key in five minutes, on the free plan.