Idempotency ensures that retrying a failed request doesn’t accidentally send duplicate emails. This is critical for reliable email delivery in distributed systems.
The Problem
Network issues can cause ambiguous failures:
Client ──▶ Request ──▶ Ark ──▶ Email Accepted
◀── Timeout ──
Did the email send or not? Without idempotency, retrying might send the email twice.
The Solution
Include an idempotency key with your request. If you retry with the same key, Ark returns the original response instead of processing again.
from ark import Ark
client = Ark()
idempotency_key = f "order-confirmation- { order_id } "
email = client.emails.send(
from_ = "[email protected] " ,
to = [customer.email],
subject = f "Order Confirmation # { order_id } " ,
html = order_confirmation_html,
idempotency_key = idempotency_key
)
import Ark from 'ark' ;
const client = new Ark ();
const idempotencyKey = `order-confirmation- ${ orderId } ` ;
const email = await client . emails . send ({
from: '[email protected] ' ,
to: [ customer . email ],
subject: `Order Confirmation # ${ orderId } ` ,
html: orderConfirmationHtml ,
}, {
idempotencyKey ,
});
require "ark_email"
client = ArkEmail :: Client . new
idempotency_key = "order-confirmation- #{ order_id } "
email = client. emails . send_ (
from: "[email protected] " ,
to: [customer. email ],
subject: "Order Confirmation # #{ order_id } " ,
html: order_confirmation_html,
idempotency_key: idempotency_key
)
client := ark . NewClient ()
idempotencyKey := fmt . Sprintf ( "order-confirmation- %s " , orderID )
email , _ := client . Emails . Send ( ctx , ark . EmailSendParams {
From : "[email protected] " ,
To : [] string { customer . Email },
Subject : fmt . Sprintf ( "Order Confirmation # %s " , orderID ),
HTML : ark . String ( orderConfirmationHTML ),
}, option . WithIdempotencyKey ( idempotencyKey ))
How It Works
First request : Ark processes the request and caches the response with the key
Retry (same key) : Ark returns the cached response without reprocessing
Different key : Ark processes as a new request
Request 1 (key: abc123)
└── Process ──▶ Send email ──▶ Cache response
└── Return: { id: "msg_xyz", status: "pending" }
Request 2 (key: abc123) // Same key, retry
└── Check cache ──▶ Found!
└── Return: { id: "msg_xyz", status: "pending" } // Same response
Request 3 (key: def456) // Different key
└── Check cache ──▶ Not found
└── Process ──▶ Send email ──▶ Cache response
└── Return: { id: "msg_abc", status: "pending" } // New email
Use predictable, unique identifiers:
// Good: Derived from business logic
const key = `welcome-email- ${ userId } ` ;
const key = `order- ${ orderId } -confirmation` ;
const key = `password-reset- ${ userId } - ${ timestamp } ` ;
// Bad: Random on each attempt (defeats the purpose)
const key = crypto . randomUUID (); // DON'T DO THIS
Key Lifetime
Idempotency keys are valid for 24 hours . After expiration:
The key can be reused
A new request with the same key will process normally
Implementation Patterns
Basic Retry Logic
The SDKs automatically retry failed requests. Combined with idempotency keys, you get safe retries:
from ark import Ark
# SDK retries automatically with exponential backoff
client = Ark( max_retries = 5 )
def send_transactional_email ( order_id , customer_email , content ):
# Idempotency key ensures no duplicates even across retries
return client.emails.send(
from_ = "[email protected] " ,
to = [customer_email],
subject = "Order Shipped" ,
html = content,
idempotency_key = f "shipment- { order_id } "
)
# Safe to call multiple times - only sends once
email = send_transactional_email(order_id, "[email protected] " , html_content)
import Ark from 'ark' ;
// SDK retries automatically with exponential backoff
const client = new Ark ({ maxRetries: 5 });
async function sendTransactionalEmail ( orderId : string , customerEmail : string , content : string ) {
// Idempotency key ensures no duplicates even across retries
return client . emails . send ({
from: '[email protected] ' ,
to: [ customerEmail ],
subject: 'Order Shipped' ,
html: content ,
}, {
idempotencyKey: `shipment- ${ orderId } ` ,
});
}
// Safe to call multiple times - only sends once
const email = await sendTransactionalEmail ( orderId , '[email protected] ' , htmlContent );
require "ark_email"
# SDK retries automatically with exponential backoff
client = ArkEmail :: Client . new ( max_retries: 5 )
def send_transactional_email ( order_id , customer_email , content )
# Idempotency key ensures no duplicates even across retries
client. emails . send_ (
from: "[email protected] " ,
to: [customer_email],
subject: "Order Shipped" ,
html: content,
idempotency_key: "shipment- #{ order_id } "
)
end
# Safe to call multiple times - only sends once
email = send_transactional_email (order_id, "[email protected] " , html_content)
// SDK retries automatically with exponential backoff
client := ark . NewClient ( option . WithMaxRetries ( 5 ))
func sendTransactionalEmail ( ctx context . Context , orderID , customerEmail , content string ) ( * ark . EmailResponse , error ) {
// Idempotency key ensures no duplicates even across retries
return client . Emails . Send ( ctx , ark . EmailSendParams {
From : "[email protected] " ,
To : [] string { customerEmail },
Subject : "Order Shipped" ,
HTML : ark . String ( content ),
}, option . WithIdempotencyKey ( fmt . Sprintf ( "shipment- %s " , orderID )))
}
// Safe to call multiple times - only sends once
email , _ := sendTransactionalEmail ( ctx , orderID , "[email protected] " , htmlContent )
Job Queue Integration
With job processors like Bull, Celery, or Sidekiq:
Python (Celery)
Node.js (Bull)
Ruby (Sidekiq)
from celery import Celery
from ark import Ark
app = Celery( 'tasks' )
client = Ark()
@app.task ( bind = True , max_retries = 5 , default_retry_delay = 60 )
def send_order_confirmation ( self , order_id , customer_email , order_details ):
try :
# Idempotency key ensures exactly-once even if job retries
return client.emails.send(
from_ = "[email protected] " ,
to = [customer_email],
subject = f "Order # { order_id } Confirmed" ,
html = render_order_email(order_details),
idempotency_key = f "order-confirmation- { order_id } "
)
except Exception as e:
raise self .retry( exc = e)
# Queue the job - retries are safe with idempotency
send_order_confirmation.delay( "12345" , "[email protected] " , order_details)
import Ark from 'ark' ;
import Queue from 'bull' ;
const client = new Ark ();
const orderQueue = new Queue ( 'orders' );
orderQueue . process ( 'send-confirmation' , async ( job ) => {
const { orderId , customerEmail , orderDetails } = job . data ;
// Idempotency key ensures exactly-once even if job retries
return client . emails . send ({
from: '[email protected] ' ,
to: [ customerEmail ],
subject: `Order # ${ orderId } Confirmed` ,
html: renderOrderEmail ( orderDetails ),
}, {
idempotencyKey: `order-confirmation- ${ orderId } ` ,
});
});
// Queue the job - retries are safe with idempotency
await orderQueue . add ( 'send-confirmation' , {
orderId: '12345' ,
customerEmail: '[email protected] ' ,
orderDetails: { /* ... */ },
}, {
attempts: 5 ,
backoff: { type: 'exponential' , delay: 1000 },
});
require "ark_email"
require "sidekiq"
class OrderConfirmationWorker
include Sidekiq :: Worker
sidekiq_options retry: 5
def perform ( order_id , customer_email , order_details )
client = ArkEmail :: Client . new
# Idempotency key ensures exactly-once even if job retries
client. emails . send_ (
from: "[email protected] " ,
to: [customer_email],
subject: "Order # #{ order_id } Confirmed" ,
html: render_order_email (order_details),
idempotency_key: "order-confirmation- #{ order_id } "
)
end
end
# Queue the job - retries are safe with idempotency
OrderConfirmationWorker . perform_async ( "12345" , "[email protected] " , order_details)
Batch Operations
Use compound keys for batch items:
def send_batch_with_idempotency ( emails , batch_id ):
return client.emails.send_batch(
emails = emails,
idempotency_key = f "batch- { batch_id } "
)
# If retry needed, same batch_id returns original response
result = send_batch_with_idempotency(emails, "weekly-digest-2024-01-15" )
async function sendBatchWithIdempotency ( emails : Email [], batchId : string ) {
return client . emails . sendBatch ({
emails ,
}, {
idempotencyKey: `batch- ${ batchId } ` ,
});
}
// If retry needed, same batchId returns original response
const result = await sendBatchWithIdempotency ( emails , 'weekly-digest-2024-01-15' );
def send_batch_with_idempotency ( emails , batch_id )
client. emails . send_batch (
emails: emails,
idempotency_key: "batch- #{ batch_id } "
)
end
# If retry needed, same batch_id returns original response
result = send_batch_with_idempotency (emails, "weekly-digest-2024-01-15" )
func sendBatchWithIdempotency ( ctx context . Context , emails [] ark . BatchEmail , batchID string ) ( * ark . BatchResult , error ) {
return client . Emails . SendBatch ( ctx , ark . EmailSendBatchParams {
Emails : emails ,
}, option . WithIdempotencyKey ( fmt . Sprintf ( "batch- %s " , batchID )))
}
// If retry needed, same batchID returns original response
result , _ := sendBatchWithIdempotency ( ctx , emails , "weekly-digest-2024-01-15" )
Best Practices
Keys like order-123-confirmation are more meaningful than random UUIDs.
password-reset-user123-1705312800 prevents conflicts between different email types.
Always use for transactional emails
Order confirmations, receipts, and password resets should never be duplicated.
Store the key with your record
Save the idempotency key in your database for debugging and audit trails.
Checking Idempotency Status
The response includes idempotency information:
{
"success" : true ,
"data" : {
"id" : "msg_abc123" ,
"status" : "pending"
},
"meta" : {
"requestId" : "req_xyz789" ,
"idempotent" : true , // true if this was a cached response
"originalRequestId" : "req_original123" // original request that created it
}
}
Common Pitfalls
Don’t use timestamps alone. If your clock drifts or requests are fast, you might get collisions or miss duplicates.
Don’t reuse keys for different content. The cached response is returned regardless of the request body.
// WRONG: Same key, different content
await sendEmail ({ to: '[email protected] ' , ... }, 'key-123' );
await sendEmail ({ to: '[email protected] ' , ... }, 'key-123' ); // Returns first response!
// RIGHT: Different keys for different emails
await sendEmail ({ to: '[email protected] ' , ... }, 'welcome-user-a' );
await sendEmail ({ to: '[email protected] ' , ... }, 'welcome-user-b' );
API Reference See idempotency in action