Reference
Idempotency
A request whose response you never saw is the hardest case in any API: the message may have been accepted, or not, and there is no way to tell from the outside. An idempotency key makes retrying that request free.
It is a body field, not a header
The Idempotency-Key HTTP header is never read. It appears in our CORS allowlist, so a browser will happily send it and nothing will complain — and it has no effect whatsoever. A retry carrying only the header sends a second email.
This is worth stating plainly because Stripe, and much of the ecosystem trained on it, put idempotency in a header. Here it is idempotencyKey in the JSON body of POST /v1/emails, and nowhere else.
How it behaves
# First call
POST /v1/emails
{ "from": "…", "to": "…", "text": "…", "idempotencyKey": "invoice-2026-114" }
202 { "id": "msg_AZLm3kQ8T2Sf9pXbNc7HrQ", "status": "queued" }
# Same key again — no second message is sent
POST /v1/emails
{ "from": "…", "to": "…", "text": "…", "idempotencyKey": "invoice-2026-114" }
200 { "id": "msg_AZLm3kQ8T2Sf9pXbNc7HrQ", "status": "duplicate" }| Property | Value |
|---|---|
| Where | idempotencyKey, a string in the request body. 1–255 characters. |
| Which endpoints | POST /v1/emails only. No other endpoint accepts one. |
| Scope | Per account. Two accounts may use the same key without interfering; two keys within one account never collide. |
| First use | 202 · { id, status: "queued" } |
| Replay | 200 · { id, status: "duplicate" } — the id of the original message. |
| Lifetime | As long as the message record it belongs to. There is no short expiry window to race against, and equally no point at which a key becomes safe to reuse for a different message. |
200 and 202 are both success. A client that only accepts 202 will read its own successful retry as a failure and retry again — which is exactly the loop idempotency exists to break. Branch on res.ok, and use status only for logging.
The check runs before any other work: before the domain lookup, before suppression, before quota. A replay is therefore cheap, is not billed, does not consume any part of your daily cap or monthly allowance, and produces no second delivery record.
The key matches on the key alone
The message body is not part of the comparison. Reusing a key with different content returns 200 duplicate and the id of the original message — the new content is discarded silently, and there is no error to notice.
So a key must identify one specific message, not a customer, a template or a day. invoice-2026-114-reminder-2 is a good key. customer-8842 is a key that will one day swallow a password reset.
Derive keys from something in your own database that is already unique per message — a row id, or an entity id combined with a counter. Random keys generated per attempt are worse than none: they are unique per retry, so they guarantee duplicates rather than preventing them.
The retry pattern
Decide the key before the first attempt and reuse it for every retry of that message, including retries in a later process after a crash.
// A send that is safe to retry, because the key is decided BEFORE the
// first attempt and reused by every one of them.
async function sendInvoiceEmail(invoice) {
const key = `invoice-${invoice.id}-reminder-${invoice.reminderCount}`
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch('https://api.posthastemail.dev/v1/emails', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.POSTHASTE_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({
from: '[email protected]',
to: invoice.customerEmail,
subject: `Invoice ${invoice.number}`,
text: renderInvoiceEmail(invoice),
idempotencyKey: key,
}),
})
// 202 queued and 200 duplicate are BOTH success. Treating 200 as a
// failure and retrying is the loop this exists to break.
if (res.ok) return res.json()
// 4xx other than 429 will never succeed. Stop.
if (res.status < 500 && res.status !== 429) {
throw new Error(await res.text())
}
} catch (err) {
if (attempt === 4) throw err
}
await sleep(2 ** attempt * 1000)
}
}Retry on a timeout, a connection error, any 5xx, and 429 after the interval the response asks for. Do not retry any other 4xx — those describe a request that will fail identically for ever. See errors.
The other direction
Webhook deliveries carry their own idempotency, from us to you: Posthaste-Delivery-Id is stable across every retry of the same event. Deduplicate on it, and your handler can be as safely retried as your sends are. See webhooks.
NextRate limits →The request limit, the headers it sets, and how it differs from your quota.