Webhooks
PayElect sends signed HTTP POST requests to your registered endpoint whenever a significant event occurs. Webhooks are the primary way to receive async payment confirmations, refund outcomes, and payout results.
Event catalogue
| Event type | Trigger |
|---|---|
payment.created | POST /payment/create succeeds and a payment session is opened |
payment.pending | Payment is submitted but awaiting async provider confirmation (e.g. mobile money push) |
payment.succeeded | Payment is confirmed successful — this is the event to fulfil the order on |
payment.failed | Payment attempt failed or was declined |
payment.cancelled | Customer cancelled before completing payment |
payment.expired | Payment session expired (token TTL elapsed without completion) |
refund.succeeded | Refund was processed and funds returned to the payer |
refund.failed | Refund attempt failed; see failureReason |
payout.created | Payout request accepted (may be pending approval) |
payout.succeeded | Funds delivered to recipient |
payout.failed | Payout failed; see failureReason |
split.settled | All split allocations for a payment have been credited to subaccounts |
subaccount_payout.created | An automatic subaccount payout was initiated after split settlement |
subaccount_payout.succeeded | Subaccount payout delivered |
subaccount_payout.failed | Subaccount payout failed |
dispute.created | A dispute was opened against a payment (reserved — not yet live) |
Webhook payload structure
{
"eventId": "evt_01J4XYZABC123456789",
"eventType": "payment.succeeded",
"apiVersion": "2026-07-12",
"createdAt": "2026-07-12T09:45:01Z",
"environment": "production",
"data": {
"trxId": "TRX0000012345678",
"token": "pymt_01J4ABCDEFGHIJKLMNOPQRSTU",
"amount": "50000.00000000",
"currency": "GNF",
"status": "SUCCESS",
"custom": "order_id:8821",
"merchantId": 42
}
}
Signature verification
Every webhook request includes an X-PayElectHQ-Signature header:
X-PayElectHQ-Signature: t=1720773901,v1=a3f7b2c1d4e5f6...
The value is computed as:
- Concatenate the Unix timestamp (
t), a literal period (.), and the raw request body. - Compute HMAC-SHA256 of that string using your
webhook_secretas the key. - Compare the hex digest to the
v1value using a timing-safe comparison.
<?php
function verifyPayElectWebhook(string $rawBody, string $sigHeader, string $secret): bool
{
$parts = [];
foreach (explode(',', $sigHeader) as $part) {
[$k, $v] = explode('=', $part, 2);
$parts[$k] = $v;
}
$timestamp = $parts['t'] ?? '';
$received = $parts['v1'] ?? '';
if (abs(time() - (int) $timestamp) > 300) {
return false; // replay attack — timestamp older than 5 minutes
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $received);
}
const crypto = require('crypto');
function verifyPayElectWebhook(rawBody, sigHeader, secret) {
const parts = Object.fromEntries(
sigHeader.split(',').map(p => p.split('=', 2))
);
const timestamp = parts['t'] || '';
const received = parts['v1'] || '';
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return false; // replay attack
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(received, 'hex')
);
} catch {
return false;
}
}
import hmac
import hashlib
import time
def verify_payelecthq_webhook(raw_body: bytes, sig_header: str, secret: str) -> bool:
parts = dict(p.split('=', 1) for p in sig_header.split(','))
timestamp = parts.get('t', '')
received = parts.get('v1', '')
if abs(time.time() - int(timestamp)) > 300:
return False # replay attack
signed = f'{timestamp}.{raw_body.decode()}'
expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
Retry schedule
If your endpoint returns anything other than HTTP 200–299, PayElect retries with exponential backoff:
| Attempt | Delay after previous |
|---|---|
| 1 (initial) | — |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 1 hour |
| 5 | 4 hours |
| 6 (final) | 24 hours |
After 6 failed attempts the webhook delivery is marked as abandoned. You can re-trigger delivery from the merchant portal Developer → Webhooks → Event Log.
Deduplication
Store every processed eventId in your database and check for it before acting. The same event may be delivered more than once (e.g. if your endpoint times out). Make your handler idempotent.
// Laravel example using a processed_webhook_events table
if (ProcessedWebhookEvent::where('event_id', $event['eventId'])->exists()) {
return response('Already processed', 200);
}
DB::transaction(function () use ($event) {
fulfillOrder($event['data']['custom']);
ProcessedWebhookEvent::create(['event_id' => $event['eventId']]);
});
Out-of-order delivery
Network conditions can cause payment.succeeded to arrive before payment.created, or a payment.pending to arrive after payment.succeeded. Always use the data.status field from a GET /payment/status call as the canonical source of truth — never derive order state solely from event ordering.
Local testing with ngrok
# Install ngrok, then expose your local port
ngrok http 8000
# Copy the HTTPS URL and register it in the merchant portal
# e.g. https://a1b2c3d4.ngrok.io/webhook/payelecthq
# For Laravel, you can also use:
php artisan serve
# then register your ngrok URL as the webhook endpoint