Mobile recharge is one of the most-used API integrations in Indian fintech. Whether you're building a wallet, a reward redemption platform, or a full recharge app, the core flow is the same: user enters a mobile number, your app processes the recharge, and you confirm success in real time.
In this tutorial, you'll build a complete, production-ready recharge integration in Node.js using the API Express Mobile Recharge API. We'll cover authentication, error handling, webhooks, testing, and deployment — everything you need to ship.
By the end, you'll have working code you can drop into any Express application.
Prerequisites & Setup
Before starting, make sure you have:
- Node.js 18 or later — we'll use native
fetch(no external HTTP library needed) - npm or yarn — for dependency management
- A free API Express account — sign up here if you don't have one
- Your API key — available in your dashboard immediately after signup
Basic familiarity with Express and async/await is assumed. If you're new to Node.js, check out our introductory guide first.
Step 1: Project Setup
Create a new Node.js project and install dependencies:
Terminalmkdir recharge-demo
cd recharge-demo
npm init -y
npm install express dotenv
That's it — only two dependencies:
- express — for the HTTP server
- dotenv — to load API keys from environment variables
We'll use the native fetch API (available in Node.js 18+) instead of adding an HTTP client library. Fewer dependencies means fewer things to break in production.
Step 2: Environment Variables
Create a .env file in your project root. Never hardcode API keys in source code:
API_KEY=sk_test_your_sandbox_key_here
API_BASE_URL=https://sandbox.api.apiexpress.in
WEBHOOK_SECRET=whsec_your_webhook_secret_here
PORT=3000
.env to your .gitignore. Committing API keys to Git — even private repos — is one of the most common causes of key leaks. Do it now before you forget.Add these lines to your .gitignore:
node_modules/
.env
*.log
Step 3: Create the Recharge Client
Create a small client module to encapsulate the recharge API calls. This keeps your endpoint code clean and makes the integration easy to reuse.
recharge-client.js// recharge-client.js
require('dotenv').config();
class RechargeClient {
constructor() {
this.apiKey = process.env.API_KEY;
this.baseUrl = process.env.API_BASE_URL;
}
async recharge({ mobile, amount, operator = 'auto', refId }) {
const response = await fetch(`${this.baseUrl}/v1/recharge`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
mobile,
amount,
operator,
ref_id: refId
})
});
const data = await response.json();
if (!response.ok) {
const error = new Error(data.message || 'Recharge failed');
error.code = data.code;
error.statusCode = response.status;
error.requestId = data.request_id;
throw error;
}
return data;
}
async getTransaction(transactionId) {
const response = await fetch(
`${this.baseUrl}/v1/recharge/${transactionId}`,
{
headers: {
'Authorization': `Bearer ${this.apiKey}`
}
}
);
const data = await response.json();
if (!response.ok) {
const error = new Error(data.message || 'Transaction lookup failed');
error.code = data.code;
error.statusCode = response.status;
throw error;
}
return data;
}
}
module.exports = RechargeClient;
Two things to note about this client:
- It throws structured errors. Any non-2xx response becomes an Error object with
code,statusCode, andrequestIdfields — so your endpoint can handle them precisely. - It uses
ref_idfor idempotency. Passing a unique reference per recharge means retries won't process the same transaction twice.
Step 4: Build the Recharge Endpoint
Create the main server file with a single POST endpoint that accepts a recharge request:
server.js// server.js
require('dotenv').config();
const express = require('express');
const RechargeClient = require('./recharge-client');
const app = express();
const client = new RechargeClient();
app.use(express.json());
app.post('/api/recharge', async (req, res) => {
const { mobile, amount } = req.body;
// Basic validation
if (!mobile || !/^[6-9]\d{9}$/.test(mobile)) {
return res.status(400).json({
error: 'Invalid mobile number'
});
}
if (!amount || amount < 10 || amount > 5000) {
return res.status(400).json({
error: 'Amount must be between ₹10 and ₹5,000'
});
}
try {
// Generate idempotency key
const refId = `recharge_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const result = await client.recharge({
mobile,
amount,
refId
});
return res.json({
success: true,
transactionId: result.transaction_id,
operator: result.operator,
amount: result.amount,
status: result.status
});
} catch (error) {
console.error('Recharge failed:', error.code, error.message);
return res.status(error.statusCode || 500).json({
success: false,
error: error.message,
code: error.code
});
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
That's the complete recharge endpoint. About 40 lines of code, and it's production-ready for the happy path.
Let's test it. Start the server:
Terminalnode server.js
Then make a test recharge request in another terminal:
Terminalcurl -X POST http://localhost:3000/api/recharge \
-H "Content-Type: application/json" \
-d '{"mobile": "9876543210", "amount": 199}'
You should get a JSON response with a successful transaction. If you're using sandbox keys, no charges apply.
9876543210 for success, 9000000001 for operator failure, or an invalid number to see validation in action.Step 5: Handle Errors Gracefully
The simple endpoint above handles the happy path. Production code needs to handle every failure type gracefully. Let's expand the error handling:
server.js (updated endpoint)app.post('/api/recharge', async (req, res) => {
const { mobile, amount } = req.body;
// Input validation
if (!mobile || !/^[6-9]\d{9}$/.test(mobile)) {
return res.status(400).json({
success: false,
error: 'Please enter a valid 10-digit Indian mobile number'
});
}
if (!amount || amount < 10 || amount > 5000) {
return res.status(400).json({
success: false,
error: 'Recharge amount must be between ₹10 and ₹5,000'
});
}
try {
const refId = `recharge_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const result = await client.recharge({ mobile, amount, refId });
return res.json({
success: true,
transactionId: result.transaction_id,
operator: result.operator,
amount: result.amount,
status: result.status,
message: result.message
});
} catch (error) {
console.error('Recharge error:', {
code: error.code,
statusCode: error.statusCode,
requestId: error.requestId,
message: error.message
});
// Map API errors to user-friendly messages
const errorMap = {
'INVALID_MOBILE': {
status: 400,
message: 'This mobile number is not valid. Please check and try again.'
},
'INVALID_PARAMETER': {
status: 400,
message: 'Invalid input. Please check your request and try again.'
},
'RATE_LIMIT_EXCEEDED': {
status: 429,
message: 'Too many requests. Please wait a moment and try again.'
},
'QUOTA_EXCEEDED': {
status: 503,
message: 'Service temporarily unavailable. Please try again later.'
},
'INTERNAL_ERROR': {
status: 500,
message: 'Recharge failed due to a technical error. Please try again.'
},
'RECHARGE_FAILED': {
status: 422,
message: 'The operator declined this recharge. Please check the number and amount.'
}
};
const mapped = errorMap[error.code] || {
status: error.statusCode || 500,
message: 'Recharge could not be completed. Please try again.'
};
return res.status(mapped.status).json({
success: false,
error: mapped.message,
code: error.code,
requestId: error.requestId
});
}
});
Key improvements:
- User-friendly error messages — Users don't need to see
INVALID_MOBILE. They need a message they can act on. - Correct HTTP status codes — 400 for user errors, 429 for rate limits, 5xx for server-side issues.
- Structured logging — Every error logged with
code,statusCode, andrequestIdfor debugging. - Fallback handling — Unknown error codes still return a sensible message.
Step 6: Webhooks for Async Status
Some recharges take a few seconds to complete because they depend on the operator network. For those, you'll want webhook notifications rather than polling the API.
Add a webhook endpoint that verifies the signature and processes the event:
server.js (webhook endpoint)const crypto = require('crypto');
// Important: use express.raw() for webhooks, not express.json()
app.post(
'/webhooks/apiexpress',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-apix-signature'];
const rawBody = req.body.toString();
// Verify signature
if (!verifyWebhookSignature(rawBody, signature)) {
console.warn('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
// Parse the verified payload
const event = JSON.parse(rawBody);
// Handle different event types
switch (event.type) {
case 'recharge.completed':
console.log('Recharge completed:', event.data.transaction_id);
// Update your DB, notify user, etc.
break;
case 'recharge.failed':
console.log('Recharge failed:', event.data.transaction_id);
// Refund wallet, notify user, etc.
break;
case 'recharge.pending':
console.log('Recharge pending:', event.data.transaction_id);
// Update UI to show pending state
break;
}
// Always acknowledge quickly
res.status(200).send('OK');
}
);
function verifyWebhookSignature(payload, signatureHeader) {
if (!signatureHeader) return false;
const parts = signatureHeader.split(',');
const timestamp = parts[0].split('=')[1];
const signature = parts[1].split('=')[1];
// Reject old events (replay protection)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) return false; // 5 minute window
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(signedPayload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
}
express.raw() for webhook routes. JSON parsing can reorder fields and break signature verification. This is the #1 mistake developers make with webhooks.Step 7: Test Everything
Before deploying, test the full flow:
Test 1: Successful recharge
curl -X POST http://localhost:3000/api/recharge \
-H "Content-Type: application/json" \
-d '{"mobile": "9876543210", "amount": 199}'
Expected: {"success": true, "transactionId": "TXN...", ...}
Test 2: Invalid mobile number
curl -X POST http://localhost:3000/api/recharge \
-H "Content-Type: application/json" \
-d '{"mobile": "123", "amount": 199}'
Expected: 400 with "Please enter a valid 10-digit Indian mobile number"
Test 3: Operator failure (sandbox test number)
curl -X POST http://localhost:3000/api/recharge \
-H "Content-Type: application/json" \
-d '{"mobile": "9000000001", "amount": 199}'
Expected: 422 with operator declined message
Test 4: Webhook signature
For webhooks, you'll want to test with a real endpoint. Use webhook.site or ngrok during development to see incoming payloads without deploying.
Step 8: Deploy to Production
Before going live, run through this checklist:
- ✅ All tests pass in sandbox
- ✅ Production API key is in environment variables — not in code
- ✅ Webhook secret is configured and signature verification is tested
- ✅ Error logging is in place — with request IDs for support
- ✅ Idempotency keys are used — every recharge has a unique
ref_id - ✅ HTTPS is enforced — production webhooks must use HTTPS
- ✅ Rate limit handling works — respecting
Retry-Afterheaders - ✅ Monitoring is set up — error rate, response time, quota usage
Switch to production
Update your production environment variables:
API_KEY=sk_live_your_production_key
API_BASE_URL=https://api.apiexpress.in
WEBHOOK_SECRET=whsec_your_prod_secret
Deploy. Test a single recharge in production. Then ramp up traffic.
401 INVALID_API_KEY — our API rejects mismatched keys immediately.Summary & Next Steps
You've built a complete, production-ready Mobile Recharge integration in Node.js. Let's recap what you've accomplished:
- ✅ Set up a Node.js project with proper environment configuration
- ✅ Built a reusable recharge client with structured error handling
- ✅ Created an Express endpoint with validation and error mapping
- ✅ Added webhook processing with signature verification
- ✅ Tested every scenario in sandbox
- ✅ Prepared for production deployment
The complete code is under 150 lines — proof that API integrations don't need to be complicated when the API provider does their job well.
Where to go next
- Webhooks documentation — deep dive into signature verification, retry logic, and idempotency
- Error codes reference — complete list of error codes and recommended handling
- Sandbox testing guide — test values and mock data for every API
- Telecom Operator API — detect operators before recharge for a smoother UX
- UPI / Bank Verification API — validate payment accounts for wallet-funded recharges
If you get stuck or have questions, reach out to our team. We respond within 4 hours on business days.
And if this tutorial helped you ship faster, sign up for a free account and see what else you can build in a day.