PayElect Developer
Docs / Guides / Webhooks

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.createdPOST /payment/create succeeds and a payment session is opened
payment.pendingPayment is submitted but awaiting async provider confirmation (e.g. mobile money push)
payment.succeededPayment is confirmed successful — this is the event to fulfil the order on
payment.failedPayment attempt failed or was declined
payment.cancelledCustomer cancelled before completing payment
payment.expiredPayment session expired (token TTL elapsed without completion)
refund.succeededRefund was processed and funds returned to the payer
refund.failedRefund attempt failed; see failureReason
payout.createdPayout request accepted (may be pending approval)
payout.succeededFunds delivered to recipient
payout.failedPayout failed; see failureReason
split.settledAll split allocations for a payment have been credited to subaccounts
subaccount_payout.createdAn automatic subaccount payout was initiated after split settlement
subaccount_payout.succeededSubaccount payout delivered
subaccount_payout.failedSubaccount payout failed
dispute.createdA 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:

  1. Concatenate the Unix timestamp (t), a literal period (.), and the raw request body.
  2. Compute HMAC-SHA256 of that string using your webhook_secret as the key.
  3. Compare the hex digest to the v1 value 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:

AttemptDelay after previous
1 (initial)
25 minutes
315 minutes
41 hour
54 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
Sandbox webhooks are real. The sandbox fires real webhook deliveries to your registered sandbox webhook URL. Register a separate sandbox endpoint (e.g. a different ngrok session) and a separate production endpoint.