Agent-Native Payments: Idempotency, Webhooks & Settlement
The Problem: Why Your AI Agent Keeps Creating Duplicate Payments
Imagine you've built an AI chatbot that sells online courses. A customer asks to pay for a Python masterclass. Your agent calls a payment function, the network hiccups, and the agent retries. Now there are three identical payment requests in the system—each one charging the customer separately. By the time the bank settles, you've got a compliance nightmare, angry customers, and refund chaos.
This is the idempotency problem: in distributed systems, retries and network failures can trigger unintended duplicates. Traditional payment gateways solve this with idempotency keys. AgentPay VN brings that battle-tested pattern directly into the AI agent toolkit—letting you build payment flows that are safe by default, never holding customer funds, and settling instantly to your bank account via VietQR.
In this tutorial, you'll learn to architect agent-native payments that handle retries gracefully, confirm settlements reliably, and scale without fear.
How AgentPay VN Prevents Duplicate Charges
The Idempotency Key Pattern
AgentPay VN uses idempotency keys—unique identifiers you provide when creating a payment request. If your agent retries the same request with the same key, the system returns the existing payment instead of creating a duplicate.
Here's the core principle:
Idempotency Key = Hash(agent_id + customer_id + course_id + timestamp_bucket)
Why this matters for AI agents:
- Automatic retries work safely: LLMs and orchestrators often retry on network errors. An idempotency key means retries won't multiply charges.
- No manual deduplication: The SDK handles it; you just pass the key.
- User-friendly: Customers see one payment, not three pending transactions.
Real-World Scenario: E-Learning Bot
You have a Discord bot that sells Solidity courses. When a user types "buy course", the agent:
- Generates an idempotency key from
user_id + course_id + current_hour. - Calls
create_payment_request()with that key. - Sends the checkout URL.
- Waits for webhook confirmation.
If the webhook delivery fails and the bot retries, the idempotency key ensures only one payment record exists.
Setting Up Idempotent Payment Requests
Installation and Imports
First, install the SDK:
pip install agentpay-vn
Then initialize your agent:
from agentpay_vn import AgentPayClient
import hashlib
import json
from datetime import datetime, timedelta
# Initialize the client (uses AGENTPAY_API_KEY from environment)
client = AgentPayClient()
# Helper function to generate idempotency keys
def generate_idempotency_key(agent_id: str, customer_id: str, product_id: str) -> str:
"""
Create a stable, scoped idempotency key.
Using hourly bucket ensures the same customer buying the same product
within one hour generates the same key (safe for retries).
"""
now = datetime.utcnow()
hour_bucket = now.replace(minute=0, second=0, microsecond=0).isoformat()
key_material = f"{agent_id}:{customer_id}:{product_id}:{hour_bucket}"
return hashlib.sha256(key_material.encode()).hexdigest()[:32]
Creating a Payment with Idempotency
async def sell_course(agent_id: str, customer_id: str, course_id: str):
"""
Create an idempotent payment request for a course purchase.
Multiple calls with the same customer/course/hour will return the same payment.
"""
# Step 1: Generate stable idempotency key
idempotency_key = generate_idempotency_key(
agent_id=agent_id,
customer_id=customer_id,
product_id=course_id
)
# Step 2: Define payment details
payment_data = {
"amount": 299000, # VND (Vietnamese Dong)
"description": "Solidity Masterclass",
"customer_email": "student@example.com",
"customer_name": "Nguyen Van A",
"metadata": {
"course_id": course_id,
"agent_id": agent_id,
"utm_source": "discord_bot"
}
}
# Step 3: Create payment request with idempotency key
# If this call is retried, AgentPay VN returns the existing payment
payment = await client.create_payment_request(
amount=payment_data["amount"],
description=payment_data["description"],
customer_email=payment_data["customer_email"],
customer_name=payment_data["customer_name"],
metadata=payment_data["metadata"],
idempotency_key=idempotency_key # THE MAGIC LINE
)
# Step 4: Extract the checkout URL
checkout_url = payment.checkout_url
payment_id = payment.id
print(f"Payment created: {payment_id}")
print(f"Checkout URL: {checkout_url}")
return {
"payment_id": payment_id,
"checkout_url": checkout_url,
"idempotency_key": idempotency_key
}
Line-by-line explanation:
- Line 23: The
idempotency_keyis derived from the agent, customer, and product IDs, plus an hourly timestamp. Same inputs = same key. - Line 40: We pass
idempotency_keyto the SDK. AgentPay VN's backend checks if this key exists; if yes, returns the original payment. - Line 49: The response includes a
checkout_urlpointing directly to the merchant's bank account via VietQR—AgentPay VN never holds funds.
Webhooks and Settlement Confirmation
The Settlement Flow
AgentPay VN uses a bank feed to confirm when money actually lands in your account. Here's the flow:
- Customer pays → VietQR transfers funds to your bank immediately.
- Bank feed → AgentPay VN polls your bank (via API) and marks the payment as settled.
- Webhook → AgentPay sends a POST to your webhook URL with settlement confirmation.
- Your agent acts → Unlock the course, send an API key, email the receipt, etc.
Unlike traditional gateways that settle T+1 or T+3, VietQR settles in minutes because it's a direct bank-to-bank transfer.
Configuring Your Webhook Endpoint
Set your webhook URL in the AgentPay dashboard (or environment variable AGENTPAY_WEBHOOK_URL). Your endpoint should:
- Verify the webhook signature (to prevent replay attacks).
- Check the payment status.
- Idempotently handle the event (process only once, even if called twice).
from fastapi import FastAPI, Request
import hmac
import hashlib
app = FastAPI()
@app.post("/webhooks/agentpay")
async def handle_payment_settlement(request: Request):
"""
Webhook handler for payment settlement confirmation.
Called by AgentPay VN when a payment is marked as settled in the bank.
"""
# Step 1: Verify webhook signature
signature = request.headers.get("X-AgentPay-Signature")
secret = "your_webhook_secret" # Store securely in env vars
body = await request.body()
expected_sig = hmac.new(
secret.encode(),
body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_sig):
return {"status": "unauthorized"}, 401
# Step 2: Parse the webhook payload
payload = await request.json()
payment_id = payload.get("payment_id")
status = payload.get("status") # e.g., "settled", "pending", "failed"
amount = payload.get("amount")
metadata = payload.get("metadata", {})
# Step 3: Idempotently process the settlement
# (Use payment_id as a unique key in your DB to prevent duplicate processing)
if status == "settled":
course_id = metadata.get("course_id")
customer_email = payload.get("customer_email")
# Check if we've already processed this payment
existing = await db.settlements.find_one({"payment_id": payment_id})
if existing:
return {"status": "already_processed"}, 200
# Now unlock the course and send credentials
await unlock_course(customer_email, course_id)
await send_access_email(customer_email, course_id)
# Record that we processed it
await db.settlements.insert_one({
"payment_id": payment_id,
"course_id": course_id,
"amount": amount,
"processed_at": datetime.utcnow()
})
return {"status": "processed"}, 200
elif status == "failed":
# Handle failed payments (refunds, retries, etc.)
return {"status": "payment_failed"}, 200
return {"status": "unknown"}, 400
Using AgentPay VN with Claude via MCP
The MCP (Model Context Protocol) server exposes AgentPay functions as tools for Claude:
{
"name": "agentpay-mcp",
"version": "1.0.0",
"tools": [
{
"name": "create_payment_request",
"description": "Create a VietQR payment request with idempotency key",
"inputSchema": {
"type": "object",
"properties": {
"amount": { "type": "integer", "description": "Amount in VND" },
"description": { "type": "string" },
"customer_email": { "type": "string" },
"customer_name": { "type": "string" },
"idempotency_key": { "type": "string", "description": "Unique key to prevent duplicates" },
"metadata": { "type": "object" }
},
"required": ["amount", "description", "idempotency_key"]
}
},
{
"name": "get_payment_status",
"description": "Poll payment status (settled, pending, failed)",
"inputSchema": {
"type": "object",
"properties": {
"payment_id": { "type": "string" }
},
"required": ["payment_id"]
}
},
{
"name": "await_settlement",
"description": "Wait for payment to settle with timeout",
"inputSchema": {
"type": "object",
"properties": {
"payment_id": { "type": "string" },
"timeout_seconds": { "type": "integer", "default": 300 }
},
"required": ["payment_id"]
}
}
]
}
To use it with Claude, install and configure the MCP server:
pip install agentpay-mcp
Then add to your Claude client configuration:
from anthropic import Anthropic
client = Anthropic(
mcp_servers=[
{"name": "agentpay", "command": "agentpay-mcp"}
]
)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
tools=[...], # MCP tools auto-loaded
messages=[
{
"role": "user",
"content": "I want to sell a course for 299,000 VND to john@example.com"
}
]
)
Claude can now call create_payment_request directly, with idempotency keys generated automatically.
Real-World Walkthrough: Coffee Shop Loyalty Program
Let's build a complete example: a café chatbot that sells pre-paid loyalty cards.
The scenario: - Customers message a Facebook bot: "I want a 500k VND loyalty card." - Bot creates a payment request with idempotency key. - Customer scans QR, pays via their bank app. - Settlement webhook confirms receipt. - Bot sends a unique loyalty card code via SMS/email.
import asyncio
from agentpay_vn import AgentPayClient
from datetime import datetime
import hashlib
class CaféLoyaltyBot:
def __init__(self):
self.client = AgentPayClient()
self.loyalty_cards = {} # payment_id -> card_code
def gen_card_code(self, payment_id: str) -> str:
"""Generate a unique loyalty card code."""
return hashlib.md5(f"{payment_id}:cafe:loyaltyv1".encode()).hexdigest()[:12].upper()
async def sell_loyalty_card(self, customer_phone: str, amount_vnd: int = 500000):
"""
Main flow: Create payment, return checkout URL, wait for settlement.
"""
# Generate idempotency key (stable for 1 hour)
now = datetime.utcnow()
hour_bucket = now.replace(minute=0, second=0, microsecond=0).isoformat()
idem_key = hashlib.sha256(
f"cafe:{customer_phone}:{amount_vnd}:{hour_bucket}".encode()
).hexdigest()[:32]
# Create payment
print(f"[Café Bot] Creating {amount_vnd} VND payment for {customer_phone}...")
payment = await self.client.create_payment_request(
amount=amount_vnd,
description="Loyalty Card (500k VND balance)",
customer_name=customer_phone,
customer_email="loyalty@cafe.local",
metadata={"phone": customer_phone, "type": "loyalty_card"},
idempotency_key=idem_key
)
payment_id = payment.id
checkout_url = payment.checkout_url
print(f"[Café Bot] Checkout URL: {checkout_url}")
print(f"[Café Bot] Waiting for settlement (max 5 minutes)...")
# Wait for settlement (blocks until settled or timeout)
try:
settled_payment = await self.client.await_settlement(
payment_id=payment_id,
timeout_seconds=300
)
if settled_payment.status == "settled":
# Payment confirmed! Generate and send loyalty card
card_code = self.gen_card_code(payment_id)
self.loyalty_cards[payment_id] = card_code
print(f"[Café Bot] ✓ Payment settled!")
print(f"[Café Bot] Loyalty card: {card_code}")
print(f"[Café Bot] SMS sent to {customer_phone}: 'Your café loyalty card: {card_code}'")
return {
"status": "success",
"payment_id": payment_id,
"card_code": card_code,
"message": f"Your loyalty card is ready: {card_code}"
}
except asyncio.TimeoutError:
print(f"[Café Bot] ✗ Payment not settled within 5 minutes.")
return {
"status": "timeout",
"payment_id": payment_id,
"checkout_url": checkout_url,
"message": "Please complete payment. Check status later."
}
async def check_status(self, payment_id: str):
"""Polling endpoint for customer to check their loyalty card."""
status = await self.client.get_payment_status(payment_id)
if status.status == "settled" and payment_id in self.loyalty_cards:
return {"status": "ready", "card_code": self.loyalty_cards[payment_id]}
elif status.status == "settled":
# Generate it now (in case bot restarted)
card_code = self.gen_card_code(payment_id)
self.loyalty_cards[payment_id] = card_code
return {"status": "ready", "card_code": card_code}
else:
return {"status": status.status, "message": "Still processing..."}
# Usage
bot = CaféLoyaltyBot()
await bot.sell_loyalty_card(customer_phone="+84912345678", amount_vnd=500000)
Why this works:
- Idempotency key scoped to phone + hour means a customer can't accidentally create two payments by requesting twice.
await_settlement()blocks until the bank confirms the transfer, eliminating race conditions.- No holding funds: Money goes straight to the café's bank account; AgentPay VN is just a notification layer.
- Webhook fallback: Even if
await_settlement()times out, the webhook handler will eventually trigger and unlock the card.
Do's and Don'ts
| Do | Don't |
|---|---|
| ✅ Use stable idempotency keys (based on user + product + time bucket) | ❌ Generate random keys for every call—defeats the purpose |
| ✅ Log payment_id and idempotency_key for debugging | ❌ Assume retries are rare; plan for network failures |
| ✅ Verify webhook signatures before processing | ❌ Trust webhook content without validation |
✅ Use await_settlement() with a timeout; have a fallback |
❌ Hang forever waiting for settlement |
| ✅ Store settlement confirmations in your DB to idempotently handle duplicate webhooks | ❌ Process the same webhook twice by accident |
| ✅ Test with real QR payments in staging first | ❌ Assume prod behavior matches dev |
FAQ
Q: What happens if my webhook endpoint goes down?
A: AgentPay VN retries the webhook for 24 hours with exponential backoff. In the meantime, your agent can poll get_payment_status() to check if a payment settled. Idempotent webhook handlers ensure duplicate retries don't cause harm.
Q: How do I handle payments that fail?
A: The webhook will include status: "failed". Your agent should notify the customer and optionally re-offer the payment (with a fresh idempotency key for a new hour bucket if they retry).
Q: Can I refund a settled payment? A: AgentPay VN itself doesn't hold funds, so refunds are processed through your bank's APIs. The payment_id and metadata in AgentPay help you reconcile; the actual refund happens outside AgentPay.
Q: Is there a minimum/maximum amount? A: VietQR supports amounts from 1,000 VND to 100,000,000 VND. AgentPay VN inherits these limits. Check the docs for current policies.
Key Takeaways
- Idempotency keys prevent duplicate charges when agents retry (network failures, LLM retries, etc.). Use stable keys derived from user + product + time bucket.
- Bank feed settlement is instant (minutes, not days) because VietQR transfers directly to your account. AgentPay VN polls the bank and webhooks your agent.
await_settlement()blocks until confirmed; always use a timeout and fallback to polling.- Webhooks are fire-and-forget by design—handle duplicates gracefully using payment_id as a unique key in your database.
- AgentPay VN never holds customer funds; money flows directly from customer → bank. This is both a compliance win and a performance win.
- MCP integration lets Claude and other agents call payment functions as native tools, generating idempotency keys and awaiting settlement automatically.
Getting Started
Install AgentPay VN now:
pip install agentpay-vn
Then install the MCP server for Claude integration:
pip install agentpay-mcp
Read the full documentation and explore examples at https://agentpay.servicesai.vn/v1/docs and browse the source code on GitHub.
Start building safer, faster payment flows for your AI agents today.