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. 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.
If your server does not return a 2xx response, the webhook is dropped and logged.
Webhook Format
All webhooks follow a standard structure. The plan or item field
depends on the purchase type.
Successful Purchase
{
"event": "data.purchase",
"reference": null,
"reference_code": "TRXDATA20260615100238ONI",
"status": "successful",
"message": "Data purchase completed.",
"plan": "MTN 1GB Monthly",
"recipient": "08031234567",
"amount": 500,
"completed_at": "2026-06-15T10:05:00+01:00"
}
Failed Purchase
{
"event": "data.purchase",
"reference": null,
"reference_code": "TRXDATA20260615100238ONI",
"status": "failed",
"message": "Transaction failed. Your wallet has been refunded.",
"plan": "MTN 1GB Monthly",
"recipient": "08031234567",
"amount": 500,
"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": null,
"reference_code": "TRXEE20260615104818CQ3",
"status": "successful",
"message": "Electricity purchase completed.",
"item": "Ikeja Electricity - IKEDC (PHCN)",
"recipient": "1234567890",
"amount": 5000,
"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 |
string/null | Client reference if provided, otherwise null |
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 other purchase types) |
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 (hex-encoded, 64 characters) |
Signature Algorithm
signature = HMAC_SHA256(
key = your_webhook_secret,
message = timestamp + "." + raw_request_body
)
Important:
• The webhook secret is the HMAC key
• The message is timestamp + "." + raw_body
• Use the raw body bytes exactly as received — do not re-encode or reformat
• The output is a hex-encoded string (64 characters)
Worked Example
Here's a real example to validate your implementation:
| Secret | PG_secrete_sxgfp8FCqwn9t58LLMKzUu6DM3V0GUgQ |
| Timestamp | 1786972573 |
| Raw Body | {"event":"data.purchase","reference":null,"reference_code":"TRXDATA20260817141421TB3","status":"failed","message":"Transaction failed. Your wallet has been refunded.","plan":"200MB (CG) - 14 Days","recipient":"08012345678","amount":92,"completed_at":"2026-08-17T14:16:13+01:00"} |
| Signature | a1b2c3d4e5f6789012345678abcdef0123456789abcdef0123456789abcdef01 |
Verification Examples
<?php
$secret = 'PG_secrete_YOUR_SECRET_HERE';
$timestamp = $_SERVER['HTTP_X_PAIRGATE_TIMESTAMP'];
$providedSignature = $_SERVER['HTTP_X_PAIRGATE_SIGNATURE'];
$rawBody = 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 . '.' . $rawBody;
$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
// Verify using constant-time comparison
if (hash_equals($expectedSignature, $providedSignature)) {
// Webhook is authentic - process it
$payload = json_decode($rawBody, true);
// Your processing logic here
} else {
http_response_code(401);
exit('Invalid signature');
}
const crypto = require('crypto');
// IMPORTANT: Capture raw body before JSON parsing
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
}
}));
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 rawBody = req.rawBody; // Use raw body, NOT 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 + '.' + rawBody;
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_body = request.get_data().decode('utf-8') # Raw body
# 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_body
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
- Use raw body bytes - Never re-encode or reformat the JSON
- 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
Troubleshooting
| Issue | Solution |
|---|---|
| Signature mismatch | Ensure you're using the raw JSON payload exactly as received, not re-encoded |
| 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 |
| Wrong argument order | The secret is the HMAC key, not the message |
| Using parsed JSON | Always use the raw body string, not req.body or request.json |
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.