Build payments in minutes, not days.
Welcome. You're here because you need to accept payments in your application — and you want it done cleanly, securely, and without the usual headache. RoninPay is a developer-first payment gateway built for the modern web. It gives you a single API endpoint to create a payment, a hosted checkout page your customer completes, and real-time webhooks that tell you exactly what happened. No complex SDKs to install. No merchant accounts to configure separately. Just your credentials, a correctly-signed request, and you're live.
Get your gear ready
Think of this as your checklist before the journey starts. Everything below needs to be in hand before you write a single line of integration code. Skip any of these and you'll hit a wall.
-
A RoninPay merchant account — Sign up at the merchant portal. This gives you access to the dashboard where your credentials live.
-
Your Merchant ID — A UUID that identifies your business (e.g.
a5973138-8f7b-4255-ae5e-81a71112b5ec). Find it under Settings in the merchant panel. -
Your API Key — A secret key starting with
sk_live_. Go to API & Webhooks in the merchant panel and generate one. Keep this private — it never goes in frontend code. -
A server-side backend — Node.js, Python, PHP, or anything that can make HTTP requests. You cannot call this API from a browser — the signing logic requires your secret key, which must stay on the server.
-
A public webhook URL — A live HTTPS endpoint on your server where RoninPay can POST payment events. For local development, use ngrok to expose your localhost.
Store your Merchant ID and API Key in environment variables (.env). Never hardcode them in source files or commit them to version control.
How a payment flows
Before diving into code, understand the full journey a payment takes — from your server calling the API, to your customer completing payment, to your server receiving confirmation. There are three actors: your server, RoninPay, and your customer.
Step 1 is the only API call you make. Everything else — the payment UI, card processing, fraud checks — RoninPay handles. Your job after Step 1 is to: redirect the customer, then listen for the webhook that tells you the outcome.
Do not rely on the redirectUrl alone to confirm a payment. Customers can close their browser mid-flow. Always trust the webhook as the source of truth for payment status.
Your credentials, explained
RoninPay uses HMAC-SHA256 request signing — not simple API key headers. This means every request you send is cryptographically signed with a unique signature that proves it came from you and hasn't been modified in transit. There are no bearer tokens to refresh, no OAuth flows to implement. Just your two credentials working together.
| Credential | Format | Where to find it | Keep it secret? |
|---|---|---|---|
| Merchant ID | UUID string | Merchant Panel → Settings | Reasonably — it's used in signatures but not alone |
| API Key | sk_live_... | Merchant Panel → API & Webhooks | Absolutely. Never expose this anywhere. |
Every request needs these four headers
| Header | Value | Why it exists |
|---|---|---|
| Content-Type | application/json | Tells the server your body is JSON |
| X-Timestamp | Unix seconds (string) | Prevents replay attacks — requests older than 5 minutes are rejected |
| X-Idempotency-Key | UUID v4 | Prevents duplicate payments if you retry a failed network call |
| X-Signature | HMAC hex string | Cryptographic proof the request is authentic and unmodified |
How to sign a request
Signing feels complex the first time. Once you understand it, it's just three lines of code that run before every API call. Here's the exact algorithm — step by step — with the reasoning behind each step so it actually makes sense.
Hash your raw API key with SHA-256
RoninPay never stores your raw API key in its database — it stores the SHA-256 hash of it. This means the secret used to create your HMAC signature isn't your raw key, it's the hash of your key. This is a security design: even if the database were compromised, an attacker couldn't reconstruct your real API key.
const crypto = require('crypto'); // Your raw API key from the merchant panel const apiKey = process.env.RONIN_API_KEY; // Hash it — this is what the backend uses as the HMAC secret const apiKeyHash = crypto .createHash('sha256') .update(apiKey) .digest('hex');
Cache the hash — you only need to compute it once per process startup, not per request. It'll always produce the same output for the same API key.
Build the string-to-sign
Concatenate three things in exact order with no separator between them: your Merchant ID, the current Unix timestamp in seconds, and the full JSON body as a string. This ties the signature to your identity, a specific moment in time, and the exact request body — meaning altering any of these three things will make the signature invalid.
const merchantId = process.env.RONIN_MERCHANT_ID; const timestamp = Math.floor(Date.now() / 1000); // Unix seconds const payload = JSON.stringify(requestBody); // The JSON body string // Concatenate with NO separators — order matters exactly const stringToSign = merchantId + timestamp + payload;
The JSON body you sign and the JSON body you send in the request must be identical. Do not re-serialize the object after signing — store the string and send it as-is.
Generate the HMAC-SHA256 signature
Use the hashed API key from Step 1 as the HMAC secret, and sign the string from Step 2. The result is a hex-encoded digest — that's your signature.
// Use the HASH as the secret — NOT the raw API key const signature = crypto .createHmac('sha256', apiKeyHash) .update(stringToSign) .digest('hex');
Attach all four headers to your request
You now have everything you need. Add the four required headers to every API call you make.
const headers = { 'Content-Type': 'application/json', 'X-Timestamp': timestamp.toString(), 'X-Idempotency-Key': crypto.randomUUID(), 'X-Signature': signature, };
Generate a fresh timestamp and idempotency key for every new payment. The timestamp must be within 5 minutes of server time or the request will be rejected with a 401.
Endpoints
RoninPay's payment API is intentionally minimal — one endpoint to create a payment, one to check its status. Complexity lives in the authentication layer, not the API surface.
This is the only API call you need to initiate a payment. Send a signed request with your payment details, and RoninPay responds with a paymentUrl. Redirect your customer there — they complete the payment on RoninPay's hosted page and land on your redirectUrl when done.
Request body parameters
| Field | Type | Required | Description |
|---|---|---|---|
| amount | number | Required | Amount in INR as an integer. 200 means ₹200. No paise fractions. |
| currency | string | Required | Must be "INR" — only Indian Rupees are supported currently. |
| reference | string | Required | Your internal order ID. Use this to match the webhook event back to the order in your database. |
| redirectUrl | string | Required | Where to send the customer after payment (success or failure). Must be a valid HTTPS URL. |
| description | string | Optional | A human-readable note shown on the checkout page (e.g. "Premium Plan - 1 month"). |
Full example — Node.js
const crypto = require('crypto'); const MERCHANT_ID = process.env.RONIN_MERCHANT_ID; const API_KEY = process.env.RONIN_API_KEY; const BASE_URL = 'http://api.rpy.life/api/v1'; async function createPayment(order) { const timestamp = Math.floor(Date.now() / 1000); const body = { amount: order.amount, currency: 'INR', reference: order.id, redirectUrl: order.returnUrl, description: order.description, }; const bodyStr = JSON.stringify(body); const keyHash = crypto.createHash('sha256').update(API_KEY).digest('hex'); const signature = crypto .createHmac('sha256', keyHash) .update(MERCHANT_ID + timestamp + bodyStr) .digest('hex'); const res = await fetch(`${BASE_URL}/payments`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Timestamp': String(timestamp), 'X-Idempotency-Key': crypto.randomUUID(), 'X-Signature': signature, }, body: bodyStr, }); return res.json(); } // Usage const result = await createPayment({ amount: 499, id: 'ORDER_' + Date.now(), returnUrl: 'https://yoursite.com/payment/done', description: 'Premium Plan - 1 month', }); if (result.success) { // Redirect customer to RoninPay's checkout res.redirect(result.paymentUrl); }
Response
{
"success": true,
"paymentId": "pay_abc123xyz456",
"paymentUrl": "https://pay.rpy.life/checkout/pay_abc123xyz456",
"reference": "ORDER_001",
"amount": 499,
"currency": "INR",
"status": "pending",
"createdAt": "2024-05-30T10:00:00.000Z"
}
{
"success": false,
"error": "Invalid signature",
"code": "AUTH_FAILED"
}
Store the paymentId in your database alongside the order. You'll need it to look up payment status and to match incoming webhook events.
Poll this endpoint if you need to check the current status of a payment outside of webhooks — for example when the customer lands on your redirectUrl and you want to confirm their payment before showing a success page.
Path parameters
| Parameter | Type | Description |
|---|---|---|
| paymentId | string | The paymentId returned when you created the payment (e.g. pay_abc123xyz456) |
async function getPayment(paymentId) { const timestamp = Math.floor(Date.now() / 1000); const keyHash = crypto.createHash('sha256').update(API_KEY).digest('hex'); // For GET requests, body is empty string const signature = crypto .createHmac('sha256', keyHash) .update(MERCHANT_ID + timestamp + '') .digest('hex'); const res = await fetch( `${BASE_URL}/payments/${paymentId}`, { headers: { 'X-Timestamp': String(timestamp), 'X-Idempotency-Key': crypto.randomUUID(), 'X-Signature': signature, } } ); return res.json(); }
Payment status values
| Status | Meaning |
|---|---|
| pending | Payment created, customer has not yet completed checkout |
| success | Payment completed and confirmed — safe to fulfil the order |
| failed | Payment attempt failed — customer can try again with a new payment |
| refunded | Refund has been issued for this payment |
Webhooks — your most important tool
A webhook is a POST request that RoninPay sends to a URL on your server the moment something happens to a payment. It's faster and more reliable than polling the GET endpoint. Think of it as RoninPay calling you, rather than you calling RoninPay. This is how you actually know if a payment succeeded.
Log in to the Merchant Panel, go to API & Webhooks, find the Webhook section, and click Setup. Enter your server's public HTTPS URL (e.g. https://yoursite.com/webhooks/ronin) and save. RoninPay will immediately start sending events there.
Event types
Webhook payload shape
{
"type": "payment.success", // The event type
"paymentId": "pay_abc123xyz456", // RoninPay's payment ID
"reference": "ORDER_001", // Your original order ID
"amount": 499, // Amount in INR
"currency": "INR",
"status": "success",
"timestamp": 1717000980, // Unix timestamp of the event
"signature": "3a7f8c...d4e2" // Verify this — see below
}
Webhook receiver — complete example
const express = require('express'); const crypto = require('crypto'); const app = express(); app.post('/webhooks/ronin', express.json(), (req, res) => { // Step 1: Respond 200 immediately — do NOT wait for processing res.status(200).json({ received: true }); const event = req.body; // Step 2: Verify the webhook signature const keyHash = crypto.createHash('sha256').update(process.env.RONIN_API_KEY).digest('hex'); const strToVerify = process.env.RONIN_MERCHANT_ID + event.timestamp + JSON.stringify(event); const expected = crypto.createHmac('sha256', keyHash).update(strToVerify).digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(event.signature))) { console.error('Invalid webhook signature — ignoring'); return; } // Step 3: Handle the event switch (event.type) { case 'payment.success': // Mark order paid, send confirmation email, release goods markOrderPaid(event.reference, event.paymentId); break; case 'payment.failed': // Release held inventory, notify customer handlePaymentFailed(event.reference); break; case 'payment.refunded': handleRefund(event.reference); break; } });
Why verify webhooks?
Anyone on the internet can POST a fake payment.success event to your webhook URL. If you don't verify the signature, you could mark orders as paid when no real payment happened. Always validate the signature field using the same HMAC algorithm you use for outgoing requests. The code is already included in the webhook receiver example above — use crypto.timingSafeEqual (not ===) to prevent timing attacks.
Never use a plain === or == comparison to check signatures. These are vulnerable to timing attacks. Always use crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), or hash_equals (PHP).
Error codes
Every error response has the same shape: success: false, a human-readable error string, and a machine-readable code. Use the code field to handle errors programmatically.
success field in the response body to confirm.
error message — it will name the offending field.
X-Idempotency-Key that was already used. This means the original request succeeded — check the payment status instead of retrying.
Error response shape
{
"success": false,
"error": "Timestamp is more than 5 minutes old",
"code": "INVALID_TIMESTAMP"
}
Idempotency — never charge twice
Networks fail. Servers timeout. Retries happen. Without idempotency, retrying a failed payment request could create two separate payments. The X-Idempotency-Key header is your safety net: if RoninPay receives two requests with the same key, it returns the original response without creating a duplicate payment.
Use a fresh UUID for every new payment. If you're retrying the exact same payment after a timeout (not a new order, the same one), reuse the same idempotency key. The server will return the original result without charging again.
// New order → new key every time const idempotencyKey = crypto.randomUUID(); // → "a3f4d2e1-8c7b-4f5a-9b2e-1d3c5f7a8e9b" // Retrying same order after timeout → reuse the key you generated first const existingKey = 'a3f4d2e1-8c7b-4f5a-9b2e-1d3c5f7a8e9b'; // saved from first attempt // RoninPay returns HTTP 409 on duplicate → check existing payment status
Complete integration module
Copy this entire file into your project. Set your environment variables and you're ready to accept payments. All the signing logic is encapsulated — you just call createPayment().
// roninpay.js — drop this in your project // .env: RONIN_MERCHANT_ID=... RONIN_API_KEY=sk_live_... const crypto = require('crypto'); const MERCHANT_ID = process.env.RONIN_MERCHANT_ID; const API_KEY = process.env.RONIN_API_KEY; const BASE_URL = 'http://api.rpy.life/api/v1'; const _keyHash = () => crypto.createHash('sha256').update(API_KEY).digest('hex'); const _sign = (timestamp, body) => crypto.createHmac('sha256', _keyHash()) .update(MERCHANT_ID + timestamp + body) .digest('hex'); async function createPayment({ amount, reference, redirectUrl, description = '' }) { const ts = Math.floor(Date.now() / 1000); const body = JSON.stringify({ amount, currency: 'INR', reference, redirectUrl, description }); const res = await fetch(`${BASE_URL}/payments`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Timestamp': String(ts), 'X-Idempotency-Key': crypto.randomUUID(), 'X-Signature': _sign(ts, body), }, body, }); return res.json(); } async function getPayment(paymentId) { const ts = Math.floor(Date.now() / 1000); const res = await fetch(`${BASE_URL}/payments/${paymentId}`, { headers: { 'X-Timestamp': String(ts), 'X-Idempotency-Key': crypto.randomUUID(), 'X-Signature': _sign(ts, ''), } }); return res.json(); } module.exports = { createPayment, getPayment };
You're done. Set RONIN_MERCHANT_ID and RONIN_API_KEY in your environment, register your webhook URL in the merchant panel, and call createPayment() from your order flow. That's the entire integration.