Agent-Native Payments: Idempotency, Webhooks & Settlement
The Problem: Why Your AI Payment Agent Just Lost $500
It's 2 AM. Your AI agent is processing course enrollments. A customer clicks "enroll" — the agent creates a payment request, sends the checkout URL, and waits for settlement confirmation. The network hiccups. Your agent retries. And again. Now there are three payment requests, three QR codes, and the customer's bank has deducted the payment twice.
Welcome to the real world of agent-driven payments.
Unlike traditional monolithic payment systems, AI agents operate in hostile environments: flaky networks, concurrent requests, retry logic gone haywire, and webhook handlers that fire out of order. If you build payment flows without idempotency, webhook resilience, and settlement verification, you're playing Russian roulette with your customers' money — and your reputation.
AgentPay VN solves this by giving you a Python SDK + MCP server purpose-built for agent payments. But knowing the tools is only half the battle. You need to understand why these patterns matter and how to implement them correctly.
Let's dive in.
Understanding the Three Pillars: Idempotency, Webhooks & Settlement
Why Idempotency Matters in Agent Flows
Idempotency means that repeating the same operation produces the same result — no side effects, no duplicates. In payment systems, it's non-negotiable.
Consider a typical agent workflow:
User message → Agent processes request → Agent calls payment API → API returns response → Agent sends URL → User pays → Bank notifies → Agent confirms
Now add network failure at step 5. The agent's retry logic kicks in. If your create_payment_request endpoint isn't idempotent, you've just created duplicate requests. Two QRs. Two charges. One refund nightmare.
AgentPay VN handles this through idempotency keys. Every request carries a unique ID. If the same request comes twice with the same key, the server returns the cached response instead of creating a duplicate.
Webhooks: The Settlement Signal
Here's the critical truth: AgentPay VN never holds your money. The QR code points directly to the merchant's bank account. When payment settles, the merchant's bank notifies AgentPay via webhook. Your agent listens, confirms settlement, and fulfills the order.
Webhooks are asynchronous. They can arrive out of order, be retried, or fail silently. You must handle all three scenarios.
Settlement Verification: Trust but Verify
Don't just listen to webhooks. Also poll the payment status. This two-pronged approach (webhooks + polling) ensures you never miss a settlement or process a payment twice.
Building Your First Idempotent Payment Flow
Step 1: Install AgentPay VN
pip install agentpay-vn
This gives you the Python SDK. For Claude integration, you'll also need the MCP server:
pip install agentpay-mcp
Step 2: Create an Idempotent Payment Request
Here's the real code:
import uuid
from agentpay_vn import AgentPayVN
from datetime import datetime, timedelta
# Initialize the SDK
agent_pay = AgentPayVN(api_key="your_api_key_here")
# Generate an idempotency key from user + order data
def create_idempotent_request(user_id: str, order_id: str, amount: int, description: str):
# The idempotency key ensures that if we retry with the same inputs,
# we get the same payment request back (no duplicate)
idempotency_key = f"{user_id}:{order_id}:{amount}:{int(datetime.now().timestamp() / 3600)}"
# Create the payment request
payment = agent_pay.create_payment_request(
amount=amount, # in VND
description=description,
idempotency_key=idempotency_key,
return_url="https://yourapp.com/checkout/callback",
webhook_url="https://yourapp.com/webhooks/settlement",
expires_at=datetime.now() + timedelta(hours=1)
)
# payment now contains:
# - payment.id: unique payment ID
# - payment.checkout_url: the QR code URL
# - payment.viet_qr: merchant bank details for VietQR
return payment
# Usage in your agent
payment = create_idempotent_request(
user_id="user_42",
order_id="order_2024_001",
amount=500000, # 500k VND
description="Python Course Enrollment"
)
print(f"Payment ID: {payment.id}")
print(f"Checkout URL: {payment.checkout_url}")
Line-by-line breakdown:
- Line 11–13: The idempotency key is derived from stable data (user, order, amount). If the agent retries within the same hour, the key matches, and the server returns the cached payment without duplicating.
- Line 16–23:
create_payment_requestaccepts the idempotency key alongside amount, description, and webhook URL. This is where AgentPay's magic happens. - Line 25–28: The response includes the checkout URL (the VietQR-enabled link), which you send to the customer.
Step 3: Wait for Settlement with Polling + Webhook
import time
from typing import Optional
def await_settlement(payment_id: str, timeout_seconds: int = 3600, poll_interval: int = 10) -> dict:
"""
Polls payment status until settlement or timeout.
Also handles webhook notifications asynchronously.
Returns:
dict with status ('settled', 'expired', 'timeout') and payment details
"""
start_time = time.time()
while time.time() - start_time < timeout_seconds:
# Check current status
payment_status = agent_pay.get_payment_status(payment_id)
if payment_status.status == "settled":
# Settlement confirmed by bank
return {
"status": "settled",
"payment_id": payment_id,
"amount": payment_status.amount,
"settled_at": payment_status.settled_at,
"transaction_ref": payment_status.bank_transaction_ref
}
elif payment_status.status == "expired":
return {"status": "expired", "payment_id": payment_id}
# Payment still pending, wait before next poll
time.sleep(poll_interval)
return {"status": "timeout", "payment_id": payment_id}
# Usage
result = await_settlement(payment.id, timeout_seconds=1800) # 30 minutes
if result["status"] == "settled":
print(f"✅ Payment settled! Transaction ref: {result['transaction_ref']}")
# Fulfill order: create course account, send email, etc.
else:
print(f"❌ Payment {result['status']}")
What's happening here:
- Line 9–10: Polling every 10 seconds. Once the bank confirms,
payment_status.statusreturns"settled"along with the bank's transaction reference. - Line 11–18: The settlement signal includes
settled_atandbank_transaction_ref, which you log for reconciliation. - Line 22–24: If the QR expires (customer didn't pay in time), you detect it and can prompt for retry.
- Real-world: This polling is fast. Most VietQR payments settle within 30 seconds to 2 minutes. Your agent doesn't block; it handles other tasks.
Webhook Handling: The Asynchronous Safety Net
Polling is great, but webhooks are faster. Here's how to handle them robustly:
from flask import Flask, request, jsonify
import hmac
import hashlib
app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret_from_agentpay_dashboard"
@app.route('/webhooks/settlement', methods=['POST'])
def handle_settlement_webhook():
"""
AgentPay sends a webhook when bank confirms settlement.
Webhooks can arrive out of order or be retried, so we must be defensive.
"""
# Verify signature (prevent spoofing)
signature = request.headers.get('X-AgentPay-Signature')
body = request.get_data()
expected_sig = hmac.new(
WEBHOOK_SECRET.encode(),
body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_sig):
return {"error": "Invalid signature"}, 401
# Parse webhook data
payload = request.json
payment_id = payload['payment_id']
status = payload['status']
settled_at = payload['settled_at']
bank_ref = payload['bank_transaction_ref']
# Idempotent webhook handling: check if we've already processed this
webhook_id = payload['webhook_id'] # AgentPay provides a unique webhook ID
if db.webhook_processed(webhook_id):
# Already handled this webhook (likely a retry from AgentPay)
return {"status": "ok"}, 200
if status == "settled":
# Mark webhook as processed (atomically, before fulfillment)
db.mark_webhook_processed(webhook_id)
# Update payment record
db.update_payment(payment_id, {
"status": "settled",
"settled_at": settled_at,
"bank_ref": bank_ref
})
# Fulfill the order (async task recommended)
fulfill_order.delay(payment_id) # e.g., Celery task
return {"status": "ok"}, 200
return {"error": "Unknown status"}, 400
# Add webhook endpoint to your app
# Then register it in the AgentPay dashboard
Critical safeguards:
- Signature verification (lines 14–21): Only trust webhooks with valid signatures. This prevents attackers from faking settlements.
- Idempotent webhook ID (lines 33–36): AgentPay provides a
webhook_id. If the same webhook arrives twice (network retry, your endpoint was slow), you detect it and don't duplicate fulfillment. - Atomic marking (line 39): Mark the webhook as processed before fulfilling the order, so a crash mid-fulfillment doesn't cause you to process it twice.
- Async fulfillment (line 44): Don't block the webhook handler. Queue the fulfillment and respond immediately with 200 OK.
Real-World Walkthrough: AI Course Sales Bot
Let's build a complete example: an agent that sells online courses via VietQR.
Scenario: A user messages a Claude agent on your website asking to enroll in a "Python for Data Science" course ($50 = ~1.2M VND).
from agentpay_vn import AgentPayVN
import json
from datetime import datetime, timedelta
class CourseEnrollmentAgent:
def __init__(self):
self.agent_pay = AgentPayVN(api_key="sk_live_...")
self.courses = {
"python_data": {"name": "Python for Data Science", "price": 1200000},
"web_dev": {"name": "Web Dev Bootcamp", "price": 2500000}
}
def process_enrollment(self, user_email: str, course_id: str):
"""
Agent entry point: user asks to buy a course.
"""
course = self.courses.get(course_id)
if not course:
return {"error": "Course not found"}
# Step 1: Create idempotent payment request
idempotency_key = f"{user_email}:{course_id}:{datetime.now().isoformat()}"
payment = self.agent_pay.create_payment_request(
amount=course["price"],
description=f"Enrollment: {course['name']}",
idempotency_key=idempotency_key,
webhook_url="https://courseapp.com/webhooks/settlement"
)
# Step 2: Send checkout URL to customer
checkout_message = f"""
Great! To enroll in {course['name']}, scan this QR code:
{payment.checkout_url}
Price: {course['price']:,} VND
Valid for 1 hour.
"""
return {
"action": "send_message",
"message": checkout_message,
"payment_id": payment.id
}
def fulfill_enrollment(self, payment_id: str, user_email: str):
"""
Called after webhook confirms settlement.
"""
# Fetch payment details for audit
payment_status = self.agent_pay.get_payment_status(payment_id)
if payment_status.status != "settled":
return {"error": "Payment not settled"}
# Create course account
account = create_user_account(user_email)
send_welcome_email(user_email, course_name=payment_status.description)
return {
"status": "enrolled",
"account_id": account.id,
"course_access_url": f"https://courseapp.com/learn/{account.id}"
}
# Claude MCP integration (see next section)
Flow trace:
- User: "I want to buy the Python course"
- Agent calls
process_enrollment("user@example.com", "python_data") - AgentPay returns a payment request with idempotency key
- Agent sends checkout URL to user
- User scans QR, pays via VietQR
- Merchant's bank processes payment
- Bank notifies AgentPay
- Webhook fires →
handle_settlement_webhook()→fulfill_enrollment() - Course access granted
Total time: 30 seconds to 2 minutes from QR scan to course access.
Integrating with Claude via MCP
If you're using Claude as your agent, install the MCP server:
agentpay-mcp
Configure it in your Claude settings (or .mcp-config.json):
{
"mcpServers": {
"agentpay-vn": {
"command": "agentpay-mcp",
"args": [],
"env": {
"AGENTPAY_API_KEY": "sk_live_your_key_here",
"AGENTPAY_WEBHOOK_SECRET": "whsk_your_secret_here"
}
}
}
}
Now Claude can call AgentPay tools directly:
User: "I want to enroll in the AI course for 1.5M VND."
Claude thinks: I should create a payment request for this user.
Claude uses tool: create_payment_request(amount=1500000, description="AI Course Enrollment")
AgentPay MCP returns: {"payment_id": "pay_abc123", "checkout_url": "https://..."}
Claude responds: "Here's your payment link: [checkout_url]. Scan the QR to pay."
[User scans, pays]
[Webhook fires]
Claude uses tool: get_payment_status(payment_id="pay_abc123")
AgentPay MCP returns: {"status": "settled", "settled_at": "2024-01-15T10:30:00Z"}
Claude: "Payment confirmed! Your course access is ready: [link]"
The MCP server handles retries, signature verification, and polling under the hood.
Do's and Don'ts: Payment Agent Patterns
| DO ✅ | DON'T ❌ |
|---|---|
| Generate idempotency keys from stable data (user_id, order_id, amount) | Use random UUIDs as idempotency keys — they'll cause duplicates on retry |
| Poll for settlement status every 10–30 seconds | Poll every 1 second — you'll hit rate limits and waste compute |
| Verify webhook signatures with HMAC-SHA256 | Trust webhook data without signature verification |
| Mark webhooks as processed atomically before fulfillment | Process webhook, fulfill, then mark as processed — crash = duplicate fulfillment |
Log bank_transaction_ref for every settlement |
Ignore bank references — you'll struggle to reconcile later |
| Set reasonable payment expiry times (30 min to 1 hour) | Let payments valid for days — customers forget and get confused |
| Use async/queued tasks for fulfillment (Celery, Celery Beat, etc.) | Block the webhook handler on fulfillment — next webhook times out |
Handling Edge Cases
What if the webhook never arrives?
Your polling catches it. Within 30 seconds, you'll detect settlement via get_payment_status(). The customer gets their course access without waiting for the webhook.
What if the customer pays twice?
Idempotency keys prevent duplicate payment requests from your agent. Two separate payments will have different payment IDs. You can detect this via get_payments_for_user() and issue a refund if needed.
What if the bank's webhook arrives out of order?
Your webhook handler includes the bank's transaction timestamp (settled_at). If two webhooks for the same payment arrive, compare timestamps; the latest is the source of truth.
What if your fulfillment service is down when the webhook arrives?
The webhook handler queues the fulfillment asynchronously. If the queue service is also down, the webhook returns 500. AgentPay retries the webhook for 24 hours. Your system has time to recover.
FAQ
Q: Does AgentPay hold my money? No. The VietQR points directly at your merchant bank account. AgentPay is infrastructure, not a payment processor. Settlement goes straight to your bank.
Q: What's the transaction fee? AgentPay is open-source (MIT license). No transaction fees. Your bank may charge standard VietQR interchange fees (~1%).
Q: Can I use this with international customers? VietQR is Vietnam-only. Customers must have a Vietnamese bank account. If you need international support, AgentPay could be extended (open-source = you can contribute!).
Q: How do I test payments in development?
AgentPay provides a sandbox mode. Set AGENTPAY_ENV=sandbox and use test bank accounts. Webhooks fire immediately in sandbox (no real bank delay).
Key Takeaways
- Idempotency keys prevent duplicates when agents retry. Derive them from stable user/order data, not random UUIDs.
- Webhooks are fast (settlement in 30 sec–2 min), but polling is a safety net. Use both: webhooks for speed, polling for robustness.
- Verify webhook signatures with HMAC-SHA256. Don't trust unsigned webhooks.
- Mark webhooks as processed atomically before fulfilling, so crashes don't cause duplicate fulfillment.
- AgentPay never holds money — the QR points to your bank account. You own the settlement flow.
- Log bank transaction references (
bank_transaction_ref) for reconciliation and compliance. - Use async fulfillment queues (Celery, Bull, etc.) so webhook handlers respond quickly and retry-safely.
Next Steps
- Install AgentPay VN:
pip install agentpay-vn - Read the full documentation: https://agentpay.servicesai.vn/v1/docs
- Clone the example repo: https://github.com/phuocdu/agentpay-vn
- Integrate with your agent: Use the MCP server (
agentpay-mcp) for Claude, or call the SDK directly for other agents. - Test in sandbox first. Don't go live without testing idempotency and webhook retry scenarios.
Happy payment agent building! 🚀