Payment notifications
Gafiapay sends a webhook to your server when a payment lands in one of your virtual accounts or a refund finishes, so you don't have to poll the API.
Events
| Event | Sent when |
|---|---|
payment.received | A customer's transfer to one of your virtual accounts, permanent or temporary, has been added to your balance. |
refund.completed | A refund reaches the customer. |
refund.failed | A refund doesn't go through and the amount is returned to your balance. |
Set up your endpoint
-
Create an HTTPS endpoint
Add a public HTTPS route on your server that accepts
POSTrequests. -
Add the URL in your dashboard
Go to Settings → Webhook Settings in the Gafiapay dashboard and save your webhook URL. Gafiapay doesn't take a webhook URL per request.
Payload
Every webhook has an event name and carries the transaction under data.transaction:
{
"event": "payment.received",
"data": {
"transaction": {
"id": "68a990e4917e8283a1351fc6",
"orderNo": "MI1959193613179124912",
"email": "customer@example.com",
"amount": 1000,
"currency": "NGN",
"status": "completed",
"charges": {
"amount": 10,
"type": "payment",
"chargeType": "percentage",
"percentage": 1
},
"metadata": {
"payerAccountName": "John Doe",
"payerAccountNo": "9023456789",
"payerBankName": "Access Bank",
"virtualAccountNo": "6696342391",
"grossAmount": 1000,
"netAmount": 990,
"chargeAmount": 10
},
"description": "incoming payment of NGN1000 from John Doe (Access Bank)"
}
},
"timestamp": "1756000000000",
"signature": "9c1f..."
}amount is what the customer sent. metadata.netAmount is what was added to your balance after fees. Keep orderNo and id: you need one of them to refund the deposit.
Verify every webhook
Before you credit anything, check that the request really came from Gafiapay:
- IP allowlist. Only accept webhooks from Gafiapay's IP address,
38.242.149.154. - Signature. Each webhook has an
x-signatureheader: an HMAC SHA-256 of thex-timestampheader, made with your secret key. Compute it yourself and compare. - Timestamp. Use the
x-timestampheader as an extra check on when the webhook was sent.
Headers to check
x-signature string x-timestamp, made with your secret key, as a hex string.x-timestamp string 1756000000000.x-forwarded-for string 38.242.149.154. Check it against the allowlist.Example handler
Each example checks the IP, verifies the signature, then reads the transaction. Respond with 200 once you've recorded it.
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.GAFIAPAY_SECRET_KEY;
const ALLOWED_IPS = ['38.242.149.154'];
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
const ip_address = req.headers['x-forwarded-for'] || req.ip;
const timestamp = req.headers['x-timestamp'];
const payload = req.body;
// Validate IP address
if (!ALLOWED_IPS.includes(ip_address)) {
console.log('Invalid IP address:', ip_address);
return res.status(403).send('Permission denied, invalid IP address');
}
// Verify signature
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(timestamp)
.digest('hex');
if (signature !== expectedSignature) {
return res.status(403).send('Invalid signature');
}
// Process webhook
const { data } = payload;
const { transaction } = data;
console.log('Payment received:', {
id: transaction.id,
orderNo: transaction.orderNo,
email: transaction.email,
amount: transaction.amount,
currency: transaction.currency,
status: transaction.status,
charges: transaction.charges,
metadata: transaction.metadata,
description: transaction.description
});
// Update user balance and create transaction record
// Your business logic here
res.status(200).send('Transaction received successfully');
});
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});from flask import Flask, request, jsonify
import hmac
import hashlib
import os
app = Flask(__name__)
WEBHOOK_SECRET = os.getenv('GAFIAPAY_SECRET_KEY')
ALLOWED_IPS = ['38.242.149.154']
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('x-signature')
ip_address = request.headers.get('x-forwarded-for', request.remote_addr)
timestamp = request.headers.get('x-timestamp')
payload = request.get_json()
# Validate IP address
if ip_address not in ALLOWED_IPS:
print('Invalid IP address:', ip_address)
return jsonify({'error': 'Permission denied, invalid IP address'}), 403
# Verify signature
expected_signature = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
timestamp.encode('utf-8'),
hashlib.sha256
).hexdigest()
if signature != expected_signature:
return jsonify({'error': 'Invalid signature'}), 403
# Process webhook
data = payload.get('data', {})
transaction = data.get('transaction', {})
print('Payment received:', {
'id': transaction.get('id'),
'orderNo': transaction.get('orderNo'),
'email': transaction.get('email'),
'amount': transaction.get('amount'),
'currency': transaction.get('currency'),
'status': transaction.get('status'),
'charges': transaction.get('charges'),
'metadata': transaction.get('metadata'),
'description': transaction.get('description')
})
# Update user balance and create transaction record
# Your business logic here
return jsonify({'message': 'Transaction received successfully'})
if __name__ == '__main__':
app.run(port=3000)<?php
$webhook_secret = $_ENV['GAFIAPAY_SECRET_KEY'];
$allowed_ips = ['38.242.149.154'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'];
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$payload = file_get_contents('php://input');
// Validate IP address
if (!in_array($ip_address, $allowed_ips)) {
error_log('Invalid IP address: ' . $ip_address);
http_response_code(403);
echo json_encode(['error' => 'Permission denied, invalid IP address']);
exit;
}
// Verify signature
$expected_signature = hash_hmac('sha256', $timestamp, $webhook_secret);
if ($signature !== $expected_signature) {
http_response_code(403);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Process webhook
$data = json_decode($payload, true);
$transaction = $data['data']['transaction'] ?? [];
error_log('Payment received: ' . json_encode([
'id' => $transaction['id'] ?? '',
'orderNo' => $transaction['orderNo'] ?? '',
'email' => $transaction['email'] ?? '',
'amount' => $transaction['amount'] ?? '',
'currency' => $transaction['currency'] ?? '',
'status' => $transaction['status'] ?? '',
'charges' => $transaction['charges'] ?? '',
'metadata' => $transaction['metadata'] ?? '',
'description' => $transaction['description'] ?? ''
]));
// Update user balance and create transaction record
// Your business logic here
http_response_code(200);
echo json_encode(['message' => 'Transaction received successfully']);
} else {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}require 'sinatra'
require 'json'
require 'openssl'
require 'dotenv'
Dotenv.load
WEBHOOK_SECRET = ENV['GAFIAPAY_SECRET_KEY']
ALLOWED_IPS = ['38.242.149.154']
post '/webhook' do
signature = request.env['HTTP_X_SIGNATURE']
ip_address = request.env['HTTP_X_FORWARDED_FOR'] || request.ip
timestamp = request.env['HTTP_X_TIMESTAMP']
payload = request.body.read
# Validate IP address
unless ALLOWED_IPS.include?(ip_address)
puts 'Invalid IP address: ' + ip_address
status 403
return { error: 'Permission denied, invalid IP address' }.to_json
end
# Verify signature
expected_signature = OpenSSL::HMAC.hexdigest('sha256', WEBHOOK_SECRET, timestamp)
if signature != expected_signature
status 403
return { error: 'Invalid signature' }.to_json
end
# Process webhook
data = JSON.parse(payload)
transaction = data['data']['transaction'] || {}
puts 'Payment received: ' + {
id: transaction['id'],
orderNo: transaction['orderNo'],
email: transaction['email'],
amount: transaction['amount'],
currency: transaction['currency'],
status: transaction['status'],
charges: transaction['charges'],
metadata: transaction['metadata'],
description: transaction['description']
}.to_json
# Update user balance and create transaction record
# Your business logic here
{ message: 'Transaction received successfully' }.to_json
end
set :port, 3000import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import org.json.JSONObject;
import java.util.Arrays;
import java.util.List;
public class WebhookServer {
private static final String WEBHOOK_SECRET = System.getenv("GAFIAPAY_SECRET_KEY");
private static final List<String> ALLOWED_IPS = Arrays.asList("38.242.149.154");
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(3000), 0);
server.createContext("/webhook", new WebhookHandler());
server.setExecutor(null);
server.start();
System.out.println("Webhook server running on port 3000");
}
static class WebhookHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if ("POST".equals(exchange.getRequestMethod())) {
String signature = exchange.getRequestHeaders().getFirst("x-signature");
String ipAddress = exchange.getRequestHeaders().getFirst("x-forwarded-for");
if (ipAddress == null) {
ipAddress = exchange.getRemoteAddress().getAddress().getHostAddress();
}
String timestamp = exchange.getRequestHeaders().getFirst("x-timestamp");
String payload = new String(exchange.getRequestBody().readAllBytes());
// Validate IP address
if (!ALLOWED_IPS.contains(ipAddress)) {
System.out.println("Invalid IP address: " + ipAddress);
sendResponse(exchange, 403, "{"error":"Permission denied, invalid IP address"}");
return;
}
// Verify signature
try {
String expectedSignature = generateSignature(timestamp, WEBHOOK_SECRET);
if (!signature.equals(expectedSignature)) {
sendResponse(exchange, 403, "{"error":"Invalid signature"}");
return;
}
} catch (Exception e) {
sendResponse(exchange, 403, "{"error":"Signature verification failed"}");
return;
}
// Process webhook
JSONObject data = new JSONObject(payload);
JSONObject transaction = data.getJSONObject("data").getJSONObject("transaction");
System.out.println("Payment received: " +
"ID: " + transaction.getString("id") + ", " +
"Order No: " + transaction.getString("orderNo") + ", " +
"Email: " + transaction.getString("email") + ", " +
"Amount: " + transaction.getInt("amount") + ", " +
"Currency: " + transaction.getString("currency") + ", " +
"Status: " + transaction.getString("status") + ", " +
"Description: " + transaction.getString("description"));
// Update user balance and create transaction record
// Your business logic here
sendResponse(exchange, 200, "{"message":"Transaction received successfully"}");
} else {
sendResponse(exchange, 405, "{"error":"Method not allowed"}");
}
}
private void sendResponse(HttpExchange exchange, int statusCode, String response) throws IOException {
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, response.length());
try (OutputStream os = exchange.getResponseBody()) {
os.write(response.getBytes());
}
}
private String generateSignature(String timestamp, String secret) throws NoSuchAlgorithmException, InvalidKeyException {
Mac sha256Hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256Hmac.init(secretKey);
byte[] signedBytes = sha256Hmac.doFinal(timestamp.getBytes(StandardCharsets.UTF_8));
return bytesToHex(signedBytes);
}
private String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
}
}The same webhook can arrive more than once. Save the event and the transaction's id together, and skip any pair you've already handled, so you never credit a payment twice.
Test locally
Expose your local server with a tunnel such as ngrok and save the public URL in the dashboard:
# Using ngrok to expose local server
ngrok http 3000
# Your webhook URL will be something like:
# https://abc123.ngrok.io/webhookTroubleshooting
Webhook not received
- Check that your webhook URL is publicly reachable.
- Check that your server is running and responding.
- Make sure the endpoint accepts
POSTrequests. - Look for errors in your server logs.
Signature verification failed
- Make sure you're using the right secret key.
- Read the raw request headers before any middleware changes them.
- Check that your HMAC matches the examples above.
IP check failed
- Make sure your server sees the real client IP.
- If you're behind a proxy or load balancer, read the IP from
x-forwarded-for. - Check that the IP matches the allowlist.