Webhooks
LIVEQuick Summary
When a purchase reaches a final state, we send a POST request to your configured webhook URL. Webhooks are delivered within seconds of completion and retry up to 3 times on failure. Optional HMAC SHA-256 signature verification is available for enhanced security.
Setting Up
Your webhook URL is configured on your API key. Go to your dashboard to set or update it.
Delivery
Webhooks are sent as POST requests with a JSON body. Expect a response time of under 10 seconds.
Webhook Format
All webhooks follow a standard structure. The plan or item field
depends on the purchase type.
Successful Purchase
{
"event": "data.purchase",
"reference_code": "TRXDATA20260615100238ONI",
"status": "successful",
"message": "Data purchase completed.",
"plan": "MTN 1GB Monthly",
"recipient": "08031234567",
"amount": 500.00,
"completed_at": "2026-06-15T10:05:00+01:00"
}
Failed Purchase
{
"event": "data.purchase",
"reference_code": "TRXDATA20260615100238ONI",
"status": "failed",
"message": "Transaction failed. Your wallet has been refunded.",
"plan": "MTN 1GB Monthly",
"recipient": "08031234567",
"amount": 500.00,
"completed_at": "2026-06-15T10:05:00+01:00"
}
With Token or Pin
For electricity and exam purchases, a pin field is included when the token or pin is generated:
{
"event": "electricity.purchase",
"reference_code": "TRXEE20260615104818CQ3",
"status": "successful",
"message": "Electricity purchase completed.",
"item": "Ikeja Electricity - IKEDC (PHCN)",
"recipient": "1234567890",
"amount": 5000.00,
"pin": "1234-5678-9012-3456-7890",
"completed_at": "2026-06-15T10:05:00+01:00"
}
Webhook Fields
| Field | Type | Description |
|---|---|---|
event |
string | Purchase event (e.g. data.purchase) |
reference_code |
string | Your transaction reference for status checks |
status |
string | successful or failed |
message |
string | Human-readable status description |
plan |
string | Plan name (for data and tv purchases) |
item |
string | Item name (for airtime, electricity, bet, and education purchases) |
recipient |
string | Phone number, smartcard, meter number, or customer ID |
amount |
float | Amount charged |
pin |
string | Token or exam pin (electricity and education only) |
completed_at |
string | Timestamp when the transaction was finalised |
Webhook Security & Verification
Pairgate provides optional webhook verification to ensure that incoming webhook requests are genuinely from Pairgate and haven't been tampered with during transmission. When enabled, each webhook request includes an HMAC SHA-256 signature that you can verify using your webhook secret.
Enabling Webhook Verification
- Navigate to your API Dashboard
- Locate the Webhook Security section
- Toggle Webhook Verification to ON
- Your webhook secret will be generated and displayed only once
- Copy and securely store your secret (e.g., in your environment variables)
Webhook Headers
When webhook verification is enabled, each request includes the following additional headers:
| Header | Description |
|---|---|
X-Pairgate-Timestamp |
Unix timestamp (in seconds) when the webhook was sent |
X-Pairgate-Signature |
HMAC SHA-256 signature of the payload |
How Signature Verification Works
- Concatenate the timestamp and raw JSON payload with a dot (`.`)
- Calculate the HMAC SHA-256 hash using your webhook secret as the key
- Compare your calculated signature with the
X-Pairgate-Signatureheader - Use constant-time comparison to prevent timing attacks
signed_payload = timestamp + "." + raw_json_payload
signature = HMAC_SHA256(secret, signed_payload)
Verification Examples
<?php
$secret = 'PG_secrete_YOUR_SECRET_HERE';
$timestamp = $_SERVER['HTTP_X_PAIRGATE_TIMESTAMP'];
$providedSignature = $_SERVER['HTTP_X_PAIRGATE_SIGNATURE'];
$rawPayload = file_get_contents('php://input');
// Optional: Verify timestamp is within 5 minutes
if (abs(time() - (int)$timestamp) > 300) {
http_response_code(400);
exit('Webhook expired');
}
// Calculate expected signature
$signedPayload = $timestamp . '.' . $rawPayload;
$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
// Verify using constant-time comparison
if (hash_equals($expectedSignature, $providedSignature)) {
// Webhook is authentic - process it
$payload = json_decode($rawPayload, true);
// Your processing logic here
} else {
http_response_code(401);
exit('Invalid signature');
}
const crypto = require('crypto');
app.post('/webhook', (req, res) => {
const secret = 'PG_secrete_YOUR_SECRET_HERE';
const timestamp = req.headers['x-pairgate-timestamp'];
const providedSignature = req.headers['x-pairgate-signature'];
const rawPayload = JSON.stringify(req.body);
// Optional: Verify timestamp is within 5 minutes
if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.status(400).send('Webhook expired');
}
// Calculate expected signature
const signedPayload = timestamp + '.' + rawPayload;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Verify using constant-time comparison
if (crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(providedSignature)
)) {
// Webhook is authentic - process it
res.status(200).send('OK');
} else {
res.status(401).send('Invalid signature');
}
});
import hmac
import hashlib
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
secret = 'PG_secrete_YOUR_SECRET_HERE'
timestamp = request.headers.get('X-Pairgate-Timestamp')
provided_signature = request.headers.get('X-Pairgate-Signature')
raw_payload = request.get_data().decode('utf-8')
# Optional: Verify timestamp is within 5 minutes
import time
if abs(time.time() - int(timestamp)) > 300:
return 'Webhook expired', 400
# Calculate expected signature
signed_payload = timestamp + '.' + raw_payload
expected_signature = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Verify using constant-time comparison
if hmac.compare_digest(expected_signature, provided_signature):
return 'OK', 200
else:
return 'Invalid signature', 401
Best Practices
- Store your secret securely - Use environment variables or secure secret management
- Always verify signatures - Verify authenticity before processing
- Check timestamps - Reject webhooks older than 5 minutes to prevent replay attacks
- Use constant-time comparison - Use
hash_equals(PHP),timingSafeEqual(Node.js), orcompare_digest(Python) - Respond quickly - Return a
200 OKas soon as possible - Store raw payload - Keep original payload for debugging and audit
Troubleshooting
| Issue | Solution |
|---|---|
| Signature mismatch | Ensure you're using the raw JSON payload exactly as received |
| Missing headers | Verification may not be enabled in your dashboard |
| Expired timestamp | Check server time synchronization |
| Lost secret | Toggle verification off and on to generate a new secret |
Retry Policy
If your server does not return a 2xx response, we retry:
| Attempt | Delay |
|---|---|
| 1st | 5 seconds |
| 2nd | 15 seconds |
| 3rd | 60 seconds |
After 3 failed attempts, the webhook is dropped and logged.
Verifying Webhooks
Use the reference_code to fetch the full transaction status from
GET /transaction/status
.
This serves as a backup if a webhook is missed.