Webhooks
Webhooks provide real-time notifications when payments are completed, transfers are processed, or other important events occur in your iNPAY integration. This guide covers all webhook notifications across all integration methods.
๐ What are Webhooks?โ
Webhooks are HTTP callbacks that iNPAY sends to your application when specific events occur. Instead of polling our API for updates, webhooks push notifications to your server in real-time.
๐ Quick Reference:
- Checkout Modal/Inline developers โ Focus on
payment.checkout_*events- Server-Side API developers โ Focus on
payment.virtual_*,payment.failed,payment.expired, andtransfer.*events
Benefits:โ
- Real-time notifications - Get instant updates when payments complete
- Reduced API calls - No need to constantly check transaction status
- Better user experience - Immediate order fulfillment and notifications
- Reliable delivery - Automatic retries with exponential backoff
Integration Methods Covered:โ
๐ง Server-Side API Integrationโ
- API Integration with Secret Keys (
sk_...) - Events:
payment.virtual_account.completed,payment.virtual_payid.completed,payment.failed,payment.expired,transfer.payid.completed,transfer.external.completed
๐ Checkout Modal/Inline Integrationโ
- Checkout Modal with Public Keys (
pub_...) - Events:
payment.checkout_virtual_account.completed,payment.checkout_payid.completed
๐ง System Eventsโ
- Universal Events available for all integration methods
- Events:
webhook.test
๐ฏ Webhook Eventsโ
iNPAY sends webhooks for the following events, organized by integration method:
๐ง Server-Side API Integration Eventsโ
These events are for developers using API integration with Secret Keys:
| Event | Description | When Triggered |
|---|---|---|
payment.virtual_account.completed | Virtual account payment completed | Customer pays to your virtual account created via API |
payment.virtual_payid.completed | Virtual PayID payment completed | Customer pays to your virtual PayID created via API |
payment.failed | Payment processing failed | Payment fails due to insufficient funds, invalid details, etc. |
payment.expired | Payment request expired | Payment request expires without completion |
transfer.payid.completed | PayID-to-PayID transfer completed | Transfer between PayIDs succeeds |
transfer.external.completed | External bank transfer completed | Transfer to external bank account succeeds |
๐ Checkout Modal/Inline Integration Eventsโ
These events are for developers using Checkout Modal or Inline integration with Public Keys:
| Event | Description | When Triggered |
|---|---|---|
payment.checkout_virtual_account.completed | Virtual account payment completed | Customer pays to your virtual account via checkout modal |
payment.checkout_payid.completed | Virtual PayID payment completed | Customer pays to your virtual PayID via checkout modal |
๐ง System Eventsโ
| Event | Description | When Triggered |
|---|---|---|
webhook.test | Webhook test event | Testing webhook endpoint from dashboard |
๐๏ธ Setting Up Webhooksโ
Step 1: Configure Webhook URLโ
- Log in to your iNPAY Dashboard
- Navigate to Settings โ Keys & Webhooks
- Add your webhook endpoint URL
- Select the events you want to receive
- Save your configuration
Step 2: Implement Webhook Endpointโ
Your webhook endpoint should:
- Accept POST requests
- Verify webhook signatures
- Process the event data
- Return a 200 status code for successful processing
๐ Webhook Securityโ
Webhook URL Requirementsโ
- Protocol: HTTPS only (HTTP is not allowed)
- Response: Must return HTTP 200 status code
- Timeout: 10 seconds maximum response time
- Security: URLs are validated against blacklisted patterns (localhost, private IPs, etc.)
Webhook Headersโ
All webhooks include these headers:
X-Webhook-Signature: sha256=<signature>
X-Webhook-Timestamp: 1705278600000
X-Webhook-Event: payment.checkout_virtual_account.completed
User-Agent: iNPAY-Webhook/1.0
Content-Type: application/json
Signature Verificationโ
All webhooks are signed with your secret key using HMAC-SHA256. You must verify the signature to ensure the webhook is authentic.
- Header:
x-webhook-signature - Algorithm: HMAC-SHA256
- Secret: Your merchant secret key
- Timestamp:
x-webhook-timestamp(Unix timestamp in milliseconds)
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
// Remove 'sha256=' prefix if present
const cleanSignature = signature.replace(/^sha256=/, '');
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(cleanSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
}
// Usage in Express.js
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const payload = JSON.stringify(req.body);
if (!verifyWebhookSignature(payload, signature, process.env.MERCHANT_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook
console.log('Webhook verified:', req.body);
res.status(200).json({ success: true });
});
Timestamp Verificationโ
function verifyTimestamp(timestamp, tolerance = 300000) { // 5 minutes
const now = Date.now();
const webhookTime = parseInt(timestamp);
return Math.abs(now - webhookTime) <= tolerance;
}
๐จ Webhook Payloadsโ
๐ง Server-Side API Integration Eventsโ
1. Virtual Account Payment Completed (API/Secret Key)โ
Event: payment.virtual_account.completed
Integration Method: Server-Side API with Secret Key
Triggered When: Customer makes payment to your virtual account created via API with secret key
{
"event": "payment.virtual_account.completed",
"timestamp": "2025-01-03T10:30:00.000Z",
"data": {
"reference": "TXN_1234567890",
"accountNumber": "1001637241",
"accountName": "Your Business Name",
"amount": 5000000,
"currency": "NGN",
"customerEmail": "customer@example.com",
"customerName": "John Doe",
"status": "completed",
"transactionId": "iNPAY-abc123def456",
"fromAccount": "1234567890",
"fromClient": "John Doe",
"narration": "Payment for order #12345",
"completedAt": "2025-01-03T10:30:00.000Z",
"metadata": {
"orderId": "ORD_12345",
"productId": "PROD_67890"
},
"source": "checkout_api",
"merchantKey": "sk_..."
}
}
2. Virtual PayID Payment Completed (API/Secret Key)โ
Event: payment.virtual_payid.completed
Integration Method: Server-Side API with Secret Key
Triggered When: Customer makes payment to your virtual PayID created via API with secret key
{
"event": "payment.virtual_payid.completed",
"timestamp": "2025-01-03T10:30:00.000Z",
"data": {
"reference": "PAYID_1234567890",
"payId": "yourbusiness.dyc",
"name": "Your Business Name",
"amount": 2500000,
"currency": "NGN",
"customerEmail": "customer@example.com",
"customerName": "Jane Smith",
"status": "completed",
"transactionId": "iNPAY-xyz789abc123",
"fromAccount": "9876543210",
"fromClient": "Jane Smith",
"narration": "Payment for virtual PayID PAYID_1234567890",
"completedAt": "2025-01-03T10:30:00.000Z",
"metadata": {
"orderId": "ORD_67890",
"productId": "PROD_12345"
},
"type": "dynamic",
"expiresAt": "2025-01-10T10:30:00.000Z",
"source": "checkout_api",
"merchantKey": "sk_..."
}
}
๐ Checkout Modal/Inline Integration Eventsโ
3. Virtual Account Payment Completed (Checkout Modal/Public Key)โ
Event: payment.checkout_virtual_account.completed
Integration Method: Checkout Modal/Inline with Public Key
Triggered When: Customer makes payment to your virtual account created via checkout modal with public key
{
"event": "payment.checkout_virtual_account.completed",
"timestamp": "2025-01-03T10:30:00.000Z",
"data": {
"reference": "TXN_1234567890",
"accountNumber": "1001637241",
"accountName": "Your Business Name",
"amount": 5000000,
"currency": "NGN",
"customerEmail": "customer@example.com",
"customerName": "John Doe",
"status": "completed",
"transactionId": "iNPAY-abc123def456",
"fromAccount": "1234567890",
"fromClient": "John Doe",
"narration": "Payment for order #12345",
"completedAt": "2025-01-03T10:30:00.000Z",
"metadata": {
"orderId": "ORD_12345",
"productId": "PROD_67890"
},
"source": "checkout_modal",
"merchantKey": "pub_..."
}
}
4. Virtual PayID Payment Completed (Checkout Modal/Public Key)โ
Event: payment.checkout_payid.completed
Integration Method: Checkout Modal/Inline with Public Key
Triggered When: Customer makes payment to your virtual PayID created via checkout modal with public key
{
"event": "payment.checkout_payid.completed",
"timestamp": "2025-01-03T10:30:00.000Z",
"data": {
"reference": "PAYID_1234567890",
"payId": "yourbusiness.dyc",
"name": "Your Business Name",
"amount": 2500000,
"currency": "NGN",
"customerEmail": "customer@example.com",
"customerName": "Jane Smith",
"status": "completed",
"transactionId": "iNPAY-xyz789abc123",
"fromAccount": "9876543210",
"fromClient": "Jane Smith",
"narration": "Payment for virtual PayID PAYID_1234567890",
"completedAt": "2025-01-03T10:30:00.000Z",
"metadata": {
"orderId": "ORD_67890",
"productId": "PROD_12345"
},
"type": "dynamic",
"expiresAt": "2025-01-10T10:30:00.000Z",
"source": "checkout_modal",
"merchantKey": "pub_..."
}
}
๐ง Server-Side API Integration Events (continued)โ
5. Payment Failedโ
Event: payment.failed
Integration Method: Server-Side API with Secret Key
Triggered When: Payment processing fails
{
"event": "payment.failed",
"timestamp": "2025-01-03T10:30:00.000Z",
"data": {
"reference": "TXN_1234567890",
"amount": 1000000,
"currency": "NGN",
"customerEmail": "customer@example.com",
"customerName": "John Doe",
"status": "failed",
"transactionId": "iNPAY-abc123def456",
"failureReason": "Insufficient funds",
"errorCode": "INSUFFICIENT_FUNDS",
"failedAt": "2025-01-03T10:30:00.000Z",
"metadata": {
"orderId": "ORD_12345"
},
"source": "checkout_api",
"merchantKey": "sk_..."
}
}
6. Payment Expiredโ
Event: payment.expired
Integration Method: Server-Side API with Secret Key
Triggered When: Payment request expires
{
"event": "payment.expired",
"timestamp": "2025-01-03T10:30:00.000Z",
"data": {
"reference": "TXN_1234567890",
"amount": 750000,
"currency": "NGN",
"customerEmail": "customer@example.com",
"customerName": "John Doe",
"status": "expired",
"transactionId": "iNPAY-abc123def456",
"expiredAt": "2025-01-03T10:30:00.000Z",
"originalExpiry": "2025-01-03T09:30:00.000Z",
"metadata": {
"orderId": "ORD_12345"
},
"source": "checkout_api",
"merchantKey": "sk_..."
}
}
7. PayID Transfer Completedโ
Event: transfer.payid.completed
Integration Method: Server-Side API with Secret Key
Triggered When: Transfer to PayID is completed
{
"event": "transfer.payid.completed",
"timestamp": "2025-01-03T10:30:00.000Z",
"data": {
"transactionId": "iNPAY-abc123def456",
"reference": "TXN_1234567890",
"amount": 2000000,
"currency": "NGN",
"fromPayId": "sender.dyc",
"toPayId": "recipient.dyc",
"fromAccount": "1001637241",
"toAccount": "1001637242",
"fromClient": "John Doe",
"toClient": "Jane Smith",
"status": "completed",
"completedAt": "2025-01-03T10:30:00.000Z",
"narration": "Transfer to recipient.dyc",
"fees": 1000,
"netAmount": 1999000,
"metadata": {
"transferType": "payid_to_payid"
},
"merchantKey": "sk_..."
}
}
8. External Bank Transfer Completedโ
Event: transfer.external.completed
Integration Method: Server-Side API with Secret Key
Triggered When: Transfer to external bank account is completed
{
"event": "transfer.external.completed",
"timestamp": "2025-01-03T10:30:00.000Z",
"data": {
"transactionId": "iNPAY-abc123def456",
"reference": "TXN_1234567890",
"amount": 5000000,
"currency": "NGN",
"fromPayId": "sender.dyc",
"fromAccount": "1001637241",
"fromClient": "John Doe",
"toAccount": "1234567890",
"toAccountName": "Jane Smith",
"toBankCode": "058",
"toBankName": "GTBank",
"status": "completed",
"completedAt": "2025-01-03T10:30:00.000Z",
"narration": "Transfer to GTBank account",
"fees": 5000,
"netAmount": 4995000,
"metadata": {
"transferType": "payid_to_external"
},
"merchantKey": "sk_..."
}
}
๐ง System Eventsโ
9. Webhook Testโ
Event: webhook.test
Integration Method: Universal (All Integration Methods)
Triggered When: Testing webhook endpoint from dashboard
{
"event": "webhook.test",
"timestamp": "2025-01-03T10:30:00.000Z",
"data": {
"testId": "test_abc123def456",
"message": "This is a test webhook from iNPAY",
"merchantId": "merchant_12345",
"testedAt": "2025-01-03T10:30:00.000Z",
"environment": "production"
}
}
๐ป Complete Webhook Implementationโ
Node.js/Express Implementationโ
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// Your secret key (same one used for API authentication)
const SECRET_KEY = process.env.INPAY_SECRET_KEY;
function verifyWebhookSignature(payload, signature, secretKey) {
// Remove 'sha256=' prefix if present
const cleanSignature = signature.replace(/^sha256=/, '');
const expectedSignature = crypto
.createHmac('sha256', secretKey)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(cleanSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
}
function verifyTimestamp(timestamp, tolerance = 300000) { // 5 minutes
const now = Date.now();
const webhookTime = parseInt(timestamp);
return Math.abs(now - webhookTime) <= tolerance;
}
app.post('/webhook', (req, res) => {
try {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const event = req.headers['x-webhook-event'];
console.log('Webhook received:', {
event,
timestamp,
signature: signature ? signature.substring(0, 20) + '...' : 'missing'
});
// Verify signature
if (!verifyWebhookSignature(JSON.stringify(req.body), signature, SECRET_KEY)) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// Validate timestamp
if (!verifyTimestamp(timestamp)) {
console.error('Webhook timestamp too old');
return res.status(400).json({ error: 'Timestamp too old' });
}
// Process webhook based on event type
const { event: eventType, data } = req.body;
switch (eventType) {
case 'payment.virtual_account.completed':
handleVirtualAccountPayment(data, 'API');
break;
case 'payment.virtual_payid.completed':
handlePayIdPayment(data, 'API');
break;
case 'payment.checkout_virtual_account.completed':
handleVirtualAccountPayment(data, 'Modal');
break;
case 'payment.checkout_payid.completed':
handlePayIdPayment(data, 'Modal');
break;
case 'payment.failed':
handlePaymentFailed(data);
break;
case 'payment.expired':
handlePaymentExpired(data);
break;
case 'transfer.payid.completed':
handlePayIdTransfer(data);
break;
case 'transfer.external.completed':
handleExternalTransfer(data);
break;
case 'webhook.test':
handleWebhookTest(data);
break;
default:
console.log('Unknown webhook event:', eventType);
// Still return 200 to acknowledge receipt
}
// Always return 200 for successful processing
res.status(200).json({ success: true });
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
function handleVirtualAccountPayment(data, source) {
console.log('Virtual Account Payment Received:', {
accountNumber: data.accountNumber,
amount: data.amount / 100, // Convert from kobo to naira
reference: data.reference,
customerEmail: data.customerEmail,
source: data.source, // "checkout_api" or "checkout_modal"
merchantKey: data.merchantKey, // "sk_..." or "pub_..."
transactionId: data.transactionId
});
// Update your database
// updateOrderStatus(data.reference, 'paid');
// Send confirmation email
// sendPaymentConfirmation(data.customerEmail, data);
// Update inventory
// updateInventory(data.metadata.productId);
}
function handlePayIdPayment(data, source) {
console.log('PayID Payment Received:', {
payId: data.payId,
amount: data.amount / 100, // Convert from kobo to naira
reference: data.reference,
customerEmail: data.customerEmail,
source: data.source, // "checkout_api" or "checkout_modal"
merchantKey: data.merchantKey, // "sk_..." or "pub_..."
transactionId: data.transactionId
});
// Update your database
// updateOrderStatus(data.reference, 'paid');
// Send confirmation email
// sendPaymentConfirmation(data.customerEmail, data);
}
function handlePaymentFailed(data) {
console.log('Payment Failed:', {
reference: data.reference,
amount: data.amount / 100,
failureReason: data.failureReason,
errorCode: data.errorCode,
customerEmail: data.customerEmail
});
// Update order status to failed
// updateOrderStatus(data.reference, 'failed');
// Send failure notification
// sendPaymentFailureNotification(data.customerEmail, data);
}
function handlePaymentExpired(data) {
console.log('Payment Expired:', {
reference: data.reference,
amount: data.amount / 100,
expiredAt: data.expiredAt,
customerEmail: data.customerEmail
});
// Update order status to expired
// updateOrderStatus(data.reference, 'expired');
// Send expiration notification
// sendPaymentExpirationNotification(data.customerEmail, data);
}
function handlePayIdTransfer(data) {
console.log('PayID Transfer Completed:', {
fromPayId: data.fromPayId,
toPayId: data.toPayId,
amount: data.amount / 100,
reference: data.reference,
fees: data.fees / 100,
netAmount: data.netAmount / 100,
transactionId: data.transactionId
});
// Update your records
// updateTransferStatus(data.reference, 'completed');
// Send notification
// sendTransferNotification(data);
}
function handleExternalTransfer(data) {
console.log('External Transfer Completed:', {
fromPayId: data.fromPayId,
toAccount: data.toAccount,
bankName: data.toBankName,
amount: data.amount / 100,
fees: data.fees / 100,
netAmount: data.netAmount / 100,
reference: data.reference,
transactionId: data.transactionId
});
// Update your records
// updateTransferStatus(data.reference, 'completed');
// Send notification
// sendTransferNotification(data);
}
function handleWebhookTest(data) {
console.log('Webhook Test Received:', {
testId: data.testId,
message: data.message,
environment: data.environment
});
// Webhook test - just log for verification
}
// Health check endpoint
app.get('/webhook/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString()
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook server listening on port ${PORT}`);
console.log(`Health check: http://localhost:${PORT}/webhook/health`);
});
Python/Flask Implementationโ
from flask import Flask, request, jsonify
import hmac
import hashlib
import json
import os
from datetime import datetime
app = Flask(__name__)
SECRET_KEY = os.getenv('INPAY_SECRET_KEY')
def verify_webhook_signature(payload, signature, secret_key):
"""Verify webhook signature"""
# Remove 'sha256=' prefix if present
clean_signature = signature.replace('sha256=', '')
expected_signature = hmac.new(
secret_key.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(clean_signature, expected_signature)
def verify_timestamp(timestamp, tolerance=300000): # 5 minutes
"""Validate webhook timestamp"""
import time
now = int(time.time() * 1000)
webhook_time = int(timestamp)
return abs(now - webhook_time) <= tolerance
@app.route('/webhook', methods=['POST'])
def webhook_handler():
try:
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')
event = request.headers.get('X-Webhook-Event')
print(f'Webhook received: {event}')
# Verify signature
payload = json.dumps(request.json, separators=(',', ':'))
if not verify_webhook_signature(payload, signature, SECRET_KEY):
print('Invalid webhook signature')
return jsonify({'error': 'Invalid signature'}), 401
# Validate timestamp
if not verify_timestamp(timestamp):
print('Webhook timestamp too old')
return jsonify({'error': 'Timestamp too old'}), 400
# Process webhook
event_type = request.json.get('event')
data = request.json.get('data')
if event_type == 'payment.virtual_account.completed':
handle_virtual_account_payment(data, 'API')
elif event_type == 'payment.virtual_payid.completed':
handle_payid_payment(data, 'API')
elif event_type == 'payment.checkout_virtual_account.completed':
handle_virtual_account_payment(data, 'Modal')
elif event_type == 'payment.checkout_payid.completed':
handle_payid_payment(data, 'Modal')
elif event_type == 'payment.failed':
handle_payment_failed(data)
elif event_type == 'payment.expired':
handle_payment_expired(data)
elif event_type == 'transfer.payid.completed':
handle_payid_transfer(data)
elif event_type == 'transfer.external.completed':
handle_external_transfer(data)
elif event_type == 'webhook.test':
handle_webhook_test(data)
else:
print(f'Unknown webhook event: {event_type}')
return jsonify({'success': True})
except Exception as e:
print(f'Webhook processing error: {e}')
return jsonify({'error': 'Internal server error'}), 500
def handle_virtual_account_payment(data, source):
"""Handle virtual account payment webhook"""
print(f'Virtual Account Payment Received: {data["accountNumber"]} - {data["amount"]/100} NGN')
print(f'Source: {data["source"]}') # "checkout_api" or "checkout_modal"
print(f'Merchant Key: {data["merchantKey"]}') # "sk_..." or "pub_..."
# Update database, send confirmation, etc.
def handle_payid_payment(data, source):
"""Handle PayID payment webhook"""
print(f'PayID Payment Received: {data["payId"]} - {data["amount"]/100} NGN')
print(f'Source: {data["source"]}') # "checkout_api" or "checkout_modal"
print(f'Merchant Key: {data["merchantKey"]}') # "sk_..." or "pub_..."
# Update database, send confirmation, etc.
def handle_payment_failed(data):
"""Handle payment failed webhook"""
print(f'Payment Failed: {data["reference"]} - {data["failureReason"]}')
# Update order status, send notification, etc.
def handle_payment_expired(data):
"""Handle payment expired webhook"""
print(f'Payment Expired: {data["reference"]}')
# Update order status, send notification, etc.
def handle_payid_transfer(data):
"""Handle PayID transfer webhook"""
print(f'PayID Transfer Completed: {data["fromPayId"]} -> {data["toPayId"]}')
# Update transfer records, send notification, etc.
def handle_external_transfer(data):
"""Handle external transfer webhook"""
print(f'External Transfer Completed: {data["fromPayId"]} -> {data["toAccount"]}')
# Update transfer records, send notification, etc.
def handle_webhook_test(data):
"""Handle webhook test"""
print(f'Webhook Test: {data["message"]}')
@app.route('/webhook/health', methods=['GET'])
def health_check():
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat()
})
if __name__ == '__main__':
app.run(port=5000, debug=True)
PHP Implementationโ
<?php
// webhook.php
function verifyWebhookSignature($payload, $signature, $secret) {
// Remove 'sha256=' prefix if present
$cleanSignature = str_replace('sha256=', '', $signature);
$expectedSignature = hash_hmac('sha256', $payload, $secret);
return hash_equals($cleanSignature, $expectedSignature);
}
function verifyTimestamp($timestamp, $tolerance = 300000) { // 5 minutes
$now = round(microtime(true) * 1000);
$webhookTime = intval($timestamp);
return abs($now - $webhookTime) <= $tolerance;
}
// Get webhook data
$input = file_get_contents('php://input');
$payload = json_decode($input, true);
// Get headers
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$event = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
// Verify signature
if (!verifyWebhookSignature($input, $signature, $_ENV['INPAY_SECRET_KEY'])) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Validate timestamp
if (!verifyTimestamp($timestamp)) {
http_response_code(400);
echo json_encode(['error' => 'Timestamp too old']);
exit;
}
// Process webhook
$eventType = $payload['event'] ?? '';
$data = $payload['data'] ?? [];
switch ($eventType) {
case 'payment.virtual_account.completed':
handleVirtualAccountPayment($data, 'API');
break;
case 'payment.virtual_payid.completed':
handlePayIdPayment($data, 'API');
break;
case 'payment.checkout_virtual_account.completed':
handleVirtualAccountPayment($data, 'Modal');
break;
case 'payment.checkout_payid.completed':
handlePayIdPayment($data, 'Modal');
break;
case 'payment.failed':
handlePaymentFailed($data);
break;
case 'payment.expired':
handlePaymentExpired($data);
break;
case 'transfer.payid.completed':
handlePayIdTransfer($data);
break;
case 'transfer.external.completed':
handleExternalTransfer($data);
break;
case 'webhook.test':
handleWebhookTest($data);
break;
default:
error_log("Unknown webhook event: $eventType");
}
// Always return success
http_response_code(200);
echo json_encode(['success' => true]);
function handleVirtualAccountPayment($data, $source) {
error_log("Virtual Account Payment: {$data['accountNumber']} - " . ($data['amount'] / 100) . " NGN");
error_log("Source: {$data['source']}"); // "checkout_api" or "checkout_modal"
error_log("Merchant Key: {$data['merchantKey']}"); // "sk_..." or "pub_..."
// Update database, send confirmation, etc.
}
function handlePayIdPayment($data, $source) {
error_log("PayID Payment: {$data['payId']} - " . ($data['amount'] / 100) . " NGN");
error_log("Source: {$data['source']}"); // "checkout_api" or "checkout_modal"
error_log("Merchant Key: {$data['merchantKey']}"); // "sk_..." or "pub_..."
// Update database, send confirmation, etc.
}
function handlePaymentFailed($data) {
error_log("Payment Failed: {$data['reference']} - {$data['failureReason']}");
// Update order status, send notification, etc.
}
function handlePaymentExpired($data) {
error_log("Payment Expired: {$data['reference']}");
// Update order status, send notification, etc.
}
function handlePayIdTransfer($data) {
error_log("PayID Transfer: {$data['fromPayId']} -> {$data['toPayId']}");
// Update transfer records, send notification, etc.
}
function handleExternalTransfer($data) {
error_log("External Transfer: {$data['fromPayId']} -> {$data['toAccount']}");
// Update transfer records, send notification, etc.
}
function handleWebhookTest($data) {
error_log("Webhook Test: {$data['message']}");
}
?>
๐ Webhook Deliveryโ
Retry Logicโ
- Retry attempts: Up to 3 retry attempts with exponential backoff
- Delivery tracking: All webhook deliveries are tracked in database
- Status monitoring: Real-time delivery status monitoring available
- Failure handling: Failed webhooks are queued for retry
Delivery Trackingโ
All webhook deliveries are tracked with the following statuses:
- pending: Queued for delivery
- sending: Currently being sent
- delivered: Successfully delivered (HTTP 200 response)
- failed: Delivery failed (non-200 response or timeout)
- timeout: Request timed out
- retrying: In retry queue
Retry Policyโ
- Max Retries: 3 attempts
- Retry Delays: 30s, 5min, 30min
- Timeout: 10 seconds per attempt
- Exponential Backoff: Yes
๐งช Testing Webhooksโ
Using ngrok for Local Developmentโ
# Install ngrok
npm install -g ngrok
# Start your webhook server
node webhook-server.js
# In another terminal, expose your local server
ngrok http 3000
# Use the HTTPS URL provided by ngrok as your webhook URL
# Example: https://abc123.ngrok.io/webhook
Webhook Testing Checklistโ
- Webhook endpoint is accessible via HTTPS
- Signature verification is working
- Timestamp validation is implemented
- All event types are handled
- Database updates are working
- Error handling is in place
- Logging is configured
- Idempotency is handled (duplicate webhook processing)
๐จ Troubleshootingโ
Common Issuesโ
1. Invalid Signature Errorโ
Error: Invalid signature
Solution: Ensure you're using the correct secret key and that the payload is not modified before verification.
2. Timestamp Too Old Errorโ
Error: Timestamp too old
Solution: Check your server's system time and allow reasonable tolerance for clock skew.
3. Webhook Not Receivedโ
Possible Causes:
- Webhook URL is not accessible
- Firewall blocking requests
- SSL certificate issues
- Server is down
Solutions:
- Test webhook URL accessibility
- Check server logs
- Verify SSL certificate
- Use webhook testing tools
4. Duplicate Webhooksโ
Solution: Implement idempotency using the transactionId field:
async function handleWebhook(data) {
// Check if already processed
const existing = await database.findTransaction(data.transactionId);
if (existing) {
console.log('Webhook already processed:', data.transactionId);
return;
}
// Process webhook
await processWebhook(data);
// Mark as processed
await database.saveTransaction(data.transactionId);
}
Debugging Signature Verificationโ
If signature verification is failing, use this debugging code:
function debugSignatureVerification(payload, signature, secretKey) {
// Remove 'sha256=' prefix if present
const cleanSignature = signature.replace(/^sha256=/, '');
const expectedSignature = crypto
.createHmac('sha256', secretKey)
.update(payload, 'utf8')
.digest('hex');
console.log('=== Signature Debug Info ===');
console.log('Raw signature header:', signature);
console.log('Clean signature:', cleanSignature);
console.log('Expected signature:', expectedSignature);
console.log('Signatures match:', cleanSignature === expectedSignature);
console.log('Payload length:', payload.length);
console.log('Secret key length:', secretKey.length);
console.log('===========================');
return cleanSignature === expectedSignature;
}
Monitoring Webhooksโ
// Add monitoring to your webhook handler
app.post('/webhook', (req, res) => {
const startTime = Date.now();
try {
// Process webhook
processWebhook(req.body);
// Log successful processing
console.log('Webhook processed successfully', {
event: req.headers['x-webhook-event'],
processingTime: Date.now() - startTime,
timestamp: new Date().toISOString()
});
res.status(200).json({ success: true });
} catch (error) {
// Log error
console.error('Webhook processing failed', {
error: error.message,
event: req.headers['x-webhook-event'],
processingTime: Date.now() - startTime,
timestamp: new Date().toISOString()
});
res.status(500).json({ error: 'Processing failed' });
}
});
๐ก Best Practicesโ
โ Do'sโ
- Always verify signatures - Never process webhooks without signature verification
- Implement idempotency - Handle duplicate webhooks gracefully using
transactionId - Log everything - Keep detailed logs for debugging and monitoring
- Return 200 quickly - Acknowledge receipt before processing
- Handle errors gracefully - Don't let webhook processing break your system
- Use HTTPS - Always use secure connections for webhook endpoints
- Monitor performance - Track webhook processing times and success rates
- Test thoroughly - Test all webhook scenarios before going live
- Validate timestamps - Check webhook timestamps to prevent replay attacks
โ Don'tsโ
- Don't ignore signatures - Always verify webhook authenticity
- Don't return errors - Return 200 even if processing fails internally
- Don't block processing - Keep webhook handlers fast and efficient
- Don't skip error handling - Handle all possible error scenarios
- Don't store sensitive data - Don't log or store sensitive webhook data
- Don't ignore timestamps - Always validate webhook timestamps
๐ Important Notesโ
Amount Formatโ
- All amounts are in kobo (Nigerian currency subunit)
- Example: โฆ5,000.00 = 500000 kobo
- Conversion: 1 Naira = 100 Kobo
Webhook URL Securityโ
- Only HTTPS URLs are accepted
- Localhost and private IP addresses are blocked
- URLs are validated against security blacklists
- Maximum URL length: 2048 characters
Rate Limitingโ
- Maximum 5 webhook URL changes per hour
- 24-hour block for excessive changes
- Webhook delivery rate: No artificial limits
Environmentโ
- Production: All webhooks are sent to production URLs
- Development: Test webhooks use test endpoints
- Environment identification: Available in metadata
๐ Webhook Monitoringโ
Dashboard Accessโ
- View webhook delivery history
- Monitor success/failure rates
- Retry failed webhooks
- Test webhook endpoints
Loggingโ
- All webhook attempts are logged
- Delivery status tracked
- Error details captured
- Performance metrics available
๐ Supportโ
For webhook-related issues:
- Check webhook delivery status on iNPAY Checkout Dashboard
- Verify signature and timestamp validation
- Ensure webhook endpoint returns HTTP 200
- Contact support for delivery issues
Remember: Always return HTTP 200 to confirm webhook receipt, regardless of internal processing success or failure.
Next Steps:
- API Reference - Explore all available endpoints
- GraphQL API - Learn about GraphQL operations
- Resources - Addons and integration tools