Integrating VAS Services for Airtime and Data with Node.js
Learn how to integrate VAS services for airtime and data using Node.js, Axios, REST APIs, environment variables, and HMAC SHA512 authentication.
Integrating a Value-Added Service (VAS) provider into an application can look complicated at first, especially when the provider offers several services and requires API keys, authentication, request signatures, and specific request and response formats.
Recently, I was contracted to integrate XpressPay VAS
services for airtime and data purchases. The goal was to build a backend
service that could communicate with XpressPay, retrieve available data
products, and process data and airtime purchases.
In this article, I will walk through the approach I used,
from studying the API documentation to implementing the integration with
Node.js and Axios.
Why Node.js?
One of the first decisions I had to make was which
programming language to use for the integration.
After considering the requirements, I settled on Node.js.
Node.js is a good fit for this type of application because
the integration involves making HTTP requests to an external service and
waiting for responses. Node.js provides asynchronous, non-blocking I/O, which
makes it convenient for applications that communicate with external APIs.
Another reason was simplicity. The integration mainly
involved HTTP requests, JSON payloads, authentication headers, hashing, and
response processing. Node.js provides the necessary tools to handle all of
these without introducing unnecessary complexity.
For HTTP communication, I decided to use Axios.
Studying the XpressPay VAS Documentation
After deciding on the technology, the next step was to study
the XpressPay VAS documentation.
Before writing any code, I needed to understand how the API
worked and what information was required for each operation.
The documentation helped me identify:
- The
API base URL.
- Authentication
requirements.
- Available
endpoints.
- HTTP
methods.
- Request
payload structures.
- Response
structures.
- Required
headers.
- Product
and category identifiers.
- Transaction
identifiers.
- Hash/signature
requirements.
- Error
responses.
- Staging
and production environments.
Understanding the request and response payloads was
particularly important because it allowed me to reproduce the requests in Postman
before implementing them in Node.js.
This gave me a way to test the API independently and verify
that the credentials, request parameters, headers, and endpoints were correct.
Understanding the Integration Flow
After studying the documentation, I identified three major
operations required for the integration:
- Listing
available data products
- Purchasing
data
- Purchasing
airtime
The basic flow was:
The Node.js application acts as the integration layer
between the main application and XpressPay.
The application can request available products or initiate a
purchase through the Node.js backend. The backend is then responsible for
constructing the appropriate request, generating the required hash,
communicating with XpressPay, and returning the response.
This also means that sensitive credentials such as the
private key do not need to be exposed to the frontend.
Setting Up the Node.js Project
After understanding the API flow, I scaffolded the Node.js
project and installed the packages required for the integration.
The main external package I needed was Axios.
npm install axios
Node.js also provides the crypto module as part of its
standard library, so there was no need to install another package for
generating the HMAC SHA512 hash.
The project was organized to keep the VAS integration logic
separate from other application logic.
For example:
The exact structure can vary depending on the application,
but separating external service integrations makes the application easier to
maintain.
Configuring the API Credentials
As usual, the API credentials were configured using
environment variables rather than being hard-coded into the source code.
For example:
XPRESSPAY_BASE_URL_STAGING=https://example-api-url
XPRESSPAY_PUBLIC_KEY=your_public_key
XPRESSPAY_PRIVATE_KEY=your_private_key
The Node.js application can then read these values using
process.env.
const base_url = process.env.XPRESSPAY_BASE_URL_STAGING;
const private_key = process.env.XPRESSPAY_PRIVATE_KEY;
const public_key = process.env.XPRESSPAY_PUBLIC_KEY;
This approach makes it easier to use different
configurations for development, staging, and production.
It also prevents sensitive credentials from being directly
embedded in the source code.
The .env file should also be excluded from source control by
adding it to .gitignore.
Listing Available Data Products
The first operation I implemented was retrieving the
available data products.
The API provided a products endpoint that accepted a
category and biller identifier. In this case, the category Id and biller Id will be supply by the caller service
The function was implemented as follows:
const dataProductList = async (categoryId, billerId) => {
const apiUrl =
`${base_url}/products?categoryId=categoryId&billerId=billerId`;
const headers = {
'Authorization': `Bearer ${public_key}`,
'Content-Type': 'application/json',
};
const body = {
size: 155,
page: 0
};
await
createAppLog(
JSON.stringify({
controller: "Utility.js",
action:
"GetVtuNetworkDataList",
header:
headers,
url:
apiUrl
})
);
try {
const response
= await axios.post(
apiUrl,
body,
{
headers: headers
}
);
const
transactionStatus =
JSON.stringify(response.data);
return transactionStatus;
} catch (error) {
await createAppLog(error);
const data = {
status:
error.response?.data?.status,
message:
error.response?.data?.message
};
return
JSON.stringify(data);
}
};
The API URL is constructed from the configured base URL:
const apiUrl = `${base_url}/products?categoryId=${categoryId}&billerId=${billerId}`;
The request also contains an authorization header:
const headers = {
'Authorization':
`Bearer ${public_key}`,
'Content-Type':
'application/json',
};
The request body contains pagination information:
const body = {
size: 155,
page: 0
};
Axios is then used to send the request:
const response = await axios.post(
apiUrl,
body,
{
headers:
headers
}
);
The response returned by XpressPay contains the available
products that can be presented to the application or customer.
These products can then be used when initiating a data
purchase.
Purchasing Data
After retrieving the available data products, the next step
was to implement the data purchase operation.
The data fulfilment endpoint was:
/data/fulfil
The purchase request required information such as:
- Request
ID
- Product
unique code
- Customer
phone number
- Amount
The request body was constructed as follows:
const postData = {
requestId:
requestId,
uniqueCode:
uniqueCode,
details: {
phoneNumber:
phoneNumber,
amount: amount
}
};
The uniqueCode is important because it identifies the data
product being purchased.
For example, after retrieving the list of available
products, the application may allow the customer to select a particular data
plan. The corresponding product code can then be supplied during the purchase.
Generating the Payment Hash
One of the important requirements for the purchase operation
was generating a PaymentHash using HMAC SHA512.
I created a reusable function for this:
const generatePaymentHash = (postData) => {
const jsonBody =
JSON.stringify(postData);
return crypto
.createHmac('sha512', private_key)
.update(jsonBody)
.digest('hex');
};
The first step is converting the request body into JSON:
const jsonBody = JSON.stringify(postData);
The JSON string is then passed into the HMAC SHA512 process
using the private key:
crypto
.createHmac('sha512', private_key)
.update(jsonBody)
Finally, the hash is returned as a hexadecimal string:
.digest('hex');
The generated hash is then included in the request headers.
Data Purchase Request
The complete data purchase function looked like this:
const dataBillPayment = async (
requestId,
uniqueCode,
phoneNumber,
amount
) => {
const apiUrl =
`${base_url}/data/fulfil`;
const postData = {
requestId:
requestId,
uniqueCode:
uniqueCode,
details: {
phoneNumber: phoneNumber,
amount:
amount
}
};
const paymentHash
= generatePaymentHash(postData);
const headers = {
'Authorization': `Bearer ${public_key}`,
'PaymentHash':
paymentHash,
'channel':
'web',
'Content-Type': 'application/json'
};
await
createAppLog(
JSON.stringify({
controller: "Utility.js",
action:
"AirtimeDataBillPayment",
parameter:
postData,
header: {
...headers,
PaymentHash: "[HIDDEN]"
},
url: apiUrl
})
);
try {
const response
= await axios.post(
apiUrl,
postData,
{ headers
}
);
return JSON.stringify(response.data);
} catch (error) {
await createAppLog(error);
const data = {
status:
error.response?.data?.status,
message:
error.response?.data?.message
};
return
JSON.stringify(data);
}
};
The request is sent to:
const apiUrl = `${base_url}/data/fulfil`;
The request body is then created:
const postData = {
requestId:
requestId,
uniqueCode:
uniqueCode,
details: {
phoneNumber:
phoneNumber,
amount: amount
}
};
The PaymentHash is generated from that exact request body:
const paymentHash = generatePaymentHash(postData);
The hash is then added to the request headers:
const headers = {
'Authorization':
`Bearer ${public_key}`,
'PaymentHash':
paymentHash,
'channel': 'web',
'Content-Type':
'application/json'
};
Finally, Axios sends the request:
const response = await axios.post(
apiUrl,
postData,
{ headers }
);
The response from XpressPay is then returned to the
application.
Why the Exact JSON Body Matters
One important lesson from working with APIs that use HMAC
signatures is that the data used to generate the signature matters.
In this implementation, the hash is generated from:
generatePaymentHash(postData);
The same postData object is then sent to the API.
This is important because the receiving server can
independently generate the hash from the request body and compare it with the
PaymentHash supplied by the client.
If the values used to generate the hash are different from
the actual request, the generated hashes may not match.
Therefore, when implementing HMAC-based authentication, pay
attention to:
- Request
property names.
- Request
property values.
- JSON
structure.
- Data
types.
- Serialization.
- Secret/private
key.
- Hash
algorithm.
- Output
format.
A small difference in the data being signed can cause the
API to reject the request.
Purchasing Airtime
After implementing the data purchase flow, I moved to the airtime
purchase operation.
The airtime fulfilment endpoint was:
/airtime/fulfil
The request structure was similar to the data purchase
request.
The application needed:
- Request
ID
- Product
unique code
- Customer
phone number
- Amount
The request body was:
const postData = {
requestId:
requestId,
uniqueCode:
uniqueCode,
details: {
phoneNumber:
phoneNumber,
amount: amount
}
};
The main difference is the endpoint:
const apiUrl = `${base_url}/airtime/fulfil`;
The same HMAC SHA512 function could be reused:
const paymentHash = generatePaymentHash(postData);
The required headers were then created:
const headers = {
'Authorization':
`Bearer ${public_key}`,
'PaymentHash':
paymentHash,
'channel': 'pos',
'Content-Type':
'application/json'
};
The complete airtime purchase function was:
const airtimeBillPayment = async (
requestId,
uniqueCode,
phoneNumber,
amount
) => {
const apiUrl = `${base_url}/airtime/fulfil`;
const postData = {
requestId:
requestId,
uniqueCode:
uniqueCode,
details: {
phoneNumber: phoneNumber,
amount:
amount
}
};
const paymentHash = generatePaymentHash(postData);
const headers = {
'Authorization': `Bearer ${public_key}`,
'PaymentHash':
paymentHash,
'channel':
'pos',
'Content-Type': 'application/json'
};
await
createAppLog(
JSON.stringify({
controller: "Utility.js",
action:
"AirtimeDataBillPayment",
parameter:
postData,
header: {
...headers,
PaymentHash: "[HIDDEN]"
},
url:
apiUrl
})
);
try {
const response
= await axios.post(
apiUrl,
postData,
{ headers
}
);
return JSON.stringify(response.data);
} catch (error) {
await createAppLog(error);
const data = {
status:
error.response?.data?.status,
message:
error.response?.data?.message
};
return
JSON.stringify(data);
}
};
Notice that the overall implementation is very similar to
the data purchase function.
This is one of the advantages of creating reusable functions
such as:
generatePaymentHash()
Instead of duplicating the hashing logic for every
transaction type, the same function can be reused.
Logging the Requests
Another important part of the integration was application
logging.
When working with an external API, logging can be very
useful when troubleshooting failed requests.
For example:
await createAppLog(
JSON.stringify({
controller:
"Utility.js",
action:
"AirtimeDataBillPayment",
parameter:
postData,
header: {
...headers,
PaymentHash:
"[HIDDEN]"
},
url: apiUrl
})
);
The log records information such as:
- Controller
or service.
- Action
being performed.
- Request
parameters.
- API
URL.
- Request
headers.
However, sensitive information should not be written
directly to logs.
For example, instead of logging the actual PaymentHash:
PaymentHash: paymentHash
the implementation uses:
PaymentHash: "[HIDDEN]"
The same principle should be applied to API keys, private
keys, passwords, tokens, and other sensitive credentials.
Logs should help with troubleshooting without becoming a
source of security problems.
Handling Errors
When communicating with an external service, error handling
is important.
The request may fail because of:
- Invalid
credentials.
- Invalid
request parameters.
- Invalid
product code.
- Invalid
PaymentHash.
- Network
problems.
- Timeout.
- Service
unavailability.
- Provider-side
errors.
- Invalid
customer information.
The implementation therefore uses a try...catch block:
try {
const response =
await axios.post(
apiUrl,
postData,
{ headers }
);
return JSON.stringify(response.data);
} catch (error) {
await createAppLog(error);
const data = {
status:
error.response?.data?.status,
message:
error.response?.data?.message
};
return
JSON.stringify(data);
}
One useful feature here is optional chaining:
error.response?.data?.status
This prevents the application from throwing another error if
response does not exist.
For example, a network failure may not provide an HTTP
response at all.
The application can therefore safely attempt to retrieve the
error information when it is available.
Exporting the Functions
After implementing the three operations, I exported them
from the module:
module.exports = {
dataProductList,
airtimeBillPayment,
dataBillPayment
};
Other parts of the application can then import these
functions when they need to interact with XpressPay.
For example:
const {
dataProductList,
dataBillPayment,
airtimeBillPayment
} = require('./services/XpressPay');
This keeps the integration logic contained within one module
while allowing other parts of the application to use it.
Testing with Postman
Before connecting the integration to the application's
frontend, I tested the endpoints using Postman.
This was an important part of the development process.
I used Postman to verify:
- The
API base URL.
- Authentication.
- Request
headers.
- Request
payload.
- Product
codes.
- PaymentHash
generation.
- Customer
phone number.
- Transaction
amount.
- API
response.
- Error
responses.
Testing with Postman first made the development process
easier.
If a request failed in the Node.js application, I could
compare it with the request that worked or failed in Postman.
This helped isolate whether the problem was coming from the API, the request structure, authentication, or my application code.
Lessons from the Integration
There were several useful lessons from this integration.
1. Study the API documentation first
The API documentation is effectively the contract between
your application and the service provider.
Understanding the endpoints, request structures, headers,
authentication requirements, and response formats before coding can save
significant debugging time.
2. Test with Postman
Testing the API independently before implementing it in code
makes it easier to identify problems with the API request.
3. Keep credentials out of the source code
API keys and private keys should be stored securely using
environment variables or a proper secrets-management solution.
4. Understand the signing mechanism
If an API uses HMAC SHA512 or another signature mechanism,
understand exactly what data is expected to be signed.
5. Reuse common logic
The data and airtime transactions use similar authentication
and hashing logic.
Creating a reusable function such as:
generatePaymentHash()
reduces duplication and makes the code easier to maintain.
6. Protect sensitive information in logs
Logging is useful, but private keys, API credentials,
tokens, and transaction signatures should not be exposed unnecessarily.
7. Expect external services to fail
When your application depends on an external API, failures
are always possible.
Good error handling helps your application respond
appropriately when the provider is unavailable or a request is rejected.
Conclusion
Integrating XpressPay VAS services was a practical example
of how Node.js can be used to communicate with an external VAS provider.
The implementation started with retrieving the available
data products. Once the available products could be retrieved, the next step
was implementing data purchase, followed by airtime purchase.
The integration required more than simply sending HTTP
requests. I had to understand the API documentation, configure API credentials,
construct the correct JSON payloads, generate an HMAC SHA512 PaymentHash using
the private key, send authenticated requests with Axios, process responses, and
handle errors.
The final Node.js module provided three main operations:
module.exports = {
dataProductList,
airtimeBillPayment,
dataBillPayment
};
Although the implementation is relatively small, it
demonstrates several concepts that are common when integrating third-party
APIs: asynchronous programming, REST API communication, environment
configuration, cryptographic hashing, request signing, error handling, logging,
and reusable service functions.
For me, the most important part of the exercise was not just
getting the API to respond successfully, but understanding the complete
communication flow between the application and the VAS provider.
That understanding makes it easier to integrate additional
services in the future using the same approach.