Agent-Native Payments: Idempotency, Webhooks & Settlement

2026-07-28 · AgentPay VN

ai-agentspaymentsvietqridempotencywebhooks

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:

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:

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:

  1. Signature verification (lines 14–21): Only trust webhooks with valid signatures. This prevents attackers from faking settlements.
  2. 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.
  3. 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.
  4. 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:

  1. User: "I want to buy the Python course"
  2. Agent calls process_enrollment("user@example.com", "python_data")
  3. AgentPay returns a payment request with idempotency key
  4. Agent sends checkout URL to user
  5. User scans QR, pays via VietQR
  6. Merchant's bank processes payment
  7. Bank notifies AgentPay
  8. Webhook fires → handle_settlement_webhook()fulfill_enrollment()
  9. 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

Next Steps

  1. Install AgentPay VN: pip install agentpay-vn
  2. Read the full documentation: https://agentpay.servicesai.vn/v1/docs
  3. Clone the example repo: https://github.com/phuocdu/agentpay-vn
  4. Integrate with your agent: Use the MCP server (agentpay-mcp) for Claude, or call the SDK directly for other agents.
  5. Test in sandbox first. Don't go live without testing idempotency and webhook retry scenarios.

Happy payment agent building! 🚀

Get started →

← All posts