Idempotency
Design AI agent workflows that are safe to retry without causing duplicate actions
Idempotency
Idempotency means that performing the same operation multiple times produces the same result as performing it once. For AI agents that may retry failed requests, lose network connectivity, or process the same event twice, idempotent design prevents duplicate emails, duplicate inboxes, and data corruption.
Why Idempotency Matters for AI Agents
AI agents operate autonomously and frequently encounter transient failures. Without idempotent design:
- A network timeout during inbox creation could result in duplicate inboxes when the agent retries
- A webhook delivery replayed due to at-least-once delivery could trigger the same email reply twice
- A crashed agent restarting could re-process events it already handled
AgentPost provides built-in idempotency patterns and tools to make your agent workflows retry-safe.
The client_id Pattern
AgentPost supports a client_id field on inbox and domain creation. When provided, the API guarantees that only one resource is created for a given client_id within your organization -- subsequent requests with the same client_id return the existing resource instead of creating a duplicate.
import AgentPost from '@agentpost/sdk';
const client = new AgentPost({ apiKey: 'ap_sk_live_your_key_here' });
// This is safe to call multiple times
// The second call returns the existing inbox instead of creating a duplicate
const inbox = await client.inboxes.create({
email: 'support@agent-post.dev',
name: 'Customer Support',
client_id: 'support-inbox-v1', // Idempotency key
});
// Retry the same call after a network error -- no duplicate created
const sameInbox = await client.inboxes.create({
email: 'support@agent-post.dev',
name: 'Customer Support',
client_id: 'support-inbox-v1',
});
console.log(inbox.id === sameInbox.id); // truefrom agentpost import AgentPost
client = AgentPost(api_key="ap_sk_live_your_key_here")
# This is safe to call multiple times
inbox = client.inboxes.create(
email="support@agent-post.dev",
name="Customer Support",
client_id="support-inbox-v1", # Idempotency key
)
# Retry the same call after a network error -- no duplicate created
same_inbox = client.inboxes.create(
email="support@agent-post.dev",
name="Customer Support",
client_id="support-inbox-v1",
)
assert inbox.id == same_inbox.id # True# First call creates the inbox
curl -X POST https://api.agent-post.dev/api/v1/inboxes \
-H "Authorization: Bearer ap_sk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"email": "support@agent-post.dev",
"name": "Customer Support",
"client_id": "support-inbox-v1"
}'
# Second call returns the existing inbox (HTTP 200, not 201)
curl -X POST https://api.agent-post.dev/api/v1/inboxes \
-H "Authorization: Bearer ap_sk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"email": "support@agent-post.dev",
"name": "Customer Support",
"client_id": "support-inbox-v1"
}'Choosing client_id Values
Your client_id should be deterministic and meaningful:
| Pattern | Example | Use Case |
|---|---|---|
| Purpose-based | support-inbox-v1 | One inbox per purpose |
| Customer-scoped | customer-acmeco-inbox | One inbox per customer |
| Agent-scoped | agent-billing-bot-inbox | One inbox per agent |
| Environment + purpose | prod-support-inbox | Environment-aware |
Avoid random UUIDs as client_id -- they defeat the purpose of idempotency since each retry generates a new ID.
Handling Duplicate Webhook Deliveries
AgentPost provides at-least-once delivery for webhooks. Your webhook handler should deduplicate events using the event id:
// In-memory deduplication (use Redis for production)
const processedEvents = new Set<string>();
app.post('/webhooks/agentpost', async (c) => {
const event = JSON.parse(await c.req.text());
// Skip duplicate events
if (processedEvents.has(event.id)) {
return c.json({ received: true, duplicate: true });
}
processedEvents.add(event.id);
// Process the event
switch (event.type) {
case 'message.received':
await processIncomingEmail(event.data);
break;
}
return c.json({ received: true });
});# In-memory deduplication (use Redis for production)
processed_events = set()
@app.route("/webhooks/agentpost", methods=["POST"])
def handle_webhook():
event = request.get_json()
# Skip duplicate events
if event["id"] in processed_events:
return jsonify({"received": True, "duplicate": True})
processed_events.add(event["id"])
# Process the event
if event["type"] == "message.received":
process_incoming_email(event["data"])
return jsonify({"received": True})# Check if an event was already processed using a database query
# before processing it:
#
# SELECT 1 FROM processed_events WHERE event_id = 'evt_01JQ8X5K';
#
# If found, skip. If not found, process and insert:
# INSERT INTO processed_events (event_id, processed_at) VALUES ('evt_01JQ8X5K', NOW());Production Deduplication Strategies
For production systems, use a persistent store for event tracking:
| Strategy | Pros | Cons |
|---|---|---|
| Redis SET | Fast, automatic TTL expiry | Lost on restart without persistence |
| Database table | Durable, queryable | Adds write latency per event |
| Bloom filter | Memory-efficient for high volumes | Small false positive rate |
A Redis SET with 24-hour TTL is typically the best balance of performance and reliability:
import { Redis } from 'ioredis';
const redis = new Redis();
const EVENT_TTL_SECONDS = 86400; // 24 hours
async function isDuplicate(eventId: string): Promise<boolean> {
const result = await redis.set(
`webhook:${eventId}`,
'1',
'EX',
EVENT_TTL_SECONDS,
'NX', // Only set if not exists
);
return result === null; // null = key already existed = duplicate
}Idempotent Email Sending
Email sending is inherently non-idempotent -- each API call sends a new email. To prevent duplicate sends, track sent messages on your side:
async function replyToTicket(ticketId: string, inboxId: string, messageId: string) {
// Check if we already replied to this ticket
const alreadyReplied = await db.query(
'SELECT 1 FROM agent_replies WHERE ticket_id = $1',
[ticketId],
);
if (alreadyReplied.rows.length > 0) {
console.log(`Already replied to ticket ${ticketId}, skipping`);
return;
}
// Send the reply
const reply = await client.messages.reply(inboxId, messageId, {
text_body: 'Thanks for reaching out! We are looking into this.',
});
// Record that we replied
await db.query(
'INSERT INTO agent_replies (ticket_id, message_id, replied_at) VALUES ($1, $2, NOW())',
[ticketId, reply.id],
);
}def reply_to_ticket(ticket_id: str, inbox_id: str, message_id: str):
# Check if we already replied to this ticket
cursor.execute(
"SELECT 1 FROM agent_replies WHERE ticket_id = %s",
(ticket_id,),
)
if cursor.fetchone():
print(f"Already replied to ticket {ticket_id}, skipping")
return
# Send the reply
reply = client.messages.reply(
inbox_id,
message_id,
text_body="Thanks for reaching out! We are looking into this.",
)
# Record that we replied
cursor.execute(
"INSERT INTO agent_replies (ticket_id, message_id, replied_at) VALUES (%s, %s, NOW())",
(ticket_id, reply.id),
)
db.commit()# 1. Check if reply was already sent
ALREADY_REPLIED=$(psql -t -c "SELECT 1 FROM agent_replies WHERE ticket_id='TICKET-123'")
if [ -z "$ALREADY_REPLIED" ]; then
# 2. Send the reply
curl -X POST "https://api.agent-post.dev/api/v1/inboxes/inb_01JQ8X/messages/msg_01JQ8X/reply" \
-H "Authorization: Bearer ap_sk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"text_body": "Thanks for reaching out!"}'
# 3. Record the reply
psql -c "INSERT INTO agent_replies (ticket_id, replied_at) VALUES ('TICKET-123', NOW())"
fiSummary
| Pattern | Built-in | Your Responsibility |
|---|---|---|
| Inbox creation | client_id | Choose deterministic IDs |
| Domain creation | client_id | Choose deterministic IDs |
| Webhook deduplication | Event id field | Track processed event IDs |
| Email sending | None | Track sent messages |
| Agent retries | None | Design idempotent workflows |