Agent-native payments: idempotency, webhooks, settlement
The Silent Killer: Duplicate Charges in AI Payment Systems
Imagine your AI assistant helps a customer order coffee. The agent calls create_payment_request(). The customer scans the QR code, pays via their bank app. But the webhook network hiccup causes a retry—and suddenly the agent creates another payment request without knowing the first one settled. Now the customer is charged twice, your chatbot looks unreliable, and support tickets flood in.
This isn't hypothetical. Every payment system that touches the internet faces idempotency landmines. When your AI agent orchestrates money movement—even if it never holds the funds—one missed detail explodes into trust loss, regulatory headaches, and churn.
AgentPay VN solves this by making idempotent payments the default, coupling it with webhook-driven settlement confirmation, so your agent always knows the truth about who paid and when.
Why AI Agents Need Bulletproof Payment Idempotency
Unlike a human clicking a "Pay" button once, AI agents operate in loops. They retry on network failures. They may process the same user request twice if state is unclear. They run in parallel across multiple conversations.
The stakes are high:
- Bank reconciliation nightmare: Your merchant account shows 5 payments but your database records 3. Finance teams spend hours debugging.
- Agent hallucination risk: An LLM-based agent might misinterpret a timeout and resubmit the payment, thinking the first one failed.
- Regulatory exposure: Vietnam's payment regulations require clear audit trails. Duplicate charges violate reconciliation expectations.
- Customer refund spiral: Each duplicate charge triggers a support case. Refunding manually is slow and error-prone.
AgentPay VN's idempotency key system ensures: same request parameters + same idempotency key = guaranteed single charge, even if called 100 times.
How Idempotency Keys Work in AgentPay VN
An idempotency key is a unique identifier your agent attaches to a payment request. If the request is retried (network timeout, server crash, agent re-entry), AgentPay checks if that key already exists. If yes, it returns the cached result instead of creating a new charge.
The flow:
- Agent generates or receives a stable, unique key (e.g.,
order_12345_attempt_1,user_42_invoice_789). - Agent calls
create_payment_request()with the idempotency key. - AgentPay VN stores: key → payment_id → checkout_url.
- If agent retries with the same key, AgentPay returns the same payment_id (no new charge).
- Agent sends the customer the checkout_url.
- Customer pays once. Bank settlement confirms via webhook.
- Agent records settlement in its state.
Why this beats the alternative: If you try to manually check "was this payment already created?" in your agent code, you hit race conditions (two agent instances check simultaneously, both think it's new, both create charges).
Python: Idempotent Payment Request Creation
from agentpay_vn import AgentPayClient, CreatePaymentRequestPayload
import uuid
from datetime import datetime
# Initialize the AgentPay client
client = AgentPayClient(
api_key="your-api-key-here",
base_url="https://api.agentpay.servicesai.vn"
)
# Scenario: E-learning bot sells a Python course
user_id = "user_42"
course_id = "python_201"
course_price_vnd = 299000 # ~$12 USD
# Generate stable idempotency key: user_id + course_id + timestamp date
# Using date ensures one purchase per course per day max
today = datetime.now().strftime("%Y-%m-%d")
idempotency_key = f"{user_id}_{course_id}_{today}"
# Create payment request with idempotency
payload = CreatePaymentRequestPayload(
amount=course_price_vnd,
description=f"Python 201 Course - {user_id}",
buyer_name="Nguyen Van A",
buyer_email="buyer@example.com",
order_id=f"{course_id}_{user_id}_{today}", # Unique, stable order reference
)
try:
response = client.create_payment_request(
payload=payload,
idempotency_key=idempotency_key # THE SAFETY NET
)
# Whether this is the first call or a retry, we get the same checkout_url
checkout_url = response.checkout_url
payment_id = response.payment_id
print(f"✓ Payment request created")
print(f" Payment ID: {payment_id}")
print(f" QR Checkout: {checkout_url}")
print(f" Idempotency Key: {idempotency_key}")
# Agent now sends this URL to customer (Telegram, email, web, etc.)
# If network fails after this line, and agent retries, same URL is returned
except Exception as e:
print(f"✗ Error: {e}")
# Agent can safely retry; the idempotency key guarantees no double charge
Line-by-line explanation:
- Lines 6–9: Initialize client with API credentials.
- Lines 12–13: Define stable user/course identifiers.
- Line 16: Calculate idempotency key using date; same user + course on same day = same key.
- Lines 18–24: Build payment payload (amount in VND, description, buyer details).
- Lines 26–33: Call
create_payment_request()withidempotency_keyparam—this is the magic line. - Lines 35–43: Extract checkout_url and payment_id; agent sends URL to customer.
Critical insight: The idempotency key is not secret; it's a stable fingerprint. Your agent should derive it from business logic (user ID, order ID, date) so retries automatically use the same key.
Webhooks: How Your Agent Knows Payment Settled
VietQR payments settle into the merchant's bank account within minutes (not hours). But your agent can't poll the bank—it needs real-time confirmation.
AgentPay VN sends a webhook POST to your specified endpoint when:
- Payment confirmed: Customer completed the bank transfer; money is in flight.
- Settlement received: Bank confirmed arrival in merchant account.
Your agent (or backend service) listens to these webhooks and updates order state:
from agentpay_vn import SettlementWebhookPayload
import json
from typing import Dict
# Simulated webhook handler (would be in your FastAPI/Flask app)
def handle_agentpay_webhook(raw_body: str, signature_header: str) -> Dict:
"""
Endpoint: POST /webhooks/agentpay
AgentPay VN sends settlement notifications here.
"""
# Verify webhook signature (prevents spoofing)
# client.verify_webhook_signature(raw_body, signature_header)
# (See AgentPay VN docs for verification implementation)
payload = json.loads(raw_body)
# Webhook payload structure:
# {
# "event": "settlement.confirmed" | "payment.confirmed",
# "payment_id": "pay_xyz",
# "order_id": "python_201_user_42_2024-01-15",
# "amount": 299000,
# "status": "settled" | "confirmed",
# "bank_transaction_id": "VCB20240115123456",
# "timestamp": 1705318496
# }
event_type = payload["event"]
payment_id = payload["payment_id"]
order_id = payload["order_id"]
status = payload["status"]
bank_txn_id = payload.get("bank_transaction_id")
print(f"📬 Webhook received: {event_type} for {order_id}")
if event_type == "settlement.confirmed":
# Money has arrived in merchant's bank account
print(f"💰 Settlement confirmed! Bank txn: {bank_txn_id}")
# Update your agent's state/database
update_order_state(
order_id=order_id,
status="paid",
payment_id=payment_id,
bank_reference=bank_txn_id
)
# Trigger downstream actions
# - Send customer the course access link
# - Add student to course enrollment
# - Send confirmation email
fulfill_course_access(order_id)
return {"status": "received", "code": 200}
elif event_type == "payment.confirmed":
# Customer completed the transfer (but not yet in bank account)
print(f"✓ Payment confirmed; awaiting settlement")
update_order_state(order_id=order_id, status="confirming")
return {"status": "received", "code": 200}
else:
print(f"⚠ Unknown event: {event_type}")
return {"status": "ignored", "code": 200}
def update_order_state(order_id: str, status: str, payment_id: str = None, bank_reference: str = None):
"""Update order in database (pseudocode)"""
# db.orders.update_one({"order_id": order_id}, {"$set": {
# "payment_status": status,
# "payment_id": payment_id,
# "bank_reference": bank_reference,
# "updated_at": datetime.now()
# }})
pass
def fulfill_course_access(order_id: str):
"""Grant course access when payment settles"""
# Extract user_id from order_id (e.g., "python_201_user_42_2024-01-15" → "user_42")
# db.enrollments.insert_one({"user_id": user_id, "course_id": "python_201", "access_until": ...})
# Send email with course link
pass
Key points:
- Webhook delivery guarantee: AgentPay VN retries failed webhooks (exponential backoff, up to 24 hours).
- Idempotent webhook handler: Your code should be safe if the same webhook is delivered twice (same
payment_id+event= skip duplicate). - Settlement is the truth: Don't trust "payment.confirmed" alone; wait for "settlement.confirmed" before fulfilling (money is guaranteed).
- Never hold funds: AgentPay VN streams money directly to your merchant bank account; your server is just an orchestrator.
Real-World Walkthrough: AI Café Ordering Bot
Scenario: A Telegram bot helps customers order coffee. The bot is powered by Claude via MCP, uses AgentPay VN for payment.
Flow:
- User: "I want 1 cappuccino and 1 croissant."
- Bot (Claude agent): Calculates total (95,000 VND), calls
create_payment_request()with idempotency key =user_123_order_2024_01_15_001. - AgentPay VN returns checkout_url pointing to VietQR.
- Bot sends QR code (inline image) to Telegram.
- Customer opens their bank app, scans QR, approves transfer (safe—it goes directly to café's bank account, not AgentPay).
- Bank processes transfer within 30 seconds.
- AgentPay VN webhook hits café's server:
{"event": "settlement.confirmed", "order_id": "...", "bank_txn_id": "VCB..."}. - Café backend processes webhook: - Updates order status to "paid". - Sends to kitchen: "Cappuccino + Croissant for Nguyen". - Notifies customer: "Order confirmed! Ready in 10 min. Pick up at counter."
- Idempotency guarantee: If the bot crashes after step 2 and restarts, it retries with the same key—gets the same checkout_url, no double charge.
MCP Configuration for Claude
To use AgentPay VN with Claude Desktop or MCP clients, configure the MCP server:
{
"mcpServers": {
"agentpay-vn": {
"command": "agentpay-mcp",
"args": [],
"env": {
"AGENTPAY_API_KEY": "your-api-key-here",
"AGENTPAY_BASE_URL": "https://api.agentpay.servicesai.vn",
"AGENTPAY_WEBHOOK_SECRET": "your-webhook-secret-here"
}
}
}
}
Where to add this:
- Claude Desktop (macOS):
~/Library/Application Support/Claude/claude_desktop_config.json - Claude Desktop (Windows):
%APPDATA%/Claude/claude_desktop_config.json
Available MCP tools (Claude can call these natively):
create_payment_request(amount, description, order_id, idempotency_key)→ returns checkout_urlawait_settlement(payment_id, timeout_seconds)→ blocks until webhook confirms settlementget_payment_status(payment_id)→ instant status check
Idempotency vs. Polling: A Comparison
| Aspect | Idempotency Key | Polling (❌ Anti-pattern) |
|---|---|---|
| Duplicate prevention | Guaranteed; database-level deduplication | Not guaranteed; race conditions possible |
| Latency | Immediate response on retry | Adds delay; customer waits longer |
| Database load | Single write per request | Multiple queries per check |
| Agent complexity | Agent generates stable key; done | Agent must track state, implement backoff |
| Webhook reliability | Webhooks are bonus (not required) | Webhook must work; no polling fallback |
| Compliance | Clear audit trail (key → payment_id) | Confusing logs; hard to audit |
| Cost | Minimal API calls | Extra calls = extra cost |
Recommendation: Always use idempotency keys. Polling is a fallback only if webhooks fail for >1 hour.
Advanced Tips: Timeouts, Retries, and State
1. Set Realistic Timeouts
VietQR transfers settle in 30–120 seconds. Don't wait infinitely:
# Good: Wait max 3 minutes for settlement
from agentpay_vn import await_settlement
payment_id = "pay_abc123"
try:
settlement = await_settlement(payment_id, timeout_seconds=180)
print(f"Settled: {settlement.bank_transaction_id}")
except TimeoutError:
print("Settlement not confirmed yet; check webhook later")
# Agent should tell customer: "Payment pending; we'll confirm soon."
2. Persist Idempotency Keys
Store the key in your database alongside the order:
# Pseudocode: Save order + idempotency key
order_record = {
"order_id": "python_201_user_42_2024_01_15",
"user_id": "user_42",
"idempotency_key": "user_42_python_201_2024_01_15", # Save this!
"payment_id": "pay_xyz",
"status": "pending",
"created_at": datetime.now()
}
# db.orders.insert_one(order_record)
# On retry, look up the order and use the saved key
existing = db.orders.find_one({"order_id": "python_201_user_42_2024_01_15"})
if existing:
# Order exists; use saved key and payment_id
payment_id = existing["payment_id"]
idempotency_key = existing["idempotency_key"]
else:
# New order; generate key and call create_payment_request
idempotency_key = ...
3. Handle Webhook Deduplication
If the same webhook fires twice (network retry), your handler should be idempotent:
def handle_settlement_webhook(payload):
payment_id = payload["payment_id"]
# Check if we already processed this
existing_settlement = db.settlements.find_one({"payment_id": payment_id})
if existing_settlement:
print(f"Webhook already processed: {payment_id}")
return {"status": "already_processed", "code": 200}
# Process settlement for the first time
db.settlements.insert_one({"payment_id": payment_id, "processed_at": datetime.now()})
fulfill_order(...)
return {"status": "processed", "code": 200}
Do's and Don'ts
DO:
✓ Generate idempotency keys from business logic (order_id + date).
✓ Save idempotency keys in your database.
✓ Make webhook handlers idempotent (can safely be called twice).
✓ Wait for "settlement.confirmed" before fulfilling (not just "payment.confirmed").
✓ Log payment_id and bank_transaction_id for reconciliation.
✓ Set reasonable timeouts (180–300 seconds max).
DON'T:
✗ Generate random UUIDs as idempotency keys (retries won't match; duplication risk).
✗ Assume "payment.confirmed" means money is in your account (it isn't yet).
✗ Poll the API every second (wastes resources; use webhooks).
✗ Log customer bank details or full QR codes (privacy & security risk).
✗ Retry failed payment requests without idempotency keys.
✗ Ignore webhook verification signatures (open to spoofing).
FAQ
Q: What if the customer scans the QR but doesn't complete payment in 1 hour?
A: The payment request remains "pending" indefinitely. Your agent can prompt the customer to pay or cancel. After 24 hours, you may create a fresh payment request (new order, new idempotency key).
Q: Can I use the same idempotency key for two different customers?
A: No. The key must be unique per "business event." Use patterns like {user_id}_{order_id}_{date} so no two customers generate the same key.
Q: What happens if my webhook endpoint is down when settlement occurs?
A: AgentPay VN queues and retries the webhook up to 24 hours (exponential backoff). When your server comes back, you'll receive all pending webhooks. Your idempotent handler will process them safely.
Q: Do I need to implement webhook verification?
A: Yes. AgentPay VN signs each webhook with your webhook secret. Always verify the signature to prevent spoofed webhooks. See docs: https://agentpay.servicesai.vn/v1/docs.
Key Takeaways
- Idempotency keys are mandatory for AI agent payment systems; they prevent duplicate charges when retries happen.
- Derive idempotency keys from stable business logic (user ID, order ID, date), not random UUIDs.
- Webhooks are the real-time settlement signal; always listen for "settlement.confirmed" before fulfilling orders.
- Never poll the bank; use AgentPay VN's webhook delivery to learn payment status.
- AgentPay VN never holds money—QR codes point straight to the merchant's bank account, so your agent is an orchestrator, not a payment processor.
- Make webhook handlers idempotent; the same webhook may arrive twice, and your code must handle it safely.
- Persist payment_id and idempotency_key in your database; they're the audit trail for reconciliation.
- Set realistic settlement timeouts (180–300 seconds); VietQR transfers complete fast, but not instantly.
Getting Started
Ready to build agent-native payments?
-
Install the SDK:
bash pip install agentpay-vn -
Get your API key from https://agentpay.servicesai.vn/v1/docs (sign up as merchant).
-
Configure the MCP server in Claude Desktop (JSON above).
-
Read the full documentation: https://agentpay.servicesai.vn/v1/docs
-
Check out the GitHub repo for examples: https://github.com/phuocdu/agentpay-vn
-
Deploy with confidence: Use idempotency keys, listen to webhooks, and your AI agent will handle payments as safely as any production system.
Happy shipping! 🚀