Create a hosted payment page or QR code for your own Paytm Business VPA, then verify the exact payment before fulfilling an order. Payment Solution is separate from OTP and requires an active plan.
1. Activate a plan and prepare your backend
Open Payment Solution in your account and activate a plan using wallet credit. Your renewal/end date and usage limits are shown there. Use the same account API key as OTP; there is no second payment API key.
Use a matching MID and VPA from a Paytm Business account you control. Either save an account in the panel and use its default/rotation settings, or pass merchant_id and vpa together when creating each link. With the full API pair, no saved Paytm Business profile is required.
The customer's payment goes to the specified business VPA, not into the MeraOTP OTP wallet. This product checks provider order status; it is not a promise of official Paytm partnership, final bank settlement, dispute resolution or protection against every form of fraud. Confirm final settlement in your merchant account where needed.
Set MERAOTP_API_KEY on your server, then copy this shared HTTP helper. Add the request examples below after the helper. The helper's json is the original response body; its HTTP status is separate.
<?php
// Put this helper at the top of your backend integration file.
function meraotp(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
{
$key = getenv('MERAOTP_API_KEY');
if (!$key) throw new RuntimeException('Set MERAOTP_API_KEY on your server first.');
$ch = curl_init('https://meraotp.in/api/v1' . $path);
$headers = array_merge([
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
], $extraHeaders);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 20,
]);
if ($payload !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_THROW_ON_ERROR));
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($raw === false) throw new RuntimeException('Connection failed: ' . $error);
// Keep error JSON too: a message_id may be returned with an uncertain send.
return ['http_status' => $status, 'json' => json_decode($raw, true, 512, JSON_THROW_ON_ERROR)];
}// Save as an .mjs backend file using Node.js with built-in fetch.
async function meraotp(method, path, payload, extraHeaders = {}) {
const key = process.env.MERAOTP_API_KEY;
if (!key) throw new Error('Set MERAOTP_API_KEY on your server first.');
const response = await fetch('https://meraotp.in/api/v1' + path, {
method,
headers: {
'Authorization': `Bearer ${key}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
...extraHeaders
},
body: payload === undefined ? undefined : JSON.stringify(payload),
signal: AbortSignal.timeout(20000),
redirect: 'error'
});
// HTTP errors do not throw in fetch. Preserve both status and error JSON.
return { httpStatus: response.status, json: await response.json() };
}2. Create a payment link and QR
POST https://meraotp.in/api/v1/payments/links
- amount Required
- A rupee amount with at most two decimal places, such as
100.00. Minimum ₹1; maximum ₹10,00,000. Use the amount stored by your server. - order_id Optional; recommended
- A unique reference for this user's order: 4–64 letters, numbers,
@ . _ -. MeraOTP generates one if omitted; supplying your own makes recovery from a timeout easier. - merchant_id + vpa Optional pair
- Send both to choose the receiving account for this link only, without saving details in the panel. The MID is case-sensitive, 8–40 letters/numbers. VPA is the matching business UPI address. Omit both to use the saved default or rotation. A single field must match an account already saved in your own panel.
- payee_name Optional
- Your business name. If omitted, MeraOTP uses the matching saved account name, then the profile name, or
Merchantwhen no profile is saved. - note Optional
- A short payment description, at most 160 characters. Do not include credentials or unnecessary personal data.
- redirect_url Optional
- Your HTTPS completion page, without embedded username/password or a fragment. On a terminal result, the hosted checkout redirects immediately with
meraotp_status,payment_idandorder_id. These browser parameters are not proof of payment.
// Add this after the meraotp() helper. Create the order in YOUR database first.
$reply = meraotp('POST', '/payments/links', [
'amount' => '100.00',
'order_id' => 'ORDER_1001', // Unique for this customer order.
'merchant_id' => 'YourExactPaytmMID', // Optional pair: your own business.
'vpa' => 'business@paytm', // Omit BOTH to use saved defaults.
'payee_name' => 'Your Business',
'note' => 'Order 1001',
'redirect_url' => 'https://example.com/payment/complete', // Optional.
]);
$result = $reply['json'];
if ($reply['http_status'] === 201 && ($result['success'] ?? false) === true) {
$paymentId = $result['data']['payment_id'];
$paymentUrl = $result['data']['payment_url'];
// Save paymentId + order_id + expected amount before redirecting the payer.
}// Create the order in your own database first. Add after the meraotp() helper.
const reply = await meraotp('POST', '/payments/links', {
amount: '100.00',
order_id: 'ORDER_1001',
merchant_id: 'YourExactPaytmMID', // Optional: your own matching MID/VPA pair.
vpa: 'business@paytm', // Omit BOTH to use saved defaults/rotation.
payee_name: 'Your Business',
note: 'Order 1001',
redirect_url: 'https://example.com/payment/complete' // Optional.
});
if (reply.httpStatus === 201 && reply.json.success === true) {
const { payment_id, payment_url } = reply.json.data;
// Save payment_id with your order and expected amount, then redirect the payer.
}Example response: HTTP 201
{
"success": true,
"data": {
"payment_id": "74b98ee1aa5c4d898718939b19f41e40",
"order_id": "ORDER_1001",
"amount": "100.00",
"currency": "INR",
"payment_url": "https://meraotp.in/pay/74b98ee1aa5c4d898718939b19f41e40",
"redirect_url": "https://example.com/payment/complete",
"qr_url": "https://meraotp.in/pay/74b98ee1aa5c4d898718939b19f41e40/qr",
"qr_png_url": "https://meraotp.in/pay/74b98ee1aa5c4d898718939b19f41e40/qr-image",
"status_url": "https://meraotp.in/pay/74b98ee1aa5c4d898718939b19f41e40/status",
"upi_uri": "upi://pay?pa=business%40paytm&pn=Your%20Business&am=100.00&cu=INR&tr=ORDER_1001",
"display_methods": {
"qr": true,
"gpay": true,
"paytm": true
},
"expires_at": "2026-10-27T11:10:00+05:30"
}
}Redirect the payer to payment_url for hosted checkout. For your own payment screen, embed qr_url (SVG) or qr_png_url (PNG), use the returned URI where appropriate, and have your backend check authenticated status. QR resources support cross-origin embedding; never expose your account key in the browser to load them.
Links expire after 10 minutes. The MID and VPA are fixed on each link: changing panel defaults or enabling rotation later does not retarget an existing order. Saved display settings are respected. Without a profile, the default QR and app options are enabled; app buttons normally appear on mobile, with a desktop fallback when QR is hidden. The customer can control these settings in Setup after saving a profile.
If redirect_url is omitted, the terminal result is shown and the hosted page attempts to close after 10 seconds. A browser may refuse to close a tab it did not open by script. The page checks status automatically; navigating back to your site is not a payment verification method.
3. Ask your backend for the final status
GET https://meraotp.in/api/v1/payments/links/PAYMENT_ID
Use the saved payment_id with the same Bearer key. No JSON body is needed. For a custom screen, let the browser ask your own backend, and let your backend query MeraOTP at sensible intervals—usually 3–5 seconds while the payer is waiting.
$paymentId = '74b98ee1aa5c4d898718939b19f41e40'; // Load for YOUR order.
$reply = meraotp('GET', '/payments/links/' . rawurlencode($paymentId));
$result = $reply['json'];
$paid = $reply['http_status'] === 200 && ($result['success'] ?? false) === true
&& ($result['data']['status'] ?? '') === 'paid';
// Also match order_id, currency and the exact expected amount in your database.
// Only then fulfil this order ONCE in a database transaction.const paymentId = '74b98ee1aa5c4d898718939b19f41e40'; // Load for your order.
const reply = await meraotp('GET', '/payments/links/' + encodeURIComponent(paymentId));
const paid = reply.httpStatus === 200 && reply.json.success === true
&& reply.json.data?.status === 'paid';
// Match order_id, currency and the exact amount to your database too.
// Only then fulfil the order once, protected by your transaction/unique key.Example paid response: HTTP 200
{
"success": true,
"data": {
"payment_id": "74b98ee1aa5c4d898718939b19f41e40",
"order_id": "ORDER_1001",
"amount": "100.00",
"currency": "INR",
"status": "paid",
"payment_url": "https://meraotp.in/pay/74b98ee1aa5c4d898718939b19f41e40",
"redirect_url": "https://example.com/payment/complete",
"expires_at": "2026-10-27T11:10:00+05:30",
"paid_at": "2026-10-27T11:04:00+05:30"
}
}| Status | What your application should do |
|---|---|
active | Payment is not confirmed. Keep the order unpaid and continue bounded status checks. |
paid | Provider identity, order and exact amount matched. Match the response to your order and fulfil once. |
failed | The latest provider check reported failure. Do not fulfil unless a later check confirms a received payment. |
expired | The checkout window ended. Do not ask the payer to use its old QR. If money was deducted, recheck the original order. |
cancelled | The checkout was cancelled. This does not prove that no money reached the merchant; a later receipt may still reconcile. |
Recently closed orders are rechecked by the background reconciliation process for up to seven days. A late confirmed payment can change a previously closed order to paid. Keep the original order reference and never charge again merely because a browser timed out.
4. Verify an order or external transaction
POST https://meraotp.in/api/v1/payments/verify
Send order_id and the expected_amount from your database. For an existing MeraOTP link, the original MID and amount are stored; you do not need to repeat the MID/VPA. A different supplied MID is rejected. For an external order, pass its matching merchant_id, or omit it to use your saved profile. VPA is not a verification parameter.
$reply = meraotp('POST', '/payments/verify', [
'order_id' => 'ORDER_1001',
'expected_amount' => '100.00', // Read from your database, never the browser.
// 'merchant_id' => 'YourExactPaytmMID', // For a standalone external order.
]);
$result = $reply['json'];
$paid = $reply['http_status'] === 200 && ($result['success'] ?? false) === true
&& ($result['data']['payment_status'] ?? '') === 'verified';
// Match the order and amount, then fulfil idempotently. Do not use success alone.const reply = await meraotp('POST', '/payments/verify', {
order_id: 'ORDER_1001',
expected_amount: '100.00', // From your database, not the payer's request.
// merchant_id: 'YourExactPaytmMID' // Optional for a standalone external order.
});
const paid = reply.httpStatus === 200 && reply.json.success === true
&& reply.json.data?.payment_status === 'verified';
// Match the order and amount, then fulfil once. A successful API call can be pending.Example verified response: HTTP 200
{
"success": true,
"data": {
"verification_id": "e615e0ce06324394a109017b4271b32e",
"payment_status": "verified",
"provider_code": "01",
"transaction_id": "EXAMPLE_PROVIDER_TRANSACTION_ID",
"amount": "100.00",
"currency": "INR",
"check_count": 3,
"last_checked_at": "2026-10-27T11:04:00+05:30"
}
}The verification API uses payment_status: verified, while the link-status API uses status: paid. Do not confuse these fields. A successful API response can also report pending, unknown or failed; success: true alone is not proof of payment.
MeraOTP keeps one verification record per order and updates its check count and last-checked time. For a brand-new external payment, use the merchant order reference—not an arbitrary bank UTR or screenshot. A provider transaction ID already recorded by MeraOTP can also be looked up through the panel's transaction-verification tool.
Optional: receive payment webhooks
Configure an HTTPS webhook in Payment Solution Setup and save its separate secret securely. This is optional: API-only users can use authenticated status checks without a saved business profile. If you want webhooks, complete the panel setup; creating a link with a request-only MID/VPA does not create a webhook configuration.
Events are payment.succeeded, payment.failed, payment.expired and payment.cancelled. The X-MeraOTP-Signature header is sha256= followed by the HMAC-SHA256 of the original raw request body using your webhook secret. This validates an incoming webhook; normal API calls still use only your Bearer API key.
PHP and Node.js signature validation
<?php
// Signature validation for your webhook handler; this does not fulfil orders.
function readMeraotpWebhook(string $rawBody, string $receivedSignature): array
{
$secret = getenv('MERAOTP_WEBHOOK_SECRET');
if (!$secret) throw new RuntimeException('Webhook secret is not configured.');
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $receivedSignature)) {
throw new RuntimeException('Invalid webhook signature.');
}
return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}
// In your route: pass file_get_contents('php://input') and
// $_SERVER['HTTP_X_MERAOTP_SIGNATURE'] ?? ''. Reject invalid signatures.
// Match your order + exact amount and process once BEFORE returning HTTP 2xx.import crypto from 'node:crypto';
// Pass the ORIGINAL raw Buffer, before any JSON parser changes the request.
function readMeraotpWebhook(rawBody, receivedSignature = '') {
const secret = process.env.MERAOTP_WEBHOOK_SECRET;
if (!secret) throw new Error('Webhook secret is not configured.');
if (!Buffer.isBuffer(rawBody)) throw new Error('Expected the raw body Buffer.');
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(rawBody).digest('hex');
const received = Buffer.from(receivedSignature);
const valid = received.length === Buffer.byteLength(expected)
&& crypto.timingSafeEqual(Buffer.from(expected), received);
if (!valid) throw new Error('Invalid webhook signature.');
return JSON.parse(rawBody.toString('utf8'));
}
// Match your order, currency and exact amount; process once before HTTP 2xx.
// Reject invalid signatures and do not trust the browser redirect as payment proof.Example success event
{
"event": "payment.succeeded",
"created_at": "2026-10-27T11:04:00+05:30",
"data": {
"payment_id": "74b98ee1aa5c4d898718939b19f41e40",
"order_id": "ORDER_1001",
"amount": "100.00",
"currency": "INR",
"status": "paid",
"transaction_id": "EXAMPLE_PROVIDER_TRANSACTION_ID",
"provider_code": null,
"paid_at": "2026-10-27T11:04:00+05:30"
}
}After validating the signature, match the order, currency and exact amount to your own records. Use an atomic database update or unique fulfilment record so repeated notifications cannot credit or ship twice. Accept a late confirmed success even if an earlier event reported expiry or failure.
Return HTTP 2xx only after safely accepting the event. Network or endpoint failures can prevent webhook delivery; reconcile with the authenticated status API rather than relying on the webhook or browser redirect alone. Your business fulfilment logic is not implemented by the signature snippets.
Need a hand?
Send support the endpoint, request time, error code and a non-secret reference. Never send your API key, password, webhook secret or an OTP.
Contact developer support