Idempotency
Attach a key to a send and PostStack delivers it at most once, however many times you retry — after a timeout, a crash or a 5xx. This page covers where keys are honoured, what a repeat returns, and how long a key lasts.
When you need it#
A network error after you call POST /emails leaves you not knowing whether the email was accepted. Retrying without a key can send it twice; not retrying can send it zero times. With an idempotency key you retry freely: the first request that gets through sends the email, and every later request with the same key gets that email's ID back without sending anything.
Keys are honoured on the sending paths only:
| Path | How to pass the key | Reused with a different payload |
|---|---|---|
POST /emails (and /emails/send) | idempotency_key in the body, or an Idempotency-Key header | 422 |
POST /emails/batch | idempotency_key on each element, or one Idempotency-Key header for the batch | That element gets error; the rest proceed |
| SMTP relay | X-PostStack-Idempotency-Key message header | Replays the first email (no error) |
| Workflow send-email steps | Automatic, one key per run and step | Replays the first email (no error) |
Every other endpoint ignores the header. Creating a contact, a domain or a broadcast twice creates two; POST /workflows/events and POST /workflows/:id/trigger start a new run each time they are called.
Sending with a key#
const result = await poststack.emails.send({
from: 'Acme <billing@yourdomain.com>',
to: ['customer@example.com'],
subject: 'Your receipt for order 1234',
html: receiptHtml,
idempotency_key: 'order-1234-receipt',
});
if (result.replayed) {
// Already sent earlier — result.id is that email. Nothing went out now.
}curl -i -X POST https://api.poststack.dev/emails \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1234-receipt" \
-d '{
"from": "Acme <billing@yourdomain.com>",
"to": ["customer@example.com"],
"subject": "Your receipt for order 1234",
"html": "<p>Thanks for your order.</p>"
}'result = client.emails.send({
"from": "Acme <billing@yourdomain.com>",
"to": ["customer@example.com"],
"subject": "Your receipt for order 1234",
"html": receipt_html,
"idempotency_key": "order-1234-receipt",
})
if result.get("replayed"):
pass # already sent earlier; nothing went out nowres, err := client.Emails.Send(ctx, &poststack.SendEmailInput{
From: "Acme <billing@yourdomain.com>",
To: []string{"customer@example.com"},
Subject: "Your receipt for order 1234",
Html: receiptHTML,
IdempotencyKey: "order-1234-receipt",
})
if err == nil && res.Replayed {
// already sent earlier; nothing went out now
}# Add the key as a message header. The relay reads it and does not pass it on.
From: Acme <billing@yourdomain.com>
To: customer@example.com
Subject: Your receipt for order 1234
X-PostStack-Idempotency-Key: order-1234-receipt
Thanks for your order.idempotency_keystring- Up to 64 characters. A longer value is rejected with
400. If both are present, the body field wins over the header. Idempotency-Keyheader- Up to 64 printable ASCII characters, no spaces. A header that is empty, too long or contains other characters is silently ignored — the send goes ahead without a key — so prefer the body field when you control the payload.
idempotency_window_hoursinteger- Deprecated. Accepted (1–72) for compatibility and ignored; see How long a key lasts.
Choosing a key
- Derive it from the event that causes the email, so a retry from anywhere in your system produces the same key:
order-1234-receipt,user-88-password-reset-2026-09-29T10:14. - Never reuse one key for different emails (a per-customer key, or an order ID shared by the receipt and the shipping notice). The second email would be refused or, over SMTP, silently not sent.
- Don't start keys with
wf_: workflow steps use keys of the formwf_<run>_node_<step>in the same namespace.
What a repeated request returns#
Same key, same payload: a replay
The response is 202 with the original email's ID and "replayed": true, and the X-Idempotent-Replay: true header. Nothing is sent, no webhook fires, and the replay doesn't count toward your monthly quota or the new-account sending caps. (It does count as a request for the endpoint's rate limit.) A replay carries no spam_score or test_mode; fetch the email with GET /emails/:id if you need its status.
HTTP/1.1 202 Accepted
Content-Type: application/json
X-Idempotent-Replay: true
{
"id": "em_k3v9x2m8q1w7r4t6y0p5n2bz",
"replayed": true
}Same key, different payload: a conflict
On POST /emails, reusing a key with a request that differs from the first one is refused, because replaying would tell you an email was sent that never was:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": "This idempotency key was already used for a different request. Use a new key to send a different email."
}Nothing is sent. If you meant to send a different email, use a new key. The SMTP relay and workflow steps don't make this check: they replay the first email whatever the second request says.
In a batch
POST /emails/batch always answers 202, and each element reports its own outcome in order — a replay, a new send, or an error:
HTTP/1.1 202 Accepted
{
"data": [
{ "id": "em_k3v9x2m8q1w7r4t6y0p5n2bz", "replayed": true },
{ "id": "em_c8n1q4w0z7x2v5b9m3k6j1hd" },
{ "error": "This idempotency key was already used for a different request. Use a new key to send a different email." }
]
}An Idempotency-Key header on a batch is expanded into one key per element: <key>:0, <key>:1, and so on by position. Elements that carry their own idempotency_key keep it. Because the suffix has to fit in 64 characters, the header must leave room for it — at most 61 characters for a batch of 100. Retry a batch with the same emails in the same order, or the positions won't line up.
Two requests at the same moment
If two requests with the same key arrive together, exactly one sends. The other is answered as a replay of it (or with the 422, if its payload differs).
What counts as the same payload#
PostStack stores a SHA-256 fingerprint of the request body as you sent it, after validation, next to the key. Two requests match when every field other than idempotency_key and idempotency_window_hours is equal:
- Key order in the JSON doesn't matter.
- Any change to a field does: a new tag, a different
scheduled_at, one more recipient, a changed attachment, differentvariables. - It is the request that is compared, not the rendered email. If you edit the template behind a
template_idbetween a send and its retry, the retry still matches and replays.
Emails stored before PostStack began fingerprinting requests (September 2026) have no fingerprint, so a reused key on one of those always replays.
Checks that run before a replay#
A retry is not answered blindly. These checks run first, so a retry can fail even though the original succeeded:
- Your account email is confirmed, and the team isn't suspended or paused.
- The
template_id, if any, still exists. - The
fromdomain is still on your team and still verified (422otherwise). - Recipient count and attachment sizes are within limits.
- The API key is allowed to send from that domain.
Suppression filtering, the spam scan, the new-account sending caps and the monthly quota are not re-applied: a replay sends nothing, so it can't exceed them.
Scope#
Keys are per team. The same key from two different API keys, from SMTP or from a workflow refers to the same email.
How long a key lasts#
A key is remembered for as long as the email it belongs to is kept — there is no separate 24-hour window. When your plan's log retention deletes the email, the key is free again and a request using it sends a new email.
| Plan | Key remembered for |
|---|---|
| Free | 14 days |
| Starter | 14 days |
| Pro | 30 days |
| Scale | 90 days |
| Enterprise | As long as the email is kept |
If your team has set a shorter data-retention period, that period applies instead. Retention runs once a day, so an email can outlive its period by up to a day.
This has a practical consequence: a key really does block a resend for weeks. If you deliberately want to send the same email again — a customer asks for their receipt a second time — use a new key such as order-1234-receipt-2.
Troubleshooting#
The API returned 202 but no email was sent
Check for "replayed": true in the body or the X-Idempotent-Replay header. The key had been used before, within your retention period — possibly by a test-mode send. The returned id is the earlier email. Send with a new key.
422: This idempotency key was already used for a different request
Something in the body changed between the first request and this one — often a timestamp or a generated value in html, tags or headers. If it is a genuine retry, rebuild the request from the same inputs; if it is a different email, use a different key.
My Idempotency-Key header seems to be ignored
The header is dropped without an error when it is longer than 64 characters (or too long to take the :index suffix on a batch), or contains spaces or non-ASCII characters. Use the idempotency_key body field, which is validated and returns 400 instead.