Your AI agent can qualify a lead, schedule a meeting, draft a proposal, and follow up three days later. But when it's time to collect payment, it hands off to a human. That handoff is the last piece of friction standing between an assistant and an autonomous agent.
This post shows you how to close that loop using Cashfree's payment gateway — the fastest and most developer-friendly PG in India. You'll build a working payment agent that creates orders, sends payment links, and handles webhooks, all in code the agent itself can call as a tool.
Why Cashfree for agentic workflows
India has two dominant payment gateways. Cashfree wins on the things that matter most when you're building for automation:
- T+1 settlements by default (Razorpay is T+2 unless you pay extra)
- 1.75% MDR vs Razorpay's 2% — at ₹10L/month volume that's ₹2,500 back in your pocket
- Clean webhook event model with HMAC-SHA256 signatures — exactly what you need for an agent that reacts to payment events
- Instant sandbox access — no approval process, no wait, just copy your test credentials and start
The API is straightforward REST. The official Python SDK (cashfree-pg) is well-maintained and handles auth, retries, and response parsing cleanly.
Architecture of a payment agent
Before writing code, here's what the agent actually does:
User: "I'd like to pay for the pro plan — ₹2,499/month"
→ Agent: calls create_payment_link tool
→ Cashfree: creates order, returns payment_session_id
→ Agent: "Here's your payment link: [link]. Let me know once you're done."
→ User pays via UPI/card/netbanking
→ Cashfree: fires PAYMENT_SUCCESS webhook
→ Agent: activates subscription, sends confirmation
The human's only action is completing the payment. Everything else is the agent.
Step 1: Install the SDK and get credentials
pip install cashfree-pg
Sign up at cashfree.com, grab your App ID and Secret Key from the Developers section. Use the sandbox credentials for testing — sandbox URL is https://sandbox.cashfree.com/pg.
Set them as environment variables:
export CASHFREE_CLIENT_ID="your_app_id"
export CASHFREE_CLIENT_SECRET="your_secret_key"
Step 2: Create a payment order
This is the core function your agent will call. It creates an order and returns a payment_session_id — pass this to Cashfree's JS SDK on your frontend, or generate a hosted checkout link directly.
import os
from cashfree_pg.api_client import Cashfree
from cashfree_pg.models.create_order_request import CreateOrderRequest
from cashfree_pg.models.customer_details import CustomerDetails
from cashfree_pg.models.order_meta import OrderMeta
def create_payment_order(
order_id: str,
amount: float,
customer_id: str,
customer_email: str,
customer_phone: str,
environment: str = "sandbox",
):
cf = Cashfree(
XEnvironment=Cashfree.SANDBOX if environment == "sandbox" else Cashfree.PRODUCTION,
XClientId=os.environ["CASHFREE_CLIENT_ID"],
XClientSecret=os.environ["CASHFREE_CLIENT_SECRET"],
)
req = CreateOrderRequest(
order_id=order_id,
order_amount=amount,
order_currency="INR",
customer_details=CustomerDetails(
customer_id=customer_id,
customer_email=customer_email,
customer_phone=customer_phone,
),
order_meta=OrderMeta(
return_url="https://yourapp.example/payments/return?order_id={order_id}",
notify_url="https://api.yourapp.example/webhooks/cashfree",
),
)
response = cf.PGCreateOrder(req, None, None)
data = response.data.to_dict()
return {
"order_id": data["order_id"],
"payment_session_id": data["payment_session_id"],
}
The payment_session_id is what you pass to Cashfree's JS Checkout SDK on the frontend. For a backend-only flow (sending a payment link over WhatsApp or email), you can construct the hosted payment page URL as https://payments.cashfree.com/forms/{order_id}.
Step 3: Verify webhooks
When a payment completes, Cashfree fires a POST to your notify_url. Always verify the signature before trusting the payload — otherwise anyone can fake a PAYMENT_SUCCESS event.
Cashfree signs webhooks with HMAC-SHA256 + base64 (not a plain hex digest like Razorpay). The exact spec:
import base64, hashlib, hmac, json, os
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
WEBHOOK_SECRET = os.environ["CASHFREE_CLIENT_SECRET"]
def verify_cashfree_signature(raw_body: str, timestamp: str, signature: str, secret: str) -> bool:
signed_payload = f"{timestamp}{raw_body}"
digest = hmac.new(secret.encode(), signed_payload.encode(), hashlib.sha256).digest()
return hmac.compare_digest(base64.b64encode(digest).decode(), signature)
@app.post("/webhooks/cashfree")
async def cashfree_webhook(
request: Request,
x_webhook_signature: str = Header(..., alias="x-webhook-signature"),
x_webhook_timestamp: str = Header(..., alias="x-webhook-timestamp"),
):
raw_body = (await request.body()).decode("utf-8")
if not verify_cashfree_signature(raw_body, x_webhook_timestamp, x_webhook_signature, WEBHOOK_SECRET):
raise HTTPException(status_code=400, detail="Invalid webhook signature")
payload = json.loads(raw_body)
order = payload["data"]["order"]
payment = payload["data"]["payment"]
order_id = order["order_id"]
payment_status = payment["payment_status"] # PAID / FAILED / USER_DROPPED
transaction_id = payment["cf_payment_id"]
if payment_status == "PAID":
# trigger fulfilment — activate subscription, send confirmation, update CRM
pass
elif payment_status == "FAILED":
# notify user, schedule retry
pass
return {"ok": True}
One mistake I see constantly: reading the body with await request.json() before signature verification. That parses the JSON and strips whitespace, which changes the string and breaks the HMAC check. Always use await request.body() and decode as UTF-8.
Step 4: Poll for status (fallback)
Webhooks can be delayed or missed. Add a status check function the agent can use to confirm payment before proceeding:
import requests, os
def check_order_status(order_id: str, environment: str = "sandbox") -> dict:
base = "https://sandbox.cashfree.com/pg" if environment == "sandbox" else "https://api.cashfree.com/pg"
headers = {
"x-api-version": "2023-08-01",
"x-client-id": os.environ["CASHFREE_CLIENT_ID"],
"x-client-secret": os.environ["CASHFREE_CLIENT_SECRET"],
}
resp = requests.get(f"{base}/orders/{order_id}", headers=headers, timeout=15)
resp.raise_for_status()
status = resp.json().get("order_status")
if status == "PAID": return {"action": "fulfil_order"}
if status == "ACTIVE": return {"action": "wait_or_retry"}
if status == "EXPIRED": return {"action": "create_new_order"}
return {"action": "manual_review", "status": status}
The agent can call this 60 seconds after sending the payment link. If still ACTIVE, schedule another check. If PAID, proceed. If EXPIRED (orders expire after 15 minutes by default), create a fresh order.
Wiring it as a Claude tool
The power of this setup is that Claude can call create_payment_link as a tool — the agent decides when to trigger payment based on the conversation, not a hardcoded rule.
Here's the tool definition to pass to Claude's API:
{
"name": "create_payment_link",
"description": "Create a Cashfree payment order and return a payment link for the customer. Use when the user explicitly wants to pay for something. Returns a URL they can open to complete payment.",
"input_schema": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"minimum": 1,
"description": "Amount in INR (e.g. 2499 for ₹2,499)"
},
"description": {
"type": "string",
"description": "What the payment is for — shown to the customer"
},
"customer_name": { "type": "string" },
"customer_email": { "type": "string", "format": "email" },
"customer_phone": {
"type": "string",
"pattern": "^[0-9]{10}$",
"description": "10-digit Indian mobile number, no country code"
}
},
"required": ["amount", "description", "customer_name", "customer_email", "customer_phone"],
"additionalProperties": false
}
}
The phone number regex (^[0-9]{10}$) matters — Cashfree's API rejects numbers with country codes or spaces, and the model will hallucinate formats without a constraint. See the tool design post for more on why parameter constraints like this prevent most agent tool failures.
Full agent loop in Python:
import anthropic, json, uuid
client = anthropic.Anthropic()
tools = [payment_tool] # the tool definition above
def run_payment_agent(conversation_history: list) -> str:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
tools=tools,
messages=conversation_history,
system="You are a sales assistant. When a customer is ready to pay, collect their name, email, and phone number, then use create_payment_link to generate a payment link. Always confirm the amount with the customer before creating the link.",
)
if response.stop_reason == "tool_use":
tool_use = next(b for b in response.content if b.type == "tool_use")
args = tool_use.input
order_id = f"order_{uuid.uuid4().hex[:12]}"
result = create_payment_order(
order_id=order_id,
amount=args["amount"],
customer_id=args["customer_email"],
customer_email=args["customer_email"],
customer_phone=args["customer_phone"],
environment="production",
)
payment_url = f"https://payments.cashfree.com/forms/{result['order_id']}"
# Feed tool result back to model
conversation_history.append({"role": "assistant", "content": response.content})
conversation_history.append({
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": tool_use.id, "content": json.dumps({"payment_url": payment_url, "order_id": result["order_id"]})}]
})
followup = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=500,
tools=tools,
messages=conversation_history,
)
return followup.content[0].text
return response.content[0].text
The n8n path (no code required)
If you're using n8n instead of Python, the same flow works with native nodes:
-
HTTP Request node —
POST https://api.cashfree.com/pg/orders- Headers:
x-api-version: 2023-08-01,x-client-id: {{$env.CASHFREE_CLIENT_ID}},x-client-secret: {{$env.CASHFREE_CLIENT_SECRET}} - Body:
order_id,order_amount,order_currency: INR, nestedcustomer_details
- Headers:
-
Set node — extract
payment_session_idand construct the payment URL -
Send the link via WhatsApp Business node, Gmail node, or Telegram node
-
Webhook node — listens for Cashfree's
PAYMENT_SUCCESSevent at/webhooks/cashfree -
Claude node — processes the payment event and decides next action (activate account, send receipt, alert sales team)
See the n8n automation guide for the full n8n + Claude setup if you're starting from scratch.
Testing in sandbox
Cashfree's sandbox is at https://sandbox.cashfree.com/pg. Test credentials are in your dashboard under Developers → API keys → Test.
Test card: 4111 1111 1111 1111, any future expiry, any CVV.
Test UPI ID: success@cashfree (always succeeds), failure@cashfree (always fails).
Run through the full flow — create order, pay with test card, verify the webhook fires, check your handler returns 200. A webhook that returns a non-200 will get retried up to 5 times.
Security checklist before going live
- Never log
payment_session_id— it grants one-time checkout access. Treat it like a password. - Always verify webhook signatures — the 8 lines above are not optional.
- Use environment variables, never hardcode credentials.
- Set an order expiry — default is 15 minutes. For high-value transactions, reduce to 5 minutes to limit exposure.
- Idempotency: use a stable
order_id(e.g., derived from your internal order ID) so retried API calls don't create duplicate orders.
The agentic payments landscape in India is moving fast — Razorpay and NPCI have their own pilot, but for developers building today, Cashfree's API is cleaner and the economics are better. The integration above takes about two hours end-to-end, and you end up with an agent that can close deals without human intervention.



