UPI AutoPay integration, from a billing system
UPI AutoPay from the merchant's side of a live billing system: RBI's ₹15,000 line, who sends the pre-debit notice, and the webhook behind every charge.
Ridam Agrawal
- 9 min
On this page
How do you integrate UPI AutoPay as a merchant?
For most merchants, through a payment gateway's subscriptions product. Easinvy Retail uses Razorpay Subscriptions: its checkout registers the mandate in the customer's UPI app and Razorpay charges each cycle. The merchant's own work is the webhook that records each charge, which has to survive retries and a renewal arriving twice.
Everything below is the autopay path of a live retail billing system, read on 27 September 2026. Easinvy Retail sells four plans on autopay: ₹499 a month, ₹1,399 for three months, ₹2,699 for six and ₹4,999 a year, all through Razorpay Subscriptions since 13 September 2026.
UPI AutoPay, from the merchant's side
A merchant rarely talks to UPI AutoPay directly. It goes through a payment gateway's subscriptions product, and the gateway talks to the rail.
Easinvy Retail uses Razorpay Subscriptions, which takes "Cards, UPI Autopay and Emandate". The shop owner picks a plan, Razorpay's checkout opens, and the owner chooses how to pay. If they choose UPI, the mandate is approved in their UPI app.
The product's code never decides anything by which method was chosen. It records the method Razorpay reports on each payment, for the admin panel, and nothing in the billing logic branches on it. That is deliberate: the mandate is Razorpay's to run, and what the product needs is the same whichever way the shop pays, which is to know when money arrived.
One limit belongs here rather than at the end. No UPI AutoPay mandate is known to have been authorised on the product. The one live subscription was a ₹1 monthly charge on 13 September 2026, and the notes kept on that test do not say which method it used.
The ₹15,000 line, and why every renewal here is under it
RBI's rules for recurring payments were consolidated this year into the Digital Payments – E-mandate Framework, 2026, issued 21 April 2026. Registering a mandate needs extra authentication: "The mandate shall be registered only after successful validation of additional factor of authentication (AFA)." So does the first charge. After that:
All recurring transactions may be authorised without AFA up to ₹15,000/- per transaction. Transactions above this amount shall be subject to AFA.
For UPI that extra factor is the PIN, and Google Pay's help says it in the app's terms: "If your recurring payment is less than ₹15,000, you need to enter your UPI PIN to set it up for the first time. If your recurring payment is more than ₹15,000, you'll need to enter your UPI PIN to approve each payment." Insurance premiums, mutual funds and card bills get a higher line, ₹1,00,000.
Every Easinvy plan is under ₹15,000 a cycle; the largest is ₹4,999 a year. A shop owner authorises once, and renewals go through without asking again. Razorpay states the same limits for its subscriptions: mandates up to ₹1,00,000, and frictionless debits up to ₹15,000.
The bank sends the pre-debit notice, not the merchant
Before every debit, the customer is warned. Who does the warning is something the pages ranking for this search disagree about. One tells merchants to send pre-debit notifications to customers themselves; another says they come from NPCI; a gateway's page implies the gateway sends them.
The RBI direction is not ambiguous:
An issuer shall send a pre-transaction notification to the customer, at least 24 hours prior to the actual charge / debit.
The issuer is the customer's bank. Google Pay's help agrees from the other side: "You'll get a notification 24 hours before any debit from the bank." The merchant does not send it.
This product's own code had it wrong too. The line that creates a subscription
passes customer_notify: 1, with this comment:
// Razorpay emails and messages the shop about upcoming charges. That is
// the pre-debit notice RBI requires, and it is theirs to send.
customer_notify: 1,
Razorpay's documentation describes the flag differently: it "Indicates whether the communication to the customer would be handled by businesses or Razorpay". That is Razorpay's own emails, not the bank's regulated notice. The mistake has no consequence, because the obligation was never the merchant's and the flag defaults to on anyway. The comment is still wrong, and it is recorded here as it stands.
Plans, periods, and a value the API would not take
A subscription charges against a plan: an amount and how often. Razorpay's
Create a Plan reference
lists the possible values for period as daily, weekly, monthly,
quarterly and yearly.
On 13 September 2026 the API refused quarterly. The commit that fixed it
records the error: sending it "is refused with "Invalid argument for period
passed", which the first press of the sync button reported." The three-month
plan is now monthly with an interval of 3, and the six-month plan monthly
with 6. Whether the reference or the API has changed since was not tested;
doing so means creating a plan on a live account.
How a monthly plan with an interval of 3 appears in a customer's UPI app is
not documented in anything read. Razorpay's plan reference only says that
"For UPI, all undefined frequencies except daily, weekly, monthly, quarterly
and yearly are considered as-presented."
Two more facts about plans shape the integration. Razorpay plans cannot be edited, so a price change is a new plan, and an existing mandate keeps the amount it was authorised for: in the product's own words, "a price change reaches a shop at its next decision, not mid-mandate." And a subscription needs a finite number of cycles. The product asks for ten years' worth, which is "until cancelled" for every practical purpose. Amounts go to Razorpay as whole paise, for the reasons in decimal vs float for money.
The webhook is where the integration lives
The mandate is Razorpay's. What the merchant owns is the webhook that hears about each charge, and it is the only part that decides whether a shop that paid gets what it paid for.
Razorpay's webhook best practices set the terms. Delivery is at least once: "Razorpay follows at-least-once delivery semantics." Anything but a 2xx is a failure, retried with backoff for 24 hours. Then: "If the webhooks continue to fail for 24 hours, the webhook is disabled." Order is not promised either: "you may not always receive the webhooks in order."
Duplicates are expected, and the tool for spotting them is the
x-razorpay-event-id header, which "is unique per event and can help you
determine the duplicity of a webhook event." The product keys on it, because,
as its comment puts it, it is "stable across retries of the same event —
unlike anything inside the payload."
The detail that matters is what counts as a duplicate. Razorpay's validation page says to check whether an event "is processed at your end." Processed, not received. The usual implementation records the event id with a unique constraint the moment it arrives, which records received. If the handler then fails, every retry is turned away as a duplicate, and the charge is never recorded. Hookdeck's general guide handles the same case by deleting the row on failure; this product keeps the row and leaves it unfinished instead:
.onConflictDoUpdate({
target: [webhookEvents.provider, webhookEvents.eventId],
// A no-op write, so the row and its processed_at come back either way.
set: { eventType: input.eventType },
})
.returning({ id: webhookEvents.id, processedAt: webhookEvents.processedAt })
A row whose processed_at is empty is handed back for another attempt. The
no-op set is there because ON CONFLICT DO NOTHING returns no row, and the
handler needs the existing one to see whether it was finished.
The payment id is the real guarantee
The event table is not what stops a shop being credited twice. The comment on
it says so plainly: "the unique index on payments.provider_payment_id is the
actual guarantee, and correctness should not rest on the weaker of the two."
There are two writers. When a shop pays, the browser comes back from checkout and reports the payment, and the webhook reports it too. Both call the same function with the same payment, and the unique index on the payment id "decides which of them does the work." The browser path has no event id at all, which is why the guarantee cannot live in the event table.
The product's self-test has a check for exactly this, labelled "a fresh
delivery of the same payment does nothing": a different event id carrying a
payment already recorded. It is one of 53 checks in
backend/src/db/tests/razorpay-selftest.ts, which signs its own webhook bodies
and posts them through a running server. They were not run for this note.
A renewal arrives twice
Each renewal fires subscription.charged, which is the event that pays. It
also fires payment.captured, the event an ordinary one-off payment uses. The
webhook's comment:
A subscription's charge also arrives as
payment.captured, carrying an invoice id and an order this database never created.
Handle that second event as an order, and every renewal is recorded as money
for an order nobody made. The product ignores a payment.captured that
carries an invoice_id and lets subscription.charged do the work. This was
confirmed by the ₹1 live charge on 13 September 2026.
The subscription signature is the order signature backwards
When checkout finishes, it hands the browser a signature to verify. For a
one-off order, Razorpay's integration guide gives
hmac_sha256(order_id + "|" + razorpay_payment_id, secret). For a
subscription it gives hmac_sha256(razorpay_payment_id + "|" + subscription_id, secret).
The payment id moves from second place to first. Each page is right, neither mentions the other, and copying the order code for subscriptions rejects every real payment. The product's comment on its subscription check: "Note the order — it is the reverse of the order signature above, and getting it backwards rejects every real payment."
Take the period end from Razorpay
When a charge lands, the product needs to know when the paid period ends. It
could compute that, and it deliberately does not. It takes Razorpay's
current_end from the payload:
They are the ones who will charge on that date, and two clocks that disagree by a day is a shop shown "paid until the 13th" being charged on the 12th.
It computes a date only when the payload carries none.
What this does not check
No UPI AutoPay mandate is known to have been authorised on the product, and the notes on the one live mandate do not say which method it used. So nothing here says what a UPI app shows for these plans, or how a three-month plan registers there.
Lifecycle events are not ordered. The product refuses to let a later event
shorten a paid period, but it writes each event's status as it comes. A
delayed subscription.halted landing after a subscription.charged would set
a paying shop to suspended until the next event. That comes from reading the
code and Razorpay's sentence on ordering; it has not been observed.
The webhook checks one secret. Razorpay's validation page says to "use the old secret for webhook signature validation while retrying older requests" after a rotation, which this code would not do. And when no webhook secret is set, the handler answers 503 so that "A retryable status keeps the events queued until the secret is set." After 24 hours that is not true; the webhook is disabled. Its response time has never been measured against Razorpay's five-second limit.
None of it is changed here. This is the integration as it runs, for a product we build and operate, Easinvy Retail. Building subscription billing that survives these cases is part of SaaS product development.
Questions people ask
- How to setup AutoPay in UPI?
- Approve the merchant's mandate request in your UPI app with your UPI PIN, which RBI requires when a mandate is registered. In Google Pay the request waits under Profile, Autopay, Pending. Easinvy Retail sends the shop owner to Razorpay's checkout, which creates that request.
- Which UPI app supports AutoPay?
- NPCI's own list of apps live on UPI AutoPay names BHIM and Paytm and carries no date. Google Pay documents AutoPay in its own help pages. Easinvy Retail does not choose the app; the shop owner approves the mandate in whichever UPI app they use.
- What does it mean when UPI AutoPay is set to ₹15,000?
- Under RBI's e-mandate framework, recurring debits up to ₹15,000 each go through without extra authentication once the mandate is registered; above that, each debit needs your UPI PIN. Every Easinvy Retail plan costs less than ₹15,000 a cycle.
- Can I setup AutoPay in Gpay?
- Yes. Google Pay's help describes approving a mandate from the Pending tab under Profile and Autopay, confirmed with your UPI PIN. Its help also says the bank notifies you 24 hours before a debit, and you can pause or cancel from that notification.
Sources
Ridam Agrawal
Founder and engineer at Easinvy. Writes up the parts of building and running a multi-tenant product that were expensive to get wrong. More about the studio →
Related
CSP for a blob: iframe that prints a PDF
Why printing a PDF from a hidden iframe needs blob: in frame-src, not a data: URL, what the frame inherits from your policy, and what Chrome's PDF viewer does.
Content Security Policy for Razorpay Checkout
The Content Security Policy a live billing app ships with Razorpay Checkout, line by line: why a wildcard, why unsafe-inline, and how to prove it is on.
SaaS Product Development
Multi-tenancy, billing, and the parts founders discover too late.
All notes
Write-ups from building and running our own product.