Back to blog
Article

Recovering failed UPI payments without spamming your customers

Recovering failed UPI payments without spamming your customers
S

StriveBit

3 min readE-commerce

Recovering failed and abandoned payments without turning it into a spam campaign

A furniture retailer in Jaipur sees 34% of their orders fail at the payment step. Razorpay reports a `payment_failed` webhook, the customer sees a vague error, and the order sits in `pending_payment` until someone manually calls. Their team of two cannot call 200 people a day, so most of these orders just die.

The fix is not more reminders. It is making the retry effortless and the notifications sparse enough that they feel like help, not marketing.

We separate failures into two buckets. Soft failures — network timeouts, app crashes, UPI intent closed mid-flow — where the customer intended to pay. Hard failures — insufficient funds, declined card, invalid CVV — where the payment itself was rejected. The retry strategy differs.

For soft failures, the window is five minutes. The customer is still on the page or just left it. We regenerate the payment link with the same `order_id`, preserving cart contents, address, and applied coupons. No new order, no duplicate inventory hold. Razorpay's `retry` flag on the payment link handles the UPI deep-link freshness issue — the original VPA intent expires after ten minutes on some apps.

import requests

def regenerate_payment_link(order_id, amount_paise, customer_phone):
    resp = requests.post(
        "https://api.razorpay.com/v1/payment_links",
        auth=(RAZORPAY_KEY_ID, RAZORPAY_SECRET),
        json={
            "amount": amount_paise,
            "currency": "INR",
            "reference_id": order_id,
            "contact": customer_phone,
            "retry": {"enabled": True, "max_attempts": 3},
            "options": {"checkout": {"prefill": {"method": "upi"}}}
        }
    )
    return resp.json()["short_url"]

For hard failures, we wait an hour before the first message. Same link, same order, but we add the WhatsApp template with the failure reason stripped of jargon. "Your payment for ₹12,400 could not be completed. Tap to try again: [link]" works better than "Payment failed: INSUFFICIENT_FUNDS." Nobody knows what that means.

The second attempt is SMS, six hours later. The third, if we even do it, is the next morning. We cap at three touches across two channels over 24 hours. After that, we release the inventory hold and mark the order `abandoned`. The data shows that retries after 24 hours convert under 2% — not worth the customer's irritation.

The mistake we see most often is firing a notification on every `payment.failed` webhook. Razorpay retries the charge up to three times on some instruments, and each retry fires a webhook. If you trigger a message on each event, the customer gets three SMSes in ninety seconds. Deduplicate on `order_id` and only send on the final `payment.failed` or after a five-minute quiet period.

We also avoid the "abandoned checkout" email flow that platforms like Shopify push by default. It sends to anyone who viewed a product, which for Indian D2C mostly hits people who were price-checking. The recovery rate looks decent in the dashboard but the unsubscribe rate and spam complaints cost you deliverability on the campaigns that matter.

The furniture retailer now recovers 8% of failed payments through regenerated links alone, before any human intervention. Their call team handles 30 follow-ups a day instead of 200, and the WhatsApp template they use has not triggered a single spam complaint in four months.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch