Retry a transactional email without sending it twice
Send every transactional email with an idempotency key derived from the event that caused it (for example pwreset-8412), and send the same key again on every retry. Email Digit then returns the original result instead of sending a second email, and rejects the key if it ever arrives with a different payload.
Why retries send duplicates
A password reset that never arrives is a support ticket. One that arrives twice looks like a bug, and two different reset links in the same inbox make people wonder which one is real. Both come from the same place: the network.
Your app calls the email API. The request reaches the server, the email is queued, and then the response is lost: a timeout, a dropped connection, a deploy that restarts the worker. From your app’s side, the call failed. It retries, which is correct, and the server sees a second, new request. Without anything linking the two, it sends a second email.
Removing retries swaps duplicates for lost mail. The fix is to make the retry recognisable.
Choosing a good idempotency key
An idempotency key names the intent: “the reset email for reset request 8412”. The server remembers which keys it has seen. A request with a new key is processed; a request with a key it has seen returns the result of the first one.
The key only works if every retry produces the same one, which means it has to come from your data, not from the HTTP call.
| Key | Safe? | Why |
|---|---|---|
pwreset-8412 (the reset request id) | Yes | Every retry of that reset carries the same key. |
receipt-order-5531 | Yes | One receipt per order, however many times the job runs. |
| A random UUID generated at call time | No | A retry makes a new UUID, so it looks like a new email. |
| The recipient’s address | No | Their second, legitimate reset next month would be treated as a repeat. |
If the random UUID is created once, stored with the event, and read back on retry, it is fine. The rule is that the key is decided before the first attempt and never regenerated.
The request in Email Digit
Your app sends one POST: the trigger key of the journey to run, the recipient, the variables the email uses, and the idempotency key.
curl -X POST https://api.emaildigit.com/api/transactional/send \
-H "Authorization: Bearer $ED_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"trigger_key": "password_reset",
"to": "ana@example.com",
"variables": { "name": "Ana", "action_url": "https://example.com/reset/8412" },
"idempotency_key": "pwreset-8412"
}'It answers 202 Accepted with a status and the ids of the messages it queued. What happens on a repeat depends on what you sent:
- Same key, same payload: no new email. The response has status
duplicateand the original message ids, so your app can record them as if the first call had succeeded. - Same key, different payload: refused with
409and the message “This idempotency_key was already used with a different payload.” That is almost always a bug in the caller, such as a key built from the user id instead of the event, and you want to hear about it rather than have either version sent quietly.
“Payload” here means the trigger key, the recipient and the variables. The order of the variables does not matter. Test and live keys keep separate sets of idempotency keys, so a test run never blocks a real send.
Trigger keys lock once they are used
Idempotency protects you from the network. The second guard protects you from your own team. The trigger key is a label in the dashboard and also a string hardcoded in your app. If a teammate tidies it from password_reset to pw-reset, every call from your code starts failing, and the person who made the change never sees an error.
So once a journey’s trigger key has been used (a message has been sent for it, or the journey has been live), it can no longer be renamed from the dashboard. A key that was mistyped and never fired stays editable. The display name can change at any time. To move to a new key, create a new journey with it, switch your code over, then archive the old one.

The other answers your code should handle
| Response | Meaning | Retry? |
|---|---|---|
202, status suppressed | The address hard bounced or complained before. Recorded, nothing sent. | No |
404 | No journey has that trigger key. | No, fix the key |
409 | The journey is not active, has no steps, or the key was reused with a new payload. | No, fix the cause |
422 | Missing recipient, or your sending address is on a domain that is not verified yet. | No |
403 | The API key lacks the email:send scope. | No |
| Timeout or a 5xx | The outcome is unknown. | Yes, with the same idempotency key |
Live sends need an active journey, and a sending address on a domain that is not verified is refused. A test key records the send in the message log without delivering it, so you can wire up the integration against a draft journey.
What this does not cover
Idempotency stops the same request from sending twice. It does not decide whether two different events should both send: two genuine resets a minute apart have two keys and send two emails, which is correct. And we do not publish a time after which a key may be reused, so treat keys as permanent and never reuse one for a different message. The developer docs have the same request in JavaScript and Python.