Hosted payment page
Instead of showing the UPI details yourself, send the payer to our hosted payment page. You create the payment with one more parameter, checkout, and get back the page’s address, checkout.url. The page runs on your account’s own checkout domain in your brand: the payer pays with their UPI app, tells us their UTR, and comes back to your site. You learn what happened from the payment itself, never from the way back.
The page works without JavaScript, on a phone and on a desktop, as a whole page, in an iframe on your site, and in your app’s WebView.
Before you start
Section titled “Before you start”- A key of the account (API keys): a test key while you build, which makes test payments with the same page.
- The account’s return hosts: the host of every
return_urlyou will send, such asshop.example. They are in the account’s checkout settings in the merchant portal (Settings › Developers › Checkout); our team can set them for you too. - The account’s checkout domain. We give every account its own domain for the page from our pool and rotate it; you never name it in a request, it is in
checkout.url. An account whose domain is not ready yet gets503checkout_unavailableand no payment is made: ask your account manager. - To show the page in an iframe, the account’s embed hosts too: see In an iframe.
Create the payment
Section titled “Create the payment”-
Create the payment with
checkout. SendPOST /paymentsas for any payment, withcheckout.return_url: where the payer goes back to once they have paid. Put what you need to find the order again in the URL yourself.A payment with our hosted page curl https://api.novaro.ink/payments \-H "Authorization: Bearer sk_live_in_…" \-H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \-H "Content-Type: application/json" \-d '{"amount": 200000,"currency": "INR","payment_method_data": { "type": "upi" },"merchant_reference": "order_8812","customer": { "id": "cus_4921" },"checkout": { "return_url": "https://shop.example/orders/8812/paid" }}'The answer is
201with the payment: itschannelishosted, andcheckoutholds the page.Response · 201 Created (shortened) {"id": "pay_in_01J9…","status": "awaiting_payer","amount": 200000,"amount_payable": 200000,"currency": "INR","channel": "hosted","expires_at": 1760357700,"checkout": {"url": "https://pay-k7m2q.example/c/3fZ…","status": "not_opened","opened_at": null},"next_action": {"type": "upi_display_details","upi_display_details": { "vpa": "…", "amount": "2000.00", "reference_code": "K7M2Q" }}}next_actionis there as for any payment: the page shows these details to the payer, and you may show them too. -
Send the payer to
checkout.url. Redirect them to it, open it as a whole page, in an iframe on your site or in your app’s WebView. The address holds a token that is the payment’s: do not shorten it, cache it or share it between payers. It opens until 24 hours after the payment expires. -
Learn the result from the payment. The page tells the payer; it does not tell you. Read the payment with
GET /payments/{id}when the payer is back, and until it is final:statusWhat it means awaiting_payerThe payer has not paid yet, or has not said so. processingThe payer said they paid and gave their UTR. We are looking for the money: it succeeds when we see it, and fails if we have not seen it 24 hours later. succeededPaid. Deliver the order. expiredThe payer did not pay in time. A late payment can still make it succeed. failedIt will not be paid. Your webhook endpoints get an event for each of these changes: poll the payment only as a fallback.
The checkout parameter
Section titled “The checkout parameter”| Field | |
|---|---|
return_url |
Required. An https URL of at most 2048 characters, on one of your account’s return hosts, with no credentials and no port but 443. Another host, http, or a value that is not a URL is 400 return_url_not_allowed; one longer than 2048 characters, or with characters outside printable ASCII, is 400 parameter_invalid. It is checked when the payment is made, not on an idempotent retry: a host taken off the list since does not change the first answer. |
locale |
Optional: en, the only value for now. The page speaks its market’s language: English in India, Spanish (es-AR) in Argentina. |
The checkout object
Section titled “The checkout object”A payment made with checkout carries it in every answer; a payment made without it has checkout: null.
| Field | |
|---|---|
url |
The page. Made anew for every answer: it is the same address each time. |
status |
not_opened: the payer has not opened the page. opened: they have seen it. confirmed: they sent their UTR on it (in Argentina, said they transferred, or sent the COELSA id). closed: it no longer opens. |
opened_at |
When the payer first opened the page, in seconds since the Unix epoch; null until then. |
The page is for the payer you send there. A payment you show yourself (no checkout) still gets next_action.upi_display_details.hosted_instructions_url, our page with the same details, for a payer you would rather send there than show them yourself: it has no return.
What the payer sees
Section titled “What the payer sees”- Pay. The amount to pay, the UPI ID with a Copy button, a QR code, buttons that open PhonePe and Paytm on the same details, and the time left to pay. Under them a form for the payer’s UTR, “I have paid”, once they have paid.
- Checking. The payer sent their UTR: the page says the payment is being checked, with the UTR read-only, and follows the payment on its own. The payment is
processing. - Paid. The payment succeeded. With autoreturn on, the page goes back to
return_urlafter a few seconds; otherwise it shows “Back to your name”. - Expired. The time to pay ran out. A payer who paid late can still send their UTR here.
- Not completed. The payment failed: the page tells the payer to contact you if money left their account.
The payer can ask for another UPI ID when their app refuses the one shown, and a Having trouble? sheet helps with the usual problems; the payment, its amount and its reference code stay the same. The options say which of these the page shows.
In Argentina
Section titled “In Argentina”An ar_bank_transfer payment’s page is in Spanish, with amounts as $ 25.000.
- Tu DNI, CUIT o CUIL. When you sent no
customer.tax_id, the page asks the payer for it before it shows where to pay: we find their transfer by it. A CUIT or CUIL whose prefix was mistyped is taken as its DNI; any other number that is not one is refused on the page. - Pay. The amount, the CVU and its alias, each with a Copiar button, four steps, and the payer’s tax id, masked, as the account to transfer from. The payer transfers from their bank or wallet and taps Ya transferí.
- Checking. After «Ya transferí» the page says the payment is being checked. The payment stays
awaiting_payeruntil the money is found. Half an hour after «Ya transferí», if the money is not found yet, the page asks for the COELSA id on the receipt, which you would send ascoelsa_id; a payer who never tapped it is asked once the time to pay ran out. - Paid. The page says what arrived: a transfer of another amount is credited as it came, and the page says so.
The payer can ask for another CVU, and ¿Tenés problemas? covers a bank that shows another holder, a transfer of another amount and one that failed.
Getting the payer back
Section titled “Getting the payer back”The page sends the payer to return_url exactly as you sent it, with nothing added: no status, no payment id. Keep your order’s id in the URL, read the payment with GET /payments/{id}, and show the payer what its status says. processing is the usual state on the way back: the payer said they paid, and the money is confirmed a little later.
- With autoreturn on in the account’s options, the Paid screen goes back by itself after the seconds you set (1 to 30); with it off, the payer taps “Back to your name”. Every screen has that link while the payment has a
return_url. - The payer may never come back: they closed the tab, or paid from the app and never returned to the page. Do not wait for the return to deliver: the payment’s status is the truth, whichever way you learn it.
- A page in an iframe does not move your site: see the next section.
In an iframe
Section titled “In an iframe”-
Add your site’s host to the account’s embed hosts (up to 20). The page refuses every other frame: its
Content-Security-Policynames your hosts asframe-ancestors, and a browser shows nothing to a frame that is not listed. -
Put
checkout.urlin the frame. Give it the clipboard, so the Copy buttons work inside it; do not sandbox it, or allow forms, scripts, same-origin, popups and top navigation by user activation, which the app buttons and the Back link use.The frame <iframeid="checkout"src="https://pay-k7m2q.example/c/3fZ…"allow="clipboard-write"style="width: 100%; border: 0"></iframe> -
Listen for the page’s messages. On every screen the page posts a message to its parent, to your embed hosts only:
The message { "type": "psp.checkout", "status": "succeeded", "height": 612 }statusisawaiting_payer,processing,succeeded,expiredorfailed;heightis the page’s height in CSS pixels, to size the frame. Check the message’s origin against the page’s, and its type: anything else on your site may post messages too.Your site const frame = document.getElementById("checkout");const pageOrigin = new URL(frame.src).origin;window.addEventListener("message", (event) => {if (event.origin !== pageOrigin || event.data?.type !== "psp.checkout") return;frame.style.height = `${event.data.height}px`;if (event.data.status === "succeeded" || event.data.status === "expired" || event.data.status === "failed") {// Close the frame and show the order; read the payment from your server first.}});
Inside a frame the Paid screen does not go back to return_url by itself: browsers keep a frame from moving the page around it without a tap. Your site moves on at the succeeded message, and the Back link, which opens at the top, stays for the payer. Keep reading the payment from your server as well: a message is lost when the frame fails to load, and the payer may pay from the app without coming back to the frame.
In a WebView
Section titled “In a WebView”The page tells a WebView from a browser (Android’s WebView, iOS’s WKWebView) and hides the PhonePe and Paytm buttons there: a plain WebView cannot open another app’s link and would show the payer an error. The UPI ID, the QR code and the UTR form stay, and “Download QR” becomes a hint to take a screenshot, so the payer can always pay.
To show the app buttons in your app, let your WebView hand the apps’ links to the system, then turn on App buttons in WebView in the account’s options. The links use the schemes upi:, phonepe: and paytmmp:; open any link that is not http or https outside the WebView, and keep the WebView on the page.
webView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val url = request.url if (url.scheme == "http" || url.scheme == "https") return false return try { view.context.startActivity(Intent(Intent.ACTION_VIEW, url)) true } catch (e: ActivityNotFoundException) { // The app is not installed: the page stays, and the payer uses the UPI ID or the QR. true } }}func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard let url = navigationAction.request.url, let scheme = url.scheme?.lowercased(), scheme != "http", scheme != "https" else { decisionHandler(.allow) return } UIApplication.shared.open(url) // nothing happens when the app is not installed decisionHandler(.cancel)}Handle the way back as well: when the WebView navigates to your return host, close it and show the order from the payment’s status, as a site does. With the apps’ links handled and the option on, the page shows the buttons in your WebView as it does in a browser.
Your brand and the page’s options
Section titled “Your brand and the page’s options”The page’s look and what it shows are the account’s checkout settings, in the merchant portal (Settings › Developers › Checkout); our team can change them for you. A change reaches the page within a second, on its next load.
| Setting | |
|---|---|
| Name, logo, icon | The name on the page (your merchant name when empty), the logo in its header, the icon in the browser’s tab. |
| Colours, corners | The background, the buttons and the accent, as #RRGGBB; a pair that does not contrast enough (4.5:1) is refused with the nearest colour that does. Corners sharp, rounded or pill. |
| Ways to pay | The UPI ID, the QR code, the PhonePe and Paytm buttons and their order. At least one stays on, and a payer who would be left with none (the app buttons hidden in a WebView, or an app’s link switched off for the market) sees the UPI ID. |
| Download QR | A button that saves the QR as an image, hidden above an amount you set: banks refuse a QR from the gallery for large amounts. |
| Timer | The time left to pay, from the payment’s expires_at. |
| UTR form | The field for the payer’s UTR. Without it, the payer can only pay; you confirm the payment yourself with POST /payments/{id}/confirmations, or we match the money. |
| Get new UPI ID | Lets the payer ask for other details when their app refuses the UPI ID shown. |
| Autoreturn | Back to return_url by itself after 1 to 30 seconds on the Paid screen. |
| App buttons in WebView | Shows the PhonePe and Paytm buttons in a WebView: see In a WebView. |
| Return hosts, embed hosts | The hosts return_url may name, and the hosts that may show the page in a frame. |
The options shape the page only. The API’s next_action always carries every way to pay, whatever the page shows.
Trying it out
Section titled “Trying it out”Create a payment with checkout and a test key: its page looks and behaves as a live payment’s, at a URL whose token starts with t.. Type a magic UTR into the page’s UTR form to take the payment to the status you want to see.