The Silent Killer: Double Charges in Agent Payment Flows
Imagine your AI assistant just closed a $500 course sale for a customer. The agent calls create_payment_request(), sends a checkout link, and waits. The network hiccups. The agent retries. Now the customer sees two payment prompts—or worse, two transactions hit their bank account. By the time you notice, three more duplicates have cascaded through.
This isn't theoretical. It happens daily in production systems where agents handle financial transactions autonomously. Traditional payment gateways hold your money temporarily and reverse duplicates. But AgentPay VN works differently: it never holds funds. The VietQR code points directly to your merchant bank account. Settlement confirmation comes from your bank's own feed. This means you must architect idempotency, webhooks, and settlement tracking—or lose money to duplicate charges.
In this guide, we'll build bulletproof payment flows using AgentPay VN's Python SDK and MCP server, ensuring your agents collect payments safely, confirm settlement, and handle failure gracefully.
Why Idempotency Matters for AI Agents
AI agents are inherently retry-prone. They face network timeouts, rate limits, and transient errors. A naive agent that retries create_payment_request() without idempotency will spawn multiple payment requests—each generating a unique QR code—leading to split payments or abandoned carts.
Idempotency ensures the same logical request always yields the same result, even if called 100 times. It's the foundation of reliable agent payments.
AgentPay VN supports idempotency through request IDs. When you provide a request_id (a stable, deterministic identifier derived from order details), the system guarantees:
-
First call: Creates payment request, returns
checkout_url. -
Retry with same
request_id: Returns the samecheckout_urlwithout creating a new request. - No duplicate charges: Your agent can safely retry without fear.
This is critical for multi-agent systems where different agent instances might process the same order or where network failures trigger automatic retries.
Building Idempotent Payment Requests
Generating Stable Request IDs
The key is deriving request_id deterministically from immutable order data:
import hashlib
from agentpay_vn import AgentPayClient
# Stable request ID: hash of order essentials
def generate_request_id(customer_id: str, product_id: str, amount: int) -> str:
data = f"{customer_id}:{product_id}:{amount}"
return hashlib.sha256(data.encode()).hexdigest()[:16]
# Initialize client
client = AgentPayClient(api_key="your-api-key")
# Create idempotent payment request
customer_id = "cust_alice_001"
product_id = "course_python_101"
amount_vnd = 500000 # 500k VND
request_id = generate_request_id(customer_id, product_id, amount_vnd)
# First call: creates request
payment = client.create_payment_request(
request_id=request_id,
amount=amount_vnd,
description="Python Mastery Course",
merchant_order_id=f"order_{customer_id}_{product_id}"
)
checkout_url = payment.checkout_url
print(f"Checkout URL: {checkout_url}")
# Retry with same request_id: returns identical checkout_url
# No new payment request created
payment_retry = client.create_payment_request(
request_id=request_id,
amount=amount_vnd,
description="Python Mastery Course",
merchant_order_id=f"order_{customer_id}_{product_id}"
)
assert payment_retry.checkout_url == checkout_url
print("✓ Idempotency verified: same request_id → same checkout_url")
Why this works:
-
request_idis derived from customer, product, and amount—data that don't change during retries. - If any of those change, a new order should be created (new
request_id). - The API deduplicates based on
request_id, not on customer retry attempts.
Webhook Handling: Confirming Customer Action
After the customer scans the VietQR and completes the transfer, AgentPay VN sends a webhook to your system. This webhook is your proof that a payment was attempted—but not necessarily settled.
Setting Up Webhook Endpoints
In your agent configuration or backend:
from fastapi import FastAPI, Request, HTTPException
import hmac
import hashlib
import json
app = FastAPI()
WEBHOOK_SECRET = "your-webhook-secret" # From AgentPay dashboard
def verify_webhook_signature(body: bytes, signature: str) -> bool:
"""Verify webhook came from AgentPay VN."""
expected = hmac.new(
WEBHOOK_SECRET.encode(),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.post("/webhooks/agentpay")
async def handle_agentpay_webhook(request: Request):
signature = request.headers.get("X-AgentPay-Signature")
body = await request.body()
# Verify authenticity
if not verify_webhook_signature(body, signature or ""):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(body)
request_id = payload.get("request_id")
status = payload.get("status") # "pending", "paid", "expired"
if status == "paid":
# Customer has sent money; await settlement confirmation
print(f"✓ Payment received for request {request_id}")
# Update local DB: mark as "payment_received"
elif status == "expired":
print(f"✗ Payment expired for request {request_id}")
# Prompt agent to create new request
return {"status": "received"}
Critical distinction:
- Webhook
status="paid"means the customer's transfer is in flight to your account. - It does NOT mean settlement is complete; bank processing takes seconds to minutes.
- Use webhooks to update UI and trigger settlement polling, not to deliver goods immediately.
Settlement Confirmation: Trust Your Bank Feed
AgentPay VN does not track settlement. Your bank does. When the customer's bank processes the transfer, it credits your merchant account. AgentPay provides tools to poll settlement status, but the source of truth is your bank's transaction feed.
Polling for Settlement
import time
from agentpay_vn import AgentPayClient
client = AgentPayClient(api_key="your-api-key")
# After webhook confirms payment_received, poll settlement
async def await_settlement(request_id: str, timeout_seconds: int = 60) -> dict:
"""
Poll settlement status until confirmed or timeout.
"""
start = time.time()
while time.time() - start < timeout_seconds:
settlement = client.get_settlement_status(request_id=request_id)
if settlement.status == "settled":
print(f"✓ Settlement confirmed: {settlement.amount} VND")
return {
"settled": True,
"amount": settlement.amount,
"bank_ref": settlement.bank_reference_id
}
if settlement.status == "failed":
print(f"✗ Settlement failed: {settlement.error_reason}")
return {"settled": False, "reason": settlement.error_reason}
# Still pending; wait and retry
await asyncio.sleep(2)
# Timeout; settlement not confirmed within window
return {"settled": False, "reason": "Timeout after settlement webhook"}
# Usage in agent flow
result = await await_settlement(request_id)
if result["settled"]:
print(f"Deliver course to student. Bank ref: {result['bank_ref']}")
else:
print(f"Settlement pending or failed. Retry in 1 minute.")
Best practice: Pair webhook notifications with polling. Webhooks are fast but can be delayed or lost; polling is slower but guarantees eventual consistency.
MCP Server: Integrating with Claude and Other AI Agents
For Claude and other AI agents, AgentPay VN provides a Model Context Protocol (MCP) server. This exposes payment operations as callable tools.
Claude Integration Configuration
Add this to your Claude configuration file (e.g., claude_config.json):
{
"mcpServers": {
"agentpay-vn": {
"command": "agentpay-mcp",
"env": {
"AGENTPAY_API_KEY": "your-api-key",
"AGENTPAY_WEBHOOK_SECRET": "your-webhook-secret"
}
}
}
}
Once configured, Claude gains these tools:
-
create_payment_request: Create a new VietQR payment with idempotency. -
get_payment_status: Check if customer has sent money. -
await_settlement: Poll until settlement confirmed. -
create_refund: Reverse a payment if needed.
Example: Claude as a Course Sales Bot
User: "I want to buy your Python course."
Claude (using MCP tools):
1. Calls create_payment_request(request_id="user_alice_python_101", amount=500000)
2. Receives checkout_url
3. Responds: "Here's your payment link: [URL]. Scan with your bank app."
4. Waits for webhook (customer scans and pays)
5. On webhook, calls await_settlement(request_id)
6. Once settled, says: "✓ Payment confirmed! Sending course materials now."
7. Delivers course download link
No manual intervention. The agent orchestrates the entire flow.
Real-World Walkthrough: Online Coffee Shop
Let's trace a complete order:
Scenario: A coffee shop ("CoffeeAI") runs a WhatsApp bot powered by Claude + AgentPay VN. A customer orders 2 espressos for 120,000 VND.
Flow:
-
Order Creation (11:45 AM)
- Agent generates
request_id = hash("user_bob", "2espresso", 120000)→a7f3e2b1 - Calls
create_payment_request(request_id="a7f3e2b1", amount=120000, description="2x Espresso") - Returns
checkout_url = "https://vietqr.io/...?" - Agent sends WhatsApp: "Pay here: [link]"
- Agent generates
-
Customer Scans (11:46 AM)
- Customer opens link, scans QR with banking app
- Bank sees: "CoffeeAI merchant account, 120,000 VND"
- Customer confirms transfer
-
Webhook Receipt (11:46:03 AM)
- AgentPay sends webhook:
{request_id: "a7f3e2b1", status: "paid"} - Shop backend receives it, stores in DB:
orders[a7f3e2b1].status = "payment_received" - Agent is notified via polling or event listener
- AgentPay sends webhook:
-
Settlement Polling (11:46:05 AM)
- Agent calls
await_settlement(request_id="a7f3e2b1") - First poll:
status: "pending"(bank still processing) - Second poll (2 sec later):
status: "settled",bank_ref: "VCB_20250201_987654" - Agent sends WhatsApp: "✓ Payment received! Your order is being prepared."
- Agent calls
-
Fulfillment (11:50 AM)
- Order marked as confirmed in kitchen system
- Barista prepares 2 espressos
- Order ready; agent sends: "Ready for pickup!"
Key insight: The agent never held money. It verified the customer sent it, waited for the bank to confirm receipt, then triggered fulfillment. If the customer's bank failed to process (unlikely with VietQR), the agent would have retried the webhook polling and eventually returned an error to the customer.
Do's and Don'ts: Common Pitfalls
| Do | Don't |
|---|---|
Use deterministic request_id from order data |
Generate random UUIDs for request_id
|
| Verify webhook signatures before processing | Trust webhook payload without verification |
| Implement polling with exponential backoff | Poll once and assume settlement failed |
Store bank_reference_id from settlement response |
Treat webhook as proof of settlement |
Retry create_payment_request on network error |
Create new payment requests on every retry |
| Set reasonable timeouts (30–60 sec) for settlement polling | Poll forever without a timeout |
| Log all payment lifecycle events for audits | Discard payment logs after 1 week |
FAQ
Q: What if the customer closes the checkout page without paying?
A: The webhook never arrives. Your agent's polling times out and returns {settled: False, reason: "Timeout"}. The agent can then prompt the customer to try again or offer support.
Q: Can I refund a payment after settlement?
A: Yes. Call client.create_refund(request_id, reason="customer_request"). The refund is sent back to the customer's bank account. Settlement is confirmed via the same polling mechanism.
Q: How does AgentPay handle concurrent requests from multiple agents?
A: Idempotency is your shield. If agent A and agent B both call create_payment_request(request_id="x") simultaneously, AgentPay's database lock ensures only one payment request is created. Both agents receive the same checkout_url and proceed safely.
Q: What's the maximum polling timeout?
A: VietQR typically settles within 5–30 seconds. Set your polling timeout to 60 seconds to be safe. If settlement isn't confirmed by then, escalate to support; it's rare and indicates a bank-side issue.
Key Takeaways
-
Idempotency is non-negotiable: Derive
request_idfrom immutable order data. Retries are safe; duplicates are prevented. - Webhooks notify; polling confirms: Webhooks tell you the customer sent money. Polling confirms the bank received it. Use both.
-
Never assume settlement: Webhook
status="paid"is not settlement. Always callawait_settlement()before delivering goods. -
Bank reference is your proof: Once settled, store
bank_reference_id. It's your audit trail if disputes arise. - Agent-native means autonomous: With proper idempotency and error handling, your AI agents can collect payments end-to-end without human intervention.
Get Started Now
AgentPay VN makes agent payments safe and simple. Install the SDK and MCP server, integrate idempotency and webhooks into your agent flow, and let your AI collect payments directly to your bank account.
Next steps:
-
Install the SDK:
pip install agentpay-vn -
Set up the MCP server:
agentpay-mcp(configure in your Claude or agent config) - Read the full docs: https://agentpay.servicesai.vn/v1/docs
- Explore the code: GitHub: phuocdu/agentpay-vn
- Build: Start with a test agent and process a few test payments to verify your idempotency and settlement flow.
Happy collecting!
Top comments (0)