Webhooks allow your application to receive real-time notifications about transaction status changes. When a PayIn or PayOut transaction is completed or fails, we'll send a POST request to your configured callback URL.
All webhooks follow a consistent structure containing the event type, timestamp, and transaction data.
{
"event": "payin.successful",
"timestamp": 1770994473,
"data": {
"transaction_id": "TR260213.145421.B0EE88D4",
"tx_ref": "unique-tx-ref-123",
"amount": "1000.00",
"fee": "25.00",
"total_amount": "1025.00",
"currency": "CDF",
"status": "SUCCESS",
"channel": "mobile_money",
"payment_method": "mtn-ci",
"summary": "Payment received via MTN Mobile Money",
"failure_reason": null,
"created_at": "2026-02-13T14:54:21+00:00",
"updated_at": "2026-02-13T14:54:31+00:00",
"mobile_money": {
"phone_number": "0707070707",
"network": "mtn-ci",
"mno_transaction_id": "MP240213.1454.A12345"
},
"extras": {
"order_id": "ORD-123",
"customer_id": "CUST-456"
},
"account_balance": {
"previous_balance": "10000.00",
"current_balance": "11000.00",
"balance_type": "AVAILABLE"
}
}
}
{
"event": "payin.successful",
"timestamp": 1770994473,
"data": {
"transaction_id": "TR260213.145421.C1FF99E5",
"tx_ref": "unique-tx-ref-456",
"amount": "500.00",
"fee": "12.50",
"total_amount": "512.50",
"settled_amount": "500.00",
"currency": "USD",
"status": "SUCCESS",
"channel": "card",
"payment_method": "visa",
"summary": "Card payment received",
"failure_reason": null,
"bearer": "customer",
"card": {
"last4": "4242",
"brand": "VISA",
"scheme": "visa",
"issuer": "BNP PARIBAS",
"expiry_month": "12",
"expiry_year": "2028",
"issuer_country": "FR"
},
"extras": {
"order_id": "ORD-123"
},
"account_balance": {
"previous_balance": "1000.00",
"current_balance": "1500.00",
"balance_type": "AVAILABLE"
},
"created_at": "2026-02-13T14:54:21+00:00",
"updated_at": "2026-02-13T14:54:31+00:00"
}
}
{
"event": "payout.successful",
"timestamp": 1770994473,
"data": {
"transaction_id": "TR260213.145421.B0EE88D4",
"tx_ref": "123kj0e30m2",
"amount": "100.00",
"fee": "15.00",
"total_amount": "115.00",
"currency": "ZMW",
"status": "SUCCESS",
"channel": "mobile_money",
"payment_method": "airtel",
"summary": "Transfer completed successfully",
"failure_reason": null,
"created_at": "2026-02-13T14:54:21+00:00",
"updated_at": "2026-02-13T14:54:31+00:00",
"mobile_money": {
"phone_number": "260773317519",
"network": "airtel",
"mno_transaction_id": "MO240213.1454.B67890"
},
"extras": {
"test": "tes",
"order_id": "ORD-789"
},
"account_balance": {
"previous_balance": "895.00",
"current_balance": "780.00",
"balance_type": "AVAILABLE"
}
}
}
| Field | Type | Description |
|---|---|---|
event |
string | Event type: payin.successful, payin.failed, payin.pending, payin.cancelled, payout.successful, payout.failed, payout.pending, payout.cancelled |
timestamp |
integer | Unix timestamp when webhook was sent |
data |
object | Complete transaction details including status, amounts, and metadata |
data.card |
object|null | Card details — present only for PayIn transactions paid via card (channel: "card"). null otherwise. |
data.card.last4 |
string | Last 4 digits of the card number |
data.card.brand |
string | Card network brand (e.g. VISA, MASTERCARD) |
data.card.scheme |
string | Card scheme including the card type (e.g. Mastercard credit, Visa debit, Amex credit) |
data.card.issuer |
string | Name of the bank or institution that issued the card |
data.card.expiry_month |
string | Card expiry month (MM) |
data.card.expiry_year |
string | Card expiry year (YYYY) |
data.card.issuer_country |
string | ISO 3166-1 alpha-2 country code of the card-issuing bank (e.g. FR, US) |
The following events are sent via webhooks:
| Event | Description | When Triggered |
|---|---|---|
payin.successful |
PayIn completed successfully | Customer payment received and funds credited to your wallet |
payin.failed |
PayIn failed | Customer payment failed or was cancelled |
payout.successful |
PayOut completed successfully | Funds successfully sent to beneficiary |
payout.failed |
PayOut failed | Payout failed (e.g., invalid number, insufficient funds, provider error) |
payin.pending |
PayIn submitted to provider | Payment initiated and awaiting provider confirmation |
payin.cancelled |
PayIn cancelled | Customer cancelled the payment (e.g., dismissed card form) |
payout.pending |
PayOut submitted to provider | Payout initiated and awaiting provider confirmation |
payout.cancelled |
PayOut cancelled | Payout was cancelled before processing |
https://genesyspay.com/api/v2/webhooks/resend/{identifier}
Manually trigger a webhook resend for a completed or failed transaction. This is useful if your server was down or failed to process the original webhook.
| Parameter | Type | Required | Description |
|---|---|---|---|
identifier |
string | Required | Transaction ID or tx_ref of the transaction |
curl -X POST https://genesyspay.com/api/v2/webhooks/resend/TR260213.145421.B0EE88D4 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_PRIVATE_KEY"
curl -X POST https://genesyspay.com/api/v2/webhooks/resend/unique-tx-ref-123 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_PRIVATE_KEY"
{
"status": "success",
"message": "Webhook resend queued successfully",
"data": {
"transaction_id": "TR260213.145421.B0EE88D4",
"tx_ref": "unique-tx-ref-123"
}
}
| HTTP Status | Error Code | Description |
|---|---|---|
| 404 | NOT_FOUND |
Transaction not found or not eligible for webhook resend (must be SUCCESS or FAILED) |
| 422 | VALIDATION_ERROR |
Invalid identifier format |
| 401 | UNAUTHORIZED |
Invalid or missing API key |
| 500 | INTERNAL_ERROR |
Failed to queue webhook resend |
{
"status": "error",
"message": "Transaction not found or not eligible for webhook resend",
"error_code": "NOT_FOUND"
}
To ensure webhooks are coming from our servers and not malicious third parties, we include a signature in the X-Webhook-Signature header.
The signature is generated using HMAC SHA256 with your webhook secret key:
// PHP Example
$payload = file_get_contents('php://input');
$signature = hash_hmac('sha256', $payload, $your_webhook_secret);
$receivedSignature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!hash_equals($signature, $receivedSignature)) {
http_response_code(403);
exit('Invalid signature');
}
$data = json_decode($payload, true);
// Process webhook...
// Node.js Example
const crypto = require('crypto');
const express = require('express');
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const signature = crypto
.createHmac('sha256', YOUR_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const receivedSignature = req.headers['x-webhook-signature'];
if (signature !== receivedSignature) {
return res.status(403).send('Invalid signature');
}
const data = JSON.parse(req.body.toString());
// Process webhook...
res.status(200).send('OK');
});
public function handleWebhook(Request $request)
{
// 1. Verify signature
$signature = hash_hmac('sha256', $request->getContent(), config('services.payment.webhook_secret'));
if (!hash_equals($signature, $request->header('X-Webhook-Signature', ''))) {
return response()->json(['error' => 'Invalid signature'], 403);
}
// 2. Respond immediately
// Queue for async processing
ProcessWebhook::dispatch($request->all());
return response()->json(['status' => 'received'], 200);
}
// In your job/queue handler
public function handle(array $webhookData)
{
$txRef = $webhookData['data']['tx_ref'];
$event = $webhookData['event'];
$status = $webhookData['data']['status'];
// Check if already processed (idempotency)
if (WebhookLog::where('tx_ref', $txRef)->exists()) {
return; // Already processed
}
// Log webhook
WebhookLog::create(['tx_ref' => $txRef, 'event' => $event]);
// Process based on event
switch ($event) {
case 'payin.successful':
// Update order status, fulfill order, etc.
// Check card details if card payment
if ($webhookData['data']['channel'] === 'card' && isset($webhookData['data']['card'])) {
$card = $webhookData['data']['card'];
// Store $card['brand'], $card['last4'], etc.
}
Order::where('tx_ref', $txRef)->update(['status' => 'paid']);
break;
case 'payin.pending':
// Payment submitted to provider, waiting for confirmation
Order::where('tx_ref', $txRef)->update(['status' => 'pending']);
break;
case 'payin.cancelled':
// Customer cancelled the payment
Order::where('tx_ref', $txRef)->update(['status' => 'cancelled']);
break;
case 'payout.successful':
// Mark payout as completed
Payout::where('tx_ref', $txRef)->update(['status' => 'completed']);
break;
case 'payout.pending':
Payout::where('tx_ref', $txRef)->update(['status' => 'pending']);
break;
case 'payout.cancelled':
Payout::where('tx_ref', $txRef)->update(['status' => 'cancelled']);
break;
case 'payin.failed':
case 'payout.failed':
// Handle failure
$reason = $webhookData['data']['failure_reason'];
// Log failure, notify user, etc.
break;
}
}
If your endpoint fails to respond with HTTP 200, we will automatically retry sending the webhook with the following schedule:
| Attempt | Delay |
|---|---|
| 1st retry | 1 minute after initial attempt |
| 2nd retry | 5 minutes after 1st retry |
| 3rd retry | 15 minutes after 2nd retry |
| 4th retry | 1 hour after 3rd retry |
| 5th retry (final) | 6 hours after 4th retry |
During development, you can test webhook handling using these approaches:
Tools like ngrok allow you to expose your local development server:
ngrok http 8000 # Use the generated HTTPS URL as your callback_url
Create a test transaction in sandbox mode, then use the resend endpoint to trigger webhooks:
curl -X POST https://genesyspay.com/api/v2/webhooks/resend/YOUR_TX_REF \ -H "Authorization: Bearer YOUR_PRIVATE_KEY"
Use services like webhook.site or requestbin.com to inspect webhook payloads during development.