Esc

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

EventSent when
payment.receivedA customer's transfer to one of your virtual accounts, permanent or temporary, has been added to your balance.
refund.completedA refund reaches the customer.
refund.failedA refund doesn't go through and the amount is returned to your balance.

Set up your endpoint

  1. Create an HTTPS endpoint

    Add a public HTTPS route on your server that accepts POST requests.

  2. 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:

payment.received
{
  "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-signature header: an HMAC SHA-256 of the x-timestamp header, made with your secret key. Compute it yourself and compare.
  • Timestamp. Use the x-timestamp header as an extra check on when the webhook was sent.

Headers to check

x-signature string
HMAC SHA-256 of x-timestamp, made with your secret key, as a hex string.
x-timestamp string
When the webhook was sent, in Unix milliseconds, for example 1756000000000.
x-forwarded-for string
The sender's IP address, for example 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');
});
Make your handler idempotent

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:

Shell
# Using ngrok to expose local server
ngrok http 3000

# Your webhook URL will be something like:
# https://abc123.ngrok.io/webhook

Troubleshooting

Webhook not received

  • Check that your webhook URL is publicly reachable.
  • Check that your server is running and responding.
  • Make sure the endpoint accepts POST requests.
  • 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.
GafiaPayGafiaPayDocs
Guide for AI agents Dashboard