Recurring INR with mandates
In India a renewal can only be charged without the customer present if they first approved a mandate: a standing permission, with a per-charge limit, registered at their bank. tmbilling registers the mandate through your Razorpay account, sizes its limit from the subscription, sends the pre-debit notice, and stops using a mandate the customer revokes.
| Rail | Payment method on the gateway account | Notes |
|---|---|---|
| Netbanking e-mandate | bank_transfer | INR only. Limit up to ₹10,00,000 per charge. Some banks confirm a new mandate only the next working day. |
| Card | card | Razorpay must enable recurring payments on cards for your account. |
| UPI | upi | INR only. Not yet supported for live billing. Limit up to ₹1,00,000 per charge. |
Before you begin
- A Razorpay account. Use its test keys in test mode and its live keys in live mode.
- A test API key and the client from the Quickstart.
import { Tmbilling } from "@two-minute-billing/sdk";
const tmb = new Tmbilling({
apiKey: process.env.TMBILLING_API_KEY!,
baseUrl: process.env.TMBILLING_URL,
});
// The client does not wrap every setting yet. This helper sends any request with your key.
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${process.env.TMBILLING_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.TMBILLING_API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return (await res.json()) as T;
}
Create an INR business
const biz = await tmb.businesses.create({ name: "Sahyadri Softworks", default_currency: "INR" });
curl -X POST $TMBILLING_URL/v1/businesses \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "name": "Sahyadri Softworks", "default_currency": "INR" }'
export BIZ=biz_…
Connect Razorpay
In the dashboard, open Settings → Payment gateways for the business and add Razorpay with your Key ID and Key Secret. Under payment methods, select Netbanking (eMandate), cards, or both. tmbilling registers its webhook endpoint with Razorpay for you, or shows the URL and secret to add by hand.
You can also connect the account over the API. This call needs real Razorpay keys, so it is not run on this page:
POST /v1/businesses/{biz}/gateway_accounts
Content-Type: application/json
{
"gateway_key": "razorpay",
"label": "Razorpay",
"credentials": { "key_id": "rzp_test_…", "key_secret": "…" },
"currencies": ["INR"],
"method_types": ["upi", "bank_transfer"]
}
Size the mandate limit (optional)
The customer approves a per-charge limit, and the bank refuses any debit above it. tmbilling sizes the limit from the next renewal, tax and discounts included, plus 30% headroom for plan changes. It caps the limit at ₹1,00,000 so UPI stays available, unless the renewal itself is larger. With nothing to size from yet, it asks for ₹15,000.
To change the headroom or the cap for one business, set mandate_config. headroom_bps is basis
points of the renewal, so 15000 asks for 1.5 times it:
await api("PATCH", `/v1/businesses/${biz.id}`, {
mandate_config: { headroom_bps: 15000, rail_ceiling_cap_minor: 10000000 },
});
curl -X PATCH $TMBILLING_URL/v1/businesses/$BIZ \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "mandate_config": { "headroom_bps": 15000, "rail_ceiling_cap_minor": 10000000 } }'
{
"object": "business",
"id": "biz_XXXXXXXXXXXXXXXXXXXXXX01",
"default_currency": "INR",
"mandate_config": {
"headroom_bps": 15000,
"rail_ceiling_cap_minor": 10000000
}
}
Send "mandate_config": null to go back to the defaults.
Create the customer with a phone number
Razorpay needs the customer's phone number to register a mandate.
const customer = await tmb.customers.create(biz.id, {
email: "[email protected]",
first_name: "Ravi",
last_name: "Kumar",
phone: "+91 90000 00001",
currency: "INR",
});
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"first_name": "Ravi",
"last_name": "Kumar",
"phone": "+91 90000 00001",
"currency": "INR"
}'
export CUS=cus_…
Let the customer approve the mandate
Only the customer can approve a mandate, on their bank's site or with their card. The simplest way is the hosted customer portal: create a portal session and send the customer its link. They add a bank account or a card there, and the mandate comes back as a saved payment method.
const session = await tmb.paymentMethods.createPortalSession(biz.id, customer.id);
curl -X POST $TMBILLING_URL/v1/businesses/$BIZ/customers/$CUS/portal_sessions \
-H "Authorization: Bearer $TMBILLING_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"
{
"object": "portal_session",
"id": "psess_XXXXXXXXXXXXXXXXXXXXXX01",
"status": "created",
"token": "pstk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"url": "https://billing.example.com/portal/s/pstk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"access_expires_at": "2026-10-20T20:13:12.335Z",
"expires_at": "2026-10-23T19:13:12.335Z"
}
url opens on your portal host. The link works once, and must be opened before
access_expires_at, one hour after you create it. A new customer can also approve the mandate while
they pay for their first invoice in hosted checkout.
To build the flow in your own page, start the setup over the API. It needs a connected Razorpay
account, so it is not run on this page. Open Razorpay Checkout with the returned checkout
options, then save the result with POST /v1/businesses/{biz}/customers/{cus}/payment_methods.
POST /v1/businesses/{biz}/customers/{cus}/payment_methods/setup
Content-Type: application/json
{
"provider": "razorpay",
"return_url": "https://example.com/billing/done",
"cancel_url": "https://example.com/billing"
}
Read the mandate
A payment method that rides a mandate carries a mandate object:
| Field | Meaning |
|---|---|
rail | upi, emandate or card. |
status | See below. |
max_amount_minor | The per-charge limit the bank registered. tmbilling refuses a larger debit before it calls Razorpay. null means the gateway did not report it, not "no limit". |
currency | The limit's currency. |
expires_at | When the mandate ends, if it has an end. |
reference | The mandate reference. It appears in the pre-debit notice. |
status | What it means for collection |
|---|---|
initiated | Approved by the customer, not yet confirmed by the bank. |
confirmed, active | Live. Renewals are charged off-session. |
paused | Paused by the customer. The payment method is invalid until the mandate resumes, and then it is usable again. |
rejected, cancelled | Dead. The payment method becomes invalid and is not charged again. |
spent | A single-use mandate that has been used. |
Subscribe the customer
Create the subscription as usual. With the mandate's payment method as the customer's default,
each renewal is charged without the customer. UPI and netbanking debits return pending and
settle when Razorpay reports the capture, usually within minutes.
Know the limits
- ₹15,000 per debit. Above it, Indian rules make a card or UPI debit wait for the customer to authenticate it. A netbanking e-mandate does not, so use netbanking for plans priced above ₹15,000.
- The limit cannot be raised in place. If a renewal grows past
max_amount_minor(an upgrade, more seats, tax), the debit fails withmandate_ceiling_exceededand is not retried. The customer must approve a new mandate. tmbilling warns you first: when you create a subscription whose renewal is above the mandate's limit, it emitspayment_source_mandate_insufficient.
Pre-debit notices
The customer must be told before each mandate debit: the amount, the date and the mandate
reference. tmbilling emits subscription_pre_debit_notification and sends the
pre_debit_notification email up to two days before each renewal on a mandate. The amount is the
renewal estimate, tax included. Metered usage is not in it, because it is not known yet.
Handle a revoked mandate
A customer can cancel or pause a mandate at any time in their UPI app or bank. Razorpay tells tmbilling by webhook, and that is the only notice there is. tmbilling then:
- Records the new
mandate.statusand marks the payment methodinvalid. - Stops charging it. The next renewal falls back to the customer's backup payment method, or fails and enters dunning.
- Restores the payment method by itself if a paused mandate resumes.
Ask the customer for a new mandate with a fresh portal link. A cancelled mandate cannot be revived.
Check it worked
- The customer's payment method has
mandate.statusconfirmedoractiveand amax_amount_minorabove the renewal total. - The first renewal's transaction moves from
pendingto succeeded once Razorpay reports the capture.
Common errors
| What you see | Why | What to do |
|---|---|---|
Razorpay requires a phone number for recurring payments. Add a phone number to the customer. Then try to add a card again. | The customer has no phone. | Set phone on the customer. |
The selected gateway account (…) does not allow these payment methods: … | The method is not selected on the Razorpay account. | Select it under Settings → Payment gateways. |
A debit fails with mandate_ceiling_exceeded | The charge is above the mandate's limit. | Ask the customer to approve a new mandate. |
A renewal stays pending above ₹15,000 | A card or UPI debit is waiting for the customer to authenticate it. | Ask the customer to approve the debit, or move them to a netbanking e-mandate. |
Related
India GST invoices
Charge GST from an Indian business: CGST and SGST inside your state, IGST across states, with your GSTIN and the HSN or SAC code on every invoice you issue.
Meter usage events
Send usage events from your app, count them with meters, and bill each combination of values from a rate card. Retry-safe keys, batches of 500, late events and decimals.