DEV Community

ServicesAI VN
ServicesAI VN

Posted on • Originally published at agentpay.servicesai.vn

Agent-native payments: idempotency, webhooks, settlement

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:

  1. First call: Creates payment request, returns checkout_url.
  2. Retry with same request_id: Returns the same checkout_url without creating a new request.
  3. 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")
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • request_id is 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"}
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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]"
  2. Customer Scans (11:46 AM)

    • Customer opens link, scans QR with banking app
    • Bank sees: "CoffeeAI merchant account, 120,000 VND"
    • Customer confirms transfer
  3. 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
  4. 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."
  5. 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_id from 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 call await_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:

  1. Install the SDK: pip install agentpay-vn
  2. Set up the MCP server: agentpay-mcp (configure in your Claude or agent config)
  3. Read the full docs: https://agentpay.servicesai.vn/v1/docs
  4. Explore the code: GitHub: phuocdu/agentpay-vn
  5. Build: Start with a test agent and process a few test payments to verify your idempotency and settlement flow.

Happy collecting!

Top comments (0)