Skip to content

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:

PathHow to pass the keyReused with a different payload
POST /emails (and /emails/send)idempotency_key in the body, or an Idempotency-Key header422
POST /emails/batchidempotency_key on each element, or one Idempotency-Key header for the batchThat element gets error; the rest proceed
SMTP relayX-PostStack-Idempotency-Key message headerReplays the first email (no error)
Workflow send-email stepsAutomatic, one key per run and stepReplays 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.
}
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 form wf_<run>_node_<step> in the same namespace.

The SDKs add a key for you — for one call only

The TypeScript, Python and Go SDKs send a random Idempotency-Key (a UUID) with every POST and reuse it for their own automatic retries of that call. That protects against a dropped connection inside one call. It does not protect a retry your code makes — a queue job that runs again, a process restart — because the next call gets a new UUID. Pass your own idempotency_key for that.

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.

json
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:

json
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:

json
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, different variables.
  • It is the request that is compared, not the rendered email. If you edit the template behind a template_id between 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 from domain is still on your team and still verified (422 otherwise).
  • 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.

Test and live mode share keys

A key used with an sk_test_ key is taken for sk_live_ keys too. If you send with a test key and then retry the same payload with a live key, you get a replay of the simulated email and nothing is delivered. Give test traffic its own keys, for example with a test- prefix.

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.

PlanKey remembered for
Free14 days
Starter14 days
Pro30 days
Scale90 days
EnterpriseAs 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.

Next steps

Was this page helpful?

Related