Webhooks

LIVE
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
  1. Navigate to your API Dashboard
  2. Locate the Webhook Security section
  3. Toggle Webhook Verification to ON
  4. Your webhook secret will be generated and displayed only once
  5. 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
<?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');
}
Node.js (Express)
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');
    }
});
Python (Flask)
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
  1. Store your secret securely - Use environment variables or secure secret management
  2. Use raw body bytes - Never re-encode or reformat the JSON
  3. Always verify signatures - Verify authenticity before processing
  4. Check timestamps - Reject webhooks older than 5 minutes to prevent replay attacks
  5. Use constant-time comparison - Use hash_equals (PHP), timingSafeEqual (Node.js), or compare_digest (Python)
  6. Respond quickly - Return a 200 OK as 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.