First draft: 23/04/25
Understanding Hubtel's Online Checkout
A thorough review of Hubtel's online checkout documentation reveals that the responsibility of securing the webhook falls entirely on the developer. Unlike Stripe or Paystack, which provide robust webhook security mechanisms with signature verification, I believe Hubtel's system requires developers to implement their own security measures.
It's worth noting that Hubtel's redirect checkout doesn't provide a sandbox or test environment, so caution should be exercised when implementing this in development.
Basic Implementation
Let's start with the basic implementation for creating a redirect checkout to receive payments:
// Environment configuration
const HUBTEL_CLIENT_ID = process.env.HUBTEL_CLIENT_ID;
const HUBTEL_CLIENT_SECRET = process.env.HUBTEL_CLIENT_SECRET;
// Helper function for authentication
export const getHubtelAuthHeader = () => {
const auth = Buffer.from(
`${HUBTEL_CLIENT_ID}:${HUBTEL_CLIENT_SECRET}`
).toString('base64');
return `Basic ${auth}`;
};
// Express route for creating payment checkout
app.post("/create-payment-checkout", async function(req, res) {
try {
const result = await createPaymentCheckout(req.body);
return res.status(StatusCodes.CREATED).json({
status: 'success',
statusCode: StatusCodes.CREATED,
message: 'Created payment checkout successfully',
data: result,
});
} catch (err) {
console.error('Payment checkout error:', err);
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
status: 'error',
message: 'An error occurred while making payment for the order',
details: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
});
// Function to create payment checkout
const createPaymentCheckout = async (payee: CreatePaymentCheckoutData) => {
try {
const { name, email, phone, amount, description, order_id, title } = payee;
const payload = {
merchantAccountNumber: process.env.HUBTEL_MERCHANT_ACCOUNT_NUMBER,
totalAmount: amount,
title: title,
description: description || 'Payment for services',
callbackUrl: `${process.env.BACKEND_WEBHOOK_URL}/hubtel-webhook`,
returnUrl: `${process.env.FRONTEND_URL}/payment/payment-success`,
cancellationUrl: `${process.env.FRONTEND_URL}/payment/payment-failed`,
payeeName: name,
payeeEmail: email || '',
payeeMobileNumber: phone,
clientReference: order_id,
};
const response = await axios.post(
`${process.env.HUBTEL_ONLINE_CHECKOUT_URL}`,
payload,
{
headers: {
Authorization: getHubtelAuthHeader(),
'Content-Type': 'application/json',
},
}
);
if (response.data && response.data.responseCode === '0000') {
return response.data.data.checkoutUrl;
}
throw new Error(`Failed to create payment checkout: ${JSON.stringify(response.data)}`);
} catch (error) {
console.error('Payment checkout creation error:', error);
throw new Error(
error.response?.data?.message || 'Failed to create payment checkout'
);
}
};
The Security Challenge
In the implementation above, notice the callbackUrl parameter. This is where you specify your webhook URL, which Hubtel will use to send payment status updates.
If you've worked with payment processors like Stripe or Paystack before, you might be wondering about webhook security. These providers implement signature verification to ensure that:
- The webhook is called by the expected source (the payment processor)
- The webhook payload hasn't been tampered with
Without these security mechanisms, your system becomes vulnerable to replay attacks, where an attacker could send fake webhook events to your endpoint, potentially triggering unwanted actions.
Implementing Webhook Security
Since Hubtel doesn't provide built-in webhook security for their redirect checkout system, we need to implement our own. Here's a robust approach using Redis to create one-time verification codes:
import crypto from 'crypto';
// Function to secure webhook by generating a verification code
const secure_webhook = async (order_id: string) => {
try {
// Generate a random verification code
const verification_code = generateVerificationCode(10);
const webhook_secret = `webhook_secret_${verification_code}`;
// Clear any existing keys with the same name
await redisService.delFromRedis(webhook_secret);
// Store the verification code and order ID in Redis with expiration
await redisService.addToRedis({
key: webhook_secret,
value: { key: webhook_secret, order_id: order_id },
expiresIn: 3600, // expires after 1 hour
});
return verification_code;
} catch (error) {
console.error('Error securing webhook:', error);
throw error;
}
};
// Function to verify webhook using the verification code
const verify_webhook = async (
reference: string
): Promise<{ success: boolean; order_id: string }> => {
try {
const key = `webhook_secret_${reference}`;
const verify_key = await redisService.getFromRedis(key);
if (!verify_key) {
console.warn(`Webhook verification failed: No key found for reference ${reference}`);
return { success: false, order_id: '' };
}
const verify_key_obj =
typeof verify_key === 'string' ? JSON.parse(verify_key) : verify_key;
const order_id = verify_key_obj.order_id;
// Delete the key after verification to prevent reuse
await redisService.delFromRedis(key);
return { success: true, order_id };
} catch (error) {
console.error('Error verifying webhook:', error);
throw error;
}
};
// Helper function to validate amount
const amountValidator = (amount: string | number) => {
try {
const stringify_amount = amount.toString();
const parsedAmount = parseFloat(stringify_amount);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
throw new Error('Invalid payment amount: must be a positive number');
}
return parsedAmount;
} catch (error) {
console.error('Amount validation error:', error);
throw new Error('Invalid payment amount');
}
};
// Function to generate random verification code
function generateVerificationCode(length: number): string {
return crypto.randomBytes(length).toString('hex');
}
Here's the updated createPaymentCheckout function with security implementation:
// Updated createPaymentCheckout function with security implementation
const createPaymentCheckout = async (payee: CreatePaymentCheckoutData) => {
try {
const { name, email, phone, amount, description, order_id, title } = payee;
// Generate and store verification code
const transaction_reference = await secure_webhook(order_id);
// Validate payment amount
const validated_amount = amountValidator(amount);
const payload = {
merchantAccountNumber: process.env.HUBTEL_MERCHANT_ACCOUNT_NUMBER,
totalAmount: validated_amount,
title: title,
description: description || 'Payment for services',
callbackUrl: `${process.env.BACKEND_URL}/hubtel-webhook`,
returnUrl: `${process.env.FRONTEND_URL}/payment/payment-success`,
cancellationUrl: `${process.env.FRONTEND_URL}/payment/payment-failed`,
payeeName: name,
payeeEmail: email || '',
payeeMobileNumber: phone,
clientReference: transaction_reference, // Use verification code as reference
};
console.log(`Creating payment checkout for order ${order_id} with reference ${transaction_reference}`);
const response = await axios.post(
`${process.env.HUBTEL_ONLINE_CHECKOUT_URL}`,
payload,
{
headers: {
Authorization: getHubtelAuthHeader(),
'Content-Type': 'application/json',
},
}
);
if (response.data && response.data.responseCode === '0000') {
return response.data.data.checkoutUrl;
}
throw new Error(`Failed to create payment checkout: ${JSON.stringify(response.data)}`);
} catch (error) {
console.error('Payment checkout creation error:', error);
throw new Error(
error.response?.data?.message || 'Failed to create payment checkout'
);
}
};
Implementing the Webhook Endpoint
Now, let's implement the webhook endpoint that will receive callbacks from Hubtel:
app.post('/hubtel-webhook', async (req, res) => {
try {
const { ClientReference, Status, Amount, Description } = req.body;
// Verify the webhook using our security mechanism
const verification = await verify_webhook(ClientReference);
if (!verification.success) {
return res.status(StatusCodes.UNAUTHORIZED).json({
status: 'error',
message: 'Invalid webhook reference',
});
}
// Additional business logic here
return res.status(StatusCodes.OK).json({
status: 'success',
message: 'Webhook processed successfully',
});
} catch (error) {
console.error('Webhook processing error:', error);
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
status: 'error',
message: 'An error occurred while processing the webhook',
});
}
});
How the Security Mechanism Works
Our webhook security implementation works as follows:
- One-Time Verification Codes: When creating a payment checkout, we generate a random verification code and store it in Redis along with the order ID.
- Prevention of Replay Attacks: The verification code is used as the clientReference sent to Hubtel, and when Hubtel sends a webhook callback, this reference is included. We verify the reference against our Redis store and delete it after verification, ensuring it can be used only once.
- Time-Limited Exposure: We set an expiration time (1 hour) for the verification code in Redis, limiting the window of vulnerability.
- Input Validation: We validate and sanitize payment amounts to prevent injection attacks.
Additional Security Recommendations
Beyond the implementation above, consider these additional security measures:
- Rate Limiting: Implement rate limiting on your webhook endpoint to prevent brute force attacks.
- TLS Configuration: Ensure your server uses TLS (HTTPS) with proper configuration.
- Comprehensive Logging: Implement detailed logging for all webhook interactions to aid in debugging and security auditing.
- Idempotency: Ensure your webhook processing is idempotent to prevent issues if a webhook is somehow processed multiple times.
Conclusion
Implementing Hubtel's redirect checkout with Express.js and TypeScript requires careful attention to security, particularly for webhooks. The approach outlined in this guide addresses the security gaps in Hubtel's system by implementing one-time verification codes with Redis.
This implementation attempts to provide a robust defense against replay attacks.
Note: This implementation assumes you have a Redis service configured. If you're using a different data store, you'll need to adapt the code accordingly.
[1] https://developers.hubtel.com/docs/online-checkout
[2] https://expressjs.com/en/guide/routing.html
[3] https://redis.io/docs/manual/security/